Skip to content

Commit 424ccfb

Browse files
authored
Feat/sim harness (#2)
* fix(helix): track latest helix-relay:main config schema + add setup runbook Reconcile the embedded helix config with current helix-relay:main: - drop the removed `network_config: !Custom {...}` block (the relay now fetches chain spec + genesis from the beacon node at startup) - migrate `cores` to the 10-field CoresConfig (drop sub_workers; add decoder, simulator, top_bid, data_gatherer, block_merging, housekeeper) Bump the ethereum-package fork to the helix wait-for-genesis fix. Add docs/local-kurtosis-e2e.md, a full runbook (build CB image -> deploy devnet -> run cb-basic -> verify) with every gotcha (kurtosis 1.18.1 pin, the 3 helix fixes, pin-vs-latest coupling). * feat(sim): P1 harness — preflight gate + triage (observability-first) Introduce the `sim` Rust binary: the agent-drivable launch->triage->diagnose loop the release flow never had. Library-first (extract src/lib.rs so cb-verify, cb-orchestrator, and sim share one core, killing the duplication at the source). - `sim preflight <args-file>`: render + have the REAL relay/CB image parse the config in ~1s, before any ~10-min devnet spend. 3-valued verdict (Pass | Fail{field} | Inconclusive{reason}) so config-schema drift is caught as a labeled failure, never a masked runtime panic minutes into launch. Env / pre-genesis / pull issues classify Inconclusive, not a false Fail. - `sim triage <enclave>`: attach to a broken enclave, dump each non-RUNNING service's ROOT panic via a docker-logs fallback that pierces kurtosis's grpc/masking. Structured TriageReport JSON. - Gate wiring in run-and-verify.sh: preflight blocks launch on Fail; triage auto-fires on any launch failure and surfaces its JSON. Known digest-window limitation documented at the site (deferred to `sim run` in P2). Proven end-to-end vs ghcr.io/gattaca-com/helix-relay:main: valid cb-basic.yml -> pass/exit0/~8.5s; renamed `hostname` field -> Fail{hostname}/exit1/~0.3s. 77 tests green (pure cores TDD'd on real + held-out fixtures; IO wirings are Docker/kurtosis smoke checks per Law 4). Also lands docs/NORTH-STAR.md (ratified direction: full-Rust sim, owned helix mirror, dogfood one fork) and the P1 plan. * style(clippy): clear pre-existing warnings so CI can enforce -D warnings Mechanical clippy fixes across the pre-sim code (collapsible_if -> let-chains, while_let_loop -> for/by_ref, manual_strip -> strip_prefix, unnecessary_unwrap, useless_format/vec, needless_borrow, expect_fun_call). No behavior change; 77 tests still green. Two justified #[allow]s (with comments): too_many_arguments on the enclave pipeline launcher (an options struct would read worse), and dead_code on two relay data-API Deserialize targets whose unread fields document the wire schema. `cargo clippy --all-targets -- -D warnings` now passes clean, so every subsequent commit builds under a real warning gate. * docs(plan): P2 pivots from typed mirrors to Rust consolidation (post-grill) Three adversarial reviews + a direct diff killed the typed-serde-mirror plan: the "6 duplicated templates" premise is false (helix is byte-identical across all 6 scenarios; CB varies <=7 lines), typed helix gains no guard (types aren't importable; preflight stays the only check), and the serde mechanism is fragile (sentinel collisions: 2000 vs late_in_slot_time_ms, db_name==user=="helix"). A code trace also refuted "just delete templating" — the ethereum-package fills the {{ }} holes from runtime service-discovery values at launch. New plan: port the (already-DRY) Python templates verbatim into Rust const strings (holes stay literal — no serialization, no sentinel hazard); type only the assembly (Scenario enum + one Images map). Byte-identity to golden is the oracle. Fixes the live-wrong commit-boost/pbs image default along the way. * test(sim): hermetic golden config fixtures + byte-diff harness (P2 Task 0) The 6 Python-generated configs that produced the green e2e run, snapshotted with the baked-default images (commit-boost/commit-boost, public helix) so the fixtures are hermetic — not tied to the box's .env. `genmodel::assert_matches_golden` is the acceptance oracle for the verbatim port to come: byte-identity, naming the first differing line on mismatch. A guard test asserts the pbs image bug is absent from the fixtures. * feat(sim): sim generate — port config generation into Rust (P2 Task 1) Ports scripts/generate_kurtosis_configs.py into the sim binary. The config bodies are verbatim const/string templates (helix keeps its ~40 lines of binary-verified drift comments; the {{ }} runtime holes stay literal — the ethereum-package fills them at launch). Typing lives only at the assembly layer: a Scenario enum + one Images map with the CORRECT commit-boost/commit-boost default (fixing the live-wrong commit-boost/pbs Python default). All 6 scenarios reproduce their golden fixtures byte-for-byte (the acceptance oracle); cb-basic still passes sim preflight against the real helix image. Review fixes folded in (adversarial review before commit): - load_pubkeys returns Result; run assembles ALL bodies before writing any, so a missing/malformed mux keys file fails cleanly with nothing written (matches the Python's pre-write all-or-nothing), instead of panicking mid-generation with 5 stale files left behind. - run_in(keys_dir, env_path) injectable core for hermetic negative-path tests. - Scenario::ALL reordered to the true Python emission order; misleading generate_all_matches_goldens renamed to reflect it checks IO faithfulness (the hermetic golden match lives in scenario.rs on default images). 43 sim tests green (incl. missing-keys → clean-error + atomicity); clippy -D clean. * refactor(sim): retire the Python config generator; sim generate is the one source Deletes scripts/generate_kurtosis_configs.py (Law 1's root smell — it reverse-engineered helix's serde layout from binary panics) and the stale configs/example-kurtosis-config.yml hand-copy. `just generate-configs` now runs `sim generate`; the tracked cb-basic.yml regenerates byte-identically. Doc repointing: README image-default table fixed to the baked defaults (commit-boost/commit-boost, public helix — no more commit-boost/pbs), the config-gen section + repo-layout tree point at `sim generate`, and the runbook's helix-drift fix instruction now targets src/bin/sim/genmodel/helix.rs's HELIX_RELAY_CONFIG const instead of the deleted Python template. * docs(sim): mark P2 landed (consolidation) + flag Law 1 tension for J NORTH-STAR staged-plan P2 now reflects what actually shipped (verbatim port + assembly typing, not cb_common mirrors) with a note that the grill outcome tensions with Law 1's "built from cb_common structs" premise — J's call to revise the Law. P2 plan doc stamped LANDED with the commit trail. * docs(sim): record CB-preflight probe — partial coverage, defer trust call to J Probed commit-boost/commit-boost:kurtosis: CB parses eagerly + errors structured (chain-file-not-found at types.rs:442 is the clean post-deserialize PASS marker, no spec mount needed). BUT [pbs] is #[serde(flatten)] + const-defaults, so a renamed pbs field is silently defaulted and reaches PASS — a CB preflight would false-pass the likeliest drift class. Not shipping an instrument that can lie (pilot-breaks-the-instrument); deferred to J with the honest-Inconclusive vs partial-preflight tradeoff written up. * test(sim): guard the tracked cb-basic.yml against drifting from sim generate configs/generated/cb-basic.yml is tracked (render.rs's fixture + what preflight validates); a new test asserts sim generate reproduces it, so a hand-edit or a stale regen fails CI instead of silently diverging — the staleness class that rotted the old example config. * test(checks): Law 4 guard-branch tests for chain_health + payload_matching Adds decision-logic unit tests for the two Tier-1 checks that had zero: - chain_health: inverted-range → Fail; single-slot window → Skip (not a false pass on zero data) - payload_matching: empty relay set → Skip (not a false pass on zero comparisons) Only the pre-fetch guard branches are unit-testable today — both checks inline their real decision logic inside async fetch fns (no pure classifier seam, unlike cb_metrics.rs). The deeper boundaries stay uncovered pending a classifier extraction (flagged for P3). No production logic changed. * docs(plan): P3 check-trustworthiness — 3 confirmed false-greens + fix approach Consolidates the three code-verified false-greens (mux_routing pass-gate, relay_pipeline + payload_matching first-wins-union-by-slot) with their shared structural root cause: decision logic inlined in async fetch fns → no test seam → bugs hide. Fix = extract pure classifiers (the healthy cb_metrics.rs pattern), which serves Law 3 (no false greens) and Law 4 (testable verdict math) at once. Lays out the per-check judgment calls (warn-vs-fail, tier, CB-debug-logging) for J. Proposal only — verdict-changing, not started autonomously. * feat(sim): sim generate --check — CI/agent drift gate for the configs `sim generate --check` regenerates in-memory and verifies the on-disk configs already match, exiting nonzero (naming the drifted files) without writing — so CI or an agent can assert the checked-in configs are current in one command, instead of only catching drift via `cargo test`. Shares the assembly path with `run` so both fail identically on a bad mux keys file. * fix(ci): repoint integration nightly off the deleted example config Deleting configs/example-kurtosis-config.yml in the P2 retire commit left integration.yml pointing at a nonexistent file (my Task 2 grep missed .github/). Fix: generate cb-basic via `sim generate` and launch that, pinning the public commit-boost image the old example used (the nightly builds no CB image). Also align .env.example's documented defaults with Images::default and drop its reference to the retired Python generator. Flagged in-workflow: confirm the public pbs:latest tag is SSZ-current or switch to building the image. * style: cargo fmt the tree (CI fmt-check was red) This session's subagent edits (P1 sim bins, the clippy cleanup, P2 genmodel, the Law 4 tests) weren't run through rustfmt, so `cargo fmt --check` (a CI job) was failing. Pure formatting — rustfmt is AST-based and all 99 tests still pass; byte-identity of `sim generate` output is unaffected (string literals untouched). The branch now passes check + test + clippy + fmt-check. * docs(plan): ratify J's P3 verdict-fix decisions + implementation findings mux → WARN when unverifiable (debug logging already on in generated config); payload → detect per-(relay,slot) conflict, verdict stays WARN; best-bid → new arm comparing per-relay bid values from CB logs (keep delivered-count as coverage); CB-preflight → keep honest Inconclusive. * fix(checks): mux.routing WARNs when no routing decision is verifiable (P3) The false-green: mux.routing PASSed whenever there were no misrouting violations, even when ZERO routing decisions were actually checked (CB "using mux config" DEBUG events absent) — reporting "All N verified" where N counted raw log lines. Fix (J's call): extract a pure `classify_mux_routing` (the Law 4 test seam) and count `routing_decisions_verified` = events with a known pubkey AND a mux_id. If that's 0, WARN ("need CB debug logs") instead of PASS; PASS now counts verified decisions, not log lines. Debug logging is already on in the generated cb-mux config, so real runs stay green — only the unverifiable case flips to WARN. 4 decision tests added (pass / warn-unverifiable / warn-no-events / fail-misroute). * fix(checks): payload_hash_match detects cross-relay conflicts (P3) The false-green: `by_slot.entry(slot).or_insert(hash)` was first-wins union, so when two relays reported DIFFERENT block_hash for one slot (relay equivocation — the thing the cross-check exists to catch) all but the first were dropped before the on-chain compare, and the verdict was order-dependent. Fix (J's call): collect every (relay, hash) per slot; extract a pure generic `classify_payload_matches` (Law 4 seam); WARN on a cross-relay conflict OR when no relay hash matches chain. `missed` stays informational (doesn't downgrade), matching prior behavior. 5 decision tests (u64 stands in for B256). * feat(checks): relay.best_bid — verify aggregated bidding by comparing bid values (P3) The false-green: check_payloads_delivered_multi unions by slot and counts distinct delivered slots, so one delivering relay scored identically to genuine two-relay aggregation — the "aggregated bidding" the multi-relay scenario exists to test was never actually checked. Fix (J's call): keep the delivered-count as a COVERAGE check and ADD a real best-bid arm. check_best_bid_selection gathers each relay's best bid per slot (max of its builder-blocks-received) + the delivered value, then classify_best_bid (a pure Law 4 seam) asserts: WARN if no slot had >=2 relays competing (aggregation never exercised — the anti-false-green), WARN if a competitive slot delivered LESS than the best available bid, else PASS. 4 decision tests (u64 stands in for U256). Data-semantics assumption to confirm in review: a relay's offered bid = max of its builder_blocks_received for the slot; delivered value is comparable to bid value. * fix(checks): payload conflict message no longer over-claims 'cross-relay' A single relay reporting two hashes for one slot is also a conflict; the message now says 'N distinct block hashes reported' rather than asserting cross-relay disagreement. Verdict unchanged (WARN). * revert: back out relay.best_bid — unsound data source (P3 review) The adversarial review found check_best_bid_selection's data source wrong: get_builder_blocks_received returns EVERY builder submission a relay received (including bids that failed simulation and were never offered to the proposer), so max(builder_blocks) overstates the relay's offered bid and would false-alarm "value left on the table" on correct runs. The ~11-slot sampling also made the "no competition" WARN the default even when aggregation works. The pure classify_best_bid verdict logic was sound; only the IO source was wrong. Correct design (P3 follow-up): source per-relay bids from the CB "received new header" log events (relay_id + slot + value_eth, already parsed by parse_cb_log_line, full-coverage not sampled), parse value_eth decimal->wei for Ord, and compare the delivered value to the max OFFERED bid per slot. That needs CB-log context (enclave + cb_service_names) plumbed into the check — a focused, separately-reviewed piece rather than a rushed trust-core patch. check_payloads_delivered_multi stays as the coverage check. mux.routing and payload_hash_match fixes are unaffected. * docs(plan): P3 status — mux+payload landed, best-bid backed out pending CB-log source Records the adversarial-review outcome: WARN is non-fatal (won't break CI), the best-bid data-source problem + correct follow-up design, and the lenient gaps left within J's ratified scope. * feat(checks): relay.best_bid v2 — bids from CB getHeader logs (P3 review fix) Re-adds the aggregated-bidding check with the CORRECT data source the review demanded. Per-relay OFFERED bids now come from CB's own "received new header" getHeader log events (relay_id + slot + value_eth, full-coverage, already parsed by parse_cb_log_line) — what CB actually compared — instead of the relay data API's builder_blocks_received (which includes bids that failed sim and were never offered, and was slot-sampled). Fixes both review findings: no false "value left on the table" alarms, and no sampling-induced "no competition" default. value_eth is parsed decimal->wei WITHOUT float (exact); bids and delivered are compared in GWEI so sub-gwei cross-source rounding can't spuriously warn. Single- relay runs SKIP. The pure classify_best_bid seam is unchanged. New module src/checks/best_bid.rs; wired in main.rs alongside the mux check (needs CB-log context). 4 tests incl. exact value_eth parsing. * fix(checks): best_bid — verify only slots with delivered data; drop gwei (P3 review 2) Second review of best_bid v2 found the crux sound ("received new header" IS the per-relay offered bid) but caught a reintroduced Law-3 false-green: competitive slots with NO delivered payload (out of window / missed) counted toward the PASS "verified across N" claim. Now the verdict is based on VERIFIED slots (competitive AND delivered present); if none are verified → WARN, never PASS. Also: window-filter the bid harvest to [start,end]; drop the gwei rounding (both sources are exact wei, so it only added a boundary artifact) and compare wei directly; soften the suboptimal message (dropped the unfounded "misrouting" claim). +1 guard test. * feat(just): human UX — build-cb-image, pull-images, one-command e2e Closes the from-scratch gap: cb-testing had no recipe to build the CB image (it lives in the sibling commit-boost-client repo) or pre-pull the public images. - build-cb-image [tag] [cb_dir] — delegates to ../commit-boost-client `just build-all` - pull-images — pre-pull helix/reth-rbuilder/lighthouse (public; helix isn't built) - e2e [config] — generate-configs + pull-images + testnet, one command README quickstart leads with the build-cb-image (once) -> e2e path. * perf(run): pre-build cb-verify before launch + host-memory advisory Two robustness fixes surfaced by the live-devnet validation: - Pre-build cb-verify (release) BEFORE `kurtosis run` and invoke the built binary, instead of `cargo run --release` mid-devnet — that compile is a multi-GB spike while 10 services are live. Keeps it off the critical path. - A host-memory advisory before launch (non-blocking; LOW_MEM_ABORT=1 to abort). NOTE: this is a general host-resource guard, NOT the cause of the relay OOMs seen on 2026-07-31 — those were per-container cgroup OOMs (CONSTRAINT_MEMCG) at the relays' own RELAY_MAX_MEMORY cap, addressed separately in the fork. * feat(sim): drop the flashbots relay — multi-relay = two helix instances Helix is industry-predominant; the flashbots mev-boost-relay is a memory hog (leaked ~825MB/min under spamoor). So the multi-relay scenarios (cb-multiple- relays, cb-timing-games, cb-mux) now run TWO helix instances instead of helix+flashbots. flashbots stays as the block BUILDER (reth-rbuilder). - scenario.rs relays() -> ["helix","helix"] for the three multi-relay scenarios; fixed the now-inaccurate comment prose baked into the configs. - cb.rs mux labels node_1_to_flashbots/mux_flashbots -> node_1_to_helix/mux_helix_1 (cosmetic; routing is positional {{ index .Relays 0/1 }}). - Regenerated the 3 multi-relay goldens (now the intended 2-helix output, not the old Python baseline — mod.rs provenance updated). - Bumps the ethereum-package submodule to the N-instance launcher (fbe3141). Validated on a live devnet: helix-relay-2 + helix-relay-3 both survive, and every multi-relay check PASSes on real data — best_bid 33 competitive/33 verified, payload_hash 33 matched/0 conflicts, delivered across 2 relays. (Residual overall FAIL is transient warmup 5xx: 1 get_header + 8 submit, down from 42+123 when the flashbots relay was OOM-dying.) * docs: sweep synthesis + new ARCH/CHECKS/fork-delta + fix flashbots-era staleness Beefs up documentation after the P1/P2/P3 + 2-helix work, from a six-lens sweep: NEW cross-cutting docs (the biggest gaps): - docs/CHECKS.md — authoritative per-check catalog: tier, pass/warn/fail contract, data source, and the load-bearing verdict rule (exit keys ONLY on tier-1 FAIL; WARN/SKIP non-fatal, so a consumer must parse JSON result, not just exit code). - docs/ARCH.md — how it all fits: sim generate → run-and-verify → cb-verify (discovery→probes→checks→report) → fork; module map; key decisions. - docs/fork-delta.md — the ethereum-package divergence from upstream (mev_resolver component model, helix wait-for-genesis + N-instance + 8GB cap, rebase notes). - docs/SWEEP-BACKLOG.md — the prioritized backlog (bugs/docs/tests/perf/refactor/ features); docs/plans/INDEX.md — landed-vs-live plan classification. FIXES to now-stale docs: - README + .env.example: multi-relay = two helix instances (flashbots relay dropped, builder only); added relay.best_bid + sim verb contract + --skip-finalization-check + a pointer to CHECKS.md. - Re-stamped P1/P2/P3 plan statuses (all shipped; read "uncommitted/backed-out"). * fix(orchestrator): cb-verify flag is --config, not --cb-config cb-orchestrator (just test-all/test-one) invoked the cb-verify binary with --cb-config, which cb-verify's clap doesn't define, so clap rejected it and the batch runner failed at every check invocation. Surfaced by the doc sweep. * fix(checks): C1 relay-death false-green + H3 mev-rate first-relay undercount C1 (CRITICAL): when all relays are unreachable at check time (mid-run OOM death — the exact scenario this repo exists to catch), run_relay_checks emitted SKIP for the tier-1 relay.payloads_delivered_multi, and exit_code treats a tier-1 SKIP as pass → the run exits 0/PASS with the MEV pipeline unverified. Extracted a pure `all_relays_dead_results` that FAILs the tier-1 delivery check (tier-2/3 stay SKIP, already gated). Unit-tested. H3: check_mev_delivery_rate broke on the first relay that answered the data API, so under mux (each relay holds only its half of deliveries) it undercounted and spuriously WARNed. Now unions delivered payloads across ALL relays (like check_payloads_delivered_multi); SKIP only if NO relay answered. * fix(checks): H2 warmup-5xx false-red — rate-based tolerance, not any-5xx-fails The cb_*_matrix checks read absolute CUMULATIVE relay status-code counters (incl. the pre/early-window warmup phase) and FAILed on any 5xx>0, escalated to tier-1 — so a handful of transient warmup 5xx flipped a genuinely-healthy run to red (the df69c90 residual). classify_endpoint now classifies on the 5xx RATE: > MAX_5XX_RATE (25%) or --strict → FAIL (materially broken relay/CB-to-relay link); a nonzero but low rate → WARN (surfaced, non-fatal warmup noise). Test updated to the new contract + a high-rate FAIL case. * fix(checks): M4 finality gate + extract chain_health classifier seams M4: FINALITY_POSSIBLE_AFTER_SLOT 96 → 160 (5*32). check_finality FAILs unless finalized_epoch>=2, but epoch 2 first finalizes near the end of epoch 4 (~slot 160), not epoch 3 (slot 96) — justify(N+1)/finalize(N+2) lag. So any window ending in ~[96,160) ran the check on a chain that had only finalized epoch 1 → false tier-1 FAIL on a healthy chain. Default window (end_slot 288) unaffected. Extracted pure Law-4 seams with tests: classify_finality, classify_missed_slots (rate pass/warn), classify_cb_running (inspect-grep running/found-down/none). * fix(relay): M6 pagination robust to helix ordering/cursor (not flashbots) get_payloads_delivered assumed the flashbots data API's descending order + an advancing cursor. Helix's contract is unverified, risking undercount (ascending order → break after page 1) or 50× refetch (ignored cursor). Added a pure page_made_progress seam: stop when the cursor doesn't advance (bounds an ignored-cursor refetch to 2 pages) or when an in-range page adds no new slots (order-agnostic); collect in-range rows into a HashSet to dedup; short-page break keeps the normal single-page window fast. 5 tests; module doc updated to helix. * fix(ci): nightly integration — enclave-name bug, matrix, reproducibility, runner note - Pass --enclave "$ENCLAVE" to run-and-verify.sh so launch/verify/teardown all use the same enclave (it was set but never passed → teardown targeted the wrong enclave and the real one leaked). - Matrix over [cb-basic, cb-mux] (fail-fast: false), each in its own enclave — the mux/multi-relay/best_bid e2e paths had zero coverage before. - CB image via a cb_image workflow_dispatch input (schedule falls back to the default) instead of a bare inline :latest; REPRODUCIBILITY GAP + TODO to pin a digest documented inline. - Flagged the runner-memory requirement: a full devnet (esp. cb-mux's 2x8GB helix) won't fit a 7GB GitHub-hosted runner — needs a large/self-hosted runner. * fix(main): M5 drop the dead flashbots-Postgres post-mortem salvage The preflight salvage queried mev-relay-postgres / db mev_boost_relay / table mainnet_payload_delivered — all flashbots-mev-boost-relay specifics, dead after the 2-helix migration (relays are helix-relay-postgres-N with a different schema). So it always returned empty → the run failed with a MISLEADING "post-mortem found no delivery records" error instead of the real cause. Now that C1 makes relay death a clean tier-1 FAIL, dropped the salvage: relays-dead-at-preflight proceeds to the window and the relay checks report the failure. The orphaned discovery salvage fns are removed in the dead-code sweep (task 7). * refactor: delete duplicate diagnostic bins + confirmed-dead code Part A — deleted test_mux.rs (532 lines) + test_relay.rs (291 lines) and their Cargo [[bin]] blocks: they re-implemented library code (better-tested in mux_routing/discovery/relay), and test_mux's routing loop predated the P3 no-false-green fix — a REGRESSED copy that could print false greens. `just test-mux` repointed to `cb-verify --config`. Part B — dropped the crate-level #![allow(unused_imports/dead_code)] masks and deleted zero-call-site dead code: metrics sum/has/values helpers; beacon genesis/seconds-per-slot helpers; relay is_validator_registered; orchestrator's write-only EnclaveState; discovery's write-only relay_identities field + the orphaned flashbots post-mortem (PostMortemRecord/query_mev_relay_postgres/ parse_postmortem_output + tests — the M5 cleanup); diagnose CauseKind::Unknown. No verdict/check logic changed. 77+5+45 tests green, clippy -D + fmt clean. * docs: DEVELOPING.md (how to add checks/scenarios + dev loop) + README getting-started New docs/DEVELOPING.md: the contributor guide — dev loop/prereqs, the Law-4 classify_* seam pattern for adding a check (with the anti-pattern named), the Scenario/Images/golden flow for adding a scenario, the verdict contract, links to ARCH/CHECKS. Fixes three real newcomer-path gaps in the README quickstart: the Kurtosis 1.18.1 pin (not ">=0.90"), the submodule-init step missing from the ordered flow, and the read-the-report/exit-code pointer. Also drops the deleted test-relay refs + repoints test-mux to cb-verify. * perf: thin-LTO + parallelize serial beacon loops + parsed-port precedence No verdict/logic change — only how fast data is gathered: - Cargo.toml: lto=true → "thin" (fat LTO buys nothing at runtime for an I/O-bound binary but added 30-90s to every --release relink); tokio "full" → the used feature set; futures added as a direct dep. - The 3 serial per-slot beacon loops (check_mev_delivery_rate, check_missed_slots, check_payload_hash_match) now fetch with buffer_unordered(16) — ~2s → ~0.2s each. Folds are order-independent (commutative counters / slot-keyed maps), so the CheckResult is byte-identical; classifiers untouched. - discovery: try the already-parsed port before spawning a `kurtosis port print` subprocess per port (fallback preserved) — removes ~20-40 startup subprocesses. (check_cb_running's second inspect kept: its check-time freshness is load-bearing — reusing startup liveness would false-green a CB that dies mid-run.) * feat(report): provenance block + extract report::tier1_failed with exit-code tests VerificationReport now carries an optional Provenance { config_path, config_hash, images[] } gathered at run start, so a report is self-describing: a consumer (or `sim diff`) can tell WHICH config + images produced it without re-deriving from the environment. config_hash is a DefaultHasher over the config bytes; images are the resolved {role, name, id} refs. exit_code logic is extracted to report::tier1_failed (the tier-1 FAIL gate that main's verdict keys on) and pinned by 7 unit tests covering the full matrix: tier-1 FAIL -> 1, no tier-1 ran -> 2, WARN/SKIP/PASS-only -> 0. Previously this verdict was inline and untested — the one number a CI consumer trusts. * refactor(checks): CheckStatus worst-status Ord + classify seams for relay_pipeline CheckStatus gets an explicit `Ord` (severity Fail>Warn>Pass>Skip, NOT derive order) so worst-status aggregation is `.max()` over an iterator instead of a hand-rolled fold. Two of the three folds in run_relay_checks are rewired to it; the third is deliberately left (it's best-of-Pass-wins + Skip->Fail collapse, the opposite of worst-status — a comment marks why). Two pure verdict seams extracted (Law 4) with both-sides tests: classify_mev_rate(mev_blocks, total, threshold) and classify_registrations(registered, missing). discovery gains test_parse_services_from_fixture pinning the kurtosis text-table parse. 13 new tests (lib 84 -> 97). * feat(sim): `sim checks --list [--json]` catalog + `sim doctor` host preflight Two agent-facing discovery verbs on the sim binary. `sim checks --list` emits a machine-readable catalog of all 17 cb-verify checks — {id, tier, title, data_source, feature_asserted, severity_note} — so an agent can learn the harness contract (and the verdict rule: exit code keys only on tier-1 FAIL; WARN/SKIP non-fatal) without reading source or CHECKS.md. feature_asserted=true only for mux.routing and relay.best_bid. Hand-maintained (no single check registry exists to derive from yet); a module comment marks the sync obligation with src/checks + docs/CHECKS.md. `sim doctor` is a host-prerequisite preflight: kurtosis installed + pinned 1.18.1 (WARN on newer, citing the config-version-9 clash), docker reachable, >=18GB mem+swap headroom, CB image present, ethereum-package submodule non-empty. Pure classify(&Probes)->Report split from the IO gather layer; only kurtosis+docker are hard-fail, the rest advisory. 14 new tests (sim bin 45 -> 59). * feat(sim): `sim diff` — verdict/provenance regression gate between two reports Makes the report a round-trippable JSON interchange format (report + check types now derive Deserialize, with #[serde(default)] on the optional/skipped fields) and adds `sim diff <a.json> <b.json> [--json]`. diff_reports (pure Law-4 seam) compares two reports: per-check verdict changes classified by CheckStatus severity (Regressed = severity up, Improved = down), added/removed checks, config_hash delta, and per-role image id/name deltas. Exits nonzero iff a check got a strictly-worse verdict — the CI shape being "bump an image, re-run, sim diff old new, fail on regression". A check dropping to SKIP is surfaced but not a regression (the report's own tier-1 gate owns pass/fail, not the diff). 9 unit tests incl. a serialize->deserialize->diff round-trip (the point of the Deserialize derive). sim bin 59 -> 68. * feat(checks): Law-3 feature-fired assertions for the toggle scenarios skip-sigverify / extra-validation / timing-games were non-tests: they enable a CB feature then assert only what cb-basic does. Add a config-driven check that proves each enabled feature's codepath actually FIRED at runtime, via CB debug logs (the scenarios already set [logs.stdout] level = debug): - feature.timing_games -> PASS on >=1 "TG:" debug line (send_timed_get_header) - feature.extra_validation -> PASS on >=1 "fetched parent block" line - feature.skip_sigverify -> ALWAYS an honest WARN: it is a NEGATIVE codepath (sigverify simply not called, no success log/metric) and is indistinguishable from OFF on the happy path without a bad-signature relay. Never falsely green. A marker feature enabled-but-unobserved WARNs (could be no getHeader in the window), never FAILs — the same no-false-red discipline as the mux check. All verdict logic is a pure classify_* seam (9 tests). Wired into the run pipeline behind the same --config gate as mux. mux_routing gains two shared primitives (read_cb_config_template, fetch_filtered_logs) reused here; fetch_service_logs keeps its signature so the diagnose caller is untouched. sim checks catalog + its feature-invariant test updated to the five feature-asserting checks. lib 97 -> 106. * docs: record Law-3 feature-fired checks + sim diff in CHECKS.md and backlog CHECKS.md: add feature.timing_games / .extra_validation / .skip_sigverify to the catalog table + a dedicated section; rewrite the "Law 3 gap" caveat as mostly-closed with skip_sigverify the residual (negative codepath, honest WARN). Backlog: mark #1/#9/#10/#11 done; file the bad-signature helix mock as the follow-up that would make skip_sigverify a real ON/OFF test. * docs: live-validation findings from the cb-timing-games devnet run Records that this session's shipped work validated on real infra (provenance populates with real image sha256s; feature.timing_games PASS on 1342 TG: log lines; best_bid PASS on 37 competitive slots; H2 rate-classifier discriminates). Files the timing-games get_header 47.5% 5xx tier-1 FAIL as a J design call: real high 5xx rate (not warmup) from aggressive polling, but the delivery pipeline was fully green — is it a false-red for the scenario? Not patched. * docs: confirm timing-games get_header FAIL is scenario-specific (run 2 clean) The second devnet run (cb-extra-validation, normal scenario) had cb_get_header_matrix PASS with 0 get_header 5xx → overall PASS. Confirms the H2 rate-classifier does not false-fail a green run, and the timing-games 47.5% 5xx is the aggressive config's own behavior. Also validated live: feature.extra_ validation PASS, best_bid SKIP on single-relay, and sim diff on two real reports. * fix(cb_metrics): stop counting CB's synthetic timeout code 555 as relay 5xx Root cause of the timing-games false-red: CB increments its status-code counter with TIMEOUT_ERROR_CODE = 555 when IT cancels a request at its own deadline (commit-boost constants.rs) — not a relay-served status. bucket_code lumped any 5-prefixed code into 5xx, so timing-games (which cancels late polls at the 400ms budget BY DESIGN) read as "47.5% relay 5xx" and tier-1-failed a run whose relays served zero errors. Live-confirmed with a dedicated capture run (raw counter before bucketing): get_header = 200x48 / 204x6 / 400x4 / 555x42, ZERO real 5xx; helix logs 0 server errors; CB log 45 timeout mentions. Fix, scenario-agnostic (no timing-games exemption): - 555 buckets as `timeout`, its own category. - timeouts > 25% of requests -> WARN, never FAIL, not even under --strict (client-side deadline policy, not a pipeline error). - real 5xx keeps the rate-based FAIL with timeouts EXCLUDED from the denominator, so a genuine error storm still fails amid heavy timeout polling. - get_header PASS detail notes sub-threshold timeout counts. 4 new both-sides tests (timing-games shape warns; strict doesn't promote; sub-threshold passes with note; real 5xx still fails despite 90 timeouts). lib 106 -> 110. CHECKS.md matrix section + stale pre-H2 caveat rewritten; catalog severity_note updated. * docs: live-confirm the 555 timeout fix — timing-games now overall PASS (exit 0) with annotative 44.4% timeout WARN * feat(scenarios): divergent-bid 2-helix — cb-multiple-relays gets per-relay subsidies Closes the degenerate-competition gap: one shared rbuilder submitted the IDENTICAL bid to both helix instances, so relay.best_bid's "delivered >= best offered" was a tie that proved nothing about CB's selection. rbuilder upstream (which ethpandaops/reth-rbuilder:develop builds, no fork) supports per-relay bid values from ONE instance via top-level [[subsidy_overrides]] entries name-matched to [[relays]] blocks — each override seals its own bid of true_block_value + subsidy. No second builder participant needed; the previously-mapped 5-file two-builder surgery is obsolete. - ethereum-package (submodule fc5e6a2): mev_builder_subsidy accepts a LIST (positional per-relay values); list[0] = global subsidy, relays 1..n get [[subsidy_overrides]]. Scalar shape renders byte-identically to before. - cb-multiple-relays now emits mev_builder_subsidy: [1, 2] (helix-2 gets 1 ETH, helix-3 gets 2 ETH on every slot). Other scenarios keep the scalar. Golden + scenario comment updated. - relay.best_bid data gains divergent_slots (competitive slots whose offered values are NOT all equal) and the PASS detail states "real discrimination" vs "degenerate tie" — the live proof of non-degeneracy. 2 both-sides tests. lib 110 -> 112. NOTE: submodule commit fc5e6a2 is local-only (with 43fe436 + fbe3141) — needs J's push to JasonVranek/ethereum-package. * docs: live-validate divergent-bid 2-helix — 65/65 slots divergent, 0 suboptimal, overall PASS * feat(cb_metrics): handle CB's WS transport code 556 + pin WS log-variant parsing Readiness for commit-boost PR #483 (WS get_header streaming), which introduces TRANSPORT_ERROR_CODE = 556 (connect refused / dns / tls / stream broke) and a new bid log line "received new header from ws stream". - bucket_code: 556 -> its own `transport` bucket (client-observed, not relay-served — same misattribution class as the 555 fix; relay reachability's FAIL owner stays the tier-1 relay_pipeline death checks). - classify_endpoint: 555+556 aggregate into one client-side WARN above the 25% rate (never FAIL, not under --strict); both excluded from the real-5xx denominator so a genuine relay error storm still FAILs; PASS detail notes sub-threshold counts of each. - mux_routing: test pinning that the WS log variant (a superstring of the HTTP message, latency/content_type fields absent) parses through parse_cb_log_line with the fields best_bid needs (relay_id, slot, value_eth) and matches the starts_with("received new header") prefix filter our consumers use. 5 new both-sides tests + extended bucket assertions (lib 112 -> 117). CHECKS.md + sim checks catalog synced. * feat(scenarios): cb-sigverify-diff — a REAL skip_sigverify ON/OFF differential Turns feature.skip_sigverify from a permanent honest-WARN into a positive test, with NO relay modification: CB validates bids against the pubkey in its own [[relays]] url, so pointing CB at the real helix via a literal url whose pubkey is a valid-but-WRONG BLS key (a mnemonic validator key, not helix's DEFAULT_MEV_PUBKEY) makes validate_signature reject every bid (PubkeyMismatch) — the exact function skip_sigverify skips. Bids winning the auction is then positive proof the skip codepath fired. - CbParams.literal_relay_url: replaces the {{ range }} relay loop with one literal [[relays]] block (service DNS helix-relay-2:4040 resolves in-enclave; confirmed by the live 2-helix runs' service names). - Two new scenarios + goldens: cb-sigverify-diff (skip ON + poisoned url; expect green with feature.skip_sigverify PASS) and cb-sigverify-diff-control (same poison, skip OFF; EXPECTED to fail payload delivery — the control arm). `sim diff control treatment` shows the flip that proves discrimination. - feature_fired: has_poisoned_relay_pubkey (pure detector vs the known helix signing pubkey) + classify_skip_sigverify(poisoned, auction_winners): poisoned + >=1 "auction winner" (a post-validation log line) -> PASS; poisoned + 0 -> WARN (no-false-green); unpoisoned -> the honest WARN as before. 4 new tests incl. both-sides detection (lib 117 -> 120). - Catalog + CHECKS.md synced. Live differential run pending (2 devnets + sim diff). * feat(scenarios): Law 7 — parametrize on EL/CL client pairs "Coverage is a matrix, not a point": every scenario hardcoded geth+lighthouse, so a CB regression specific to another client pair was invisible. - New `ElCl { el, cl }` axis with DEFAULT (geth/lighthouse) and ALT (nethermind/prysm); `Scenario::el_cl()` selects it. The participants block is now derived from the pair instead of a const. - The pair also drives every service name derived from it — notably extra-validation's `rpc_url`, which was hardcoded to el-1-geth-lighthouse. The ethereum-package names EL services `el-{index}-{el}-{cl}` (el_launcher.star:177), so on any other pair that hardcode silently points at a nonexistent service and extra validation no-ops (feature.extra_validation would then WARN). Now derived — the exact coupling Law 7 exists to catch. - New scenario cb-basic-nethermind-prysm (P3's "one alternate EL/CL pair"): cb-basic's assertions on different clients. Full scenario x pair cross-product is deliberately left for later — this proves the parametrization end to end without exploding the fixture matrix. Every pre-existing golden is byte-identical (zero regression); 2 new tests pin both the participants flow and the rpc_url derivation. sim 68 -> 70. Live devnet validation of the alt pair still pending (needs nethermind/prysm image pulls). * fix(sim diff): Skip transitions are coverage changes, not severity regressions Found by dogfooding sim diff on the sigverify differential's two arms: the report went FAIL -> PASS, yet the verdict read REGRESSION. Cause — Direction was derived from CheckStatus's Ord (Fail > Warn > Pass > Skip), which is right for worst-status AGGREGATION (a Skip must not win a fold over a Pass) but wrong for TRANSITIONS: a Skip is not "better than a Pass", it is NO INFORMATION. So SKIP -> PASS (a check that started running and passed) was classified as a severity increase -> REGRESSED, and two of those dragged the whole run's verdict to REGRESSION. Direction now has CoverageGained (Skip -> real verdict) and CoverageLost (real verdict -> Skip); neither counts as a regression, both are still printed (cov-gain / cov-lost). Skip -> FAIL is deliberately NOT a regression either: the failure existed before, it was just unmeasured. The same rule now backs overall_regressed, so there is one classification in one place. Verified against the two real reports that exposed it: the two false REGRESSEDs became cov-gain, and the one genuine regression (cb_relay_latency PASS -> WARN) is retained. 4 tests (sim 70 -> 72). * docs: record the skip_sigverify differential result, the sim diff coverage fix, and the Law 7 slice * fix(cb_metrics): distinguish "relay rejected the blinded blocks" from "proposer never chose one" Found by the Law-7 alt-pair run (nethermind+prysm, 2026-08-04). The check reported "0 deliveries (200+202=0); proposer never chose a builder block" — but the data said the opposite: 26 blinded blocks were forwarded to the relay and the relay rejected ALL 26 with 4xx (beacon side: 26 x 502 back to the CL). The proposer very much chose builder blocks; the break was relay-side. A diagnosis that names the wrong component is worse than none — it sends an operator to debug the CL when the relay is refusing. Now split: - submissions present but zero delivered (r4xx > 0) -> FAIL naming the relay as the rejecter (a relay refusing every blinded block is not a WARN). - genuinely zero submissions -> the original "proposer never chose" WARN (--strict FAILs), which is now only reachable when nothing was submitted. 3 tests incl. the live nethermind+prysm shape (lib 120 -> 121). * docs: Law 7's first dividend — nethermind+prysm blinded blocks rejected by helix; plus the misdiagnosis fix * fix(helix): enable the GetPayloadV2 route — prysm's MEV path was blocked by OUR config Chasing the Law-7 alt-pair failure (nethermind+prysm delivered zero payloads) ended at our own helix config, not at a client or CB defect. Chain of evidence: prysm submits blinded blocks to /eth/v2/builder/blinded_blocks at ~256ms into the slot (early, so the "late block" hypothesis is dead); helix 404s that route; CB deliberately refuses to downgrade to v1 (in v2 the relay publishes the block after an empty 202, so forwarding a v1 payload would silently drop it) and returns 502. Result: every builder block the proposer chose was lost, and the 11 v2-unsupported events line up 1:1 with the 11 missed slots. Lighthouse never hit it because it submits via v1. The 404 was NOT a helix limitation: helix's Route enum has GetPayloadV2 (verified in the shipped binary alongside GetPayload, GetTopBidV2, ProposerPayloadDeliveredV2) and our generated router_config.enabled_routes listed only GetPayload. Added it. All goldens regenerated (the helix block is shared by every scenario). Also lands the check that would have named this in one line instead of a multi-hour chase: - NEW cb_relay_v2_unsupported reads pbs_submit_block_v2_unsupported_total, FAILs when nonzero (escalating to tier 1 like the matrix checks — lost submissions are at least as fatal as a 5xx) and points at the relay's route config first. - cb_v2_fallback no longer claims "relays support v2" when the counter is zero. That was a false reassurance: a relay that 404s v2 never reaches the fallback path at all, so zero fallbacks says nothing about v2 support. It now reports only what it measured ("No v2->v1 fallbacks recorded"). 6 tests (lib 121 -> 125). * docs: add CLAUDE.md (agent orientation) + sync the catalog drift it caught CLAUDE.md is the router for the repo: what it is, USING it (real flags only, verified against justfile/run-and-verify.sh/clap), the verdict model, a DEBUGGING section built around the method that has repeatedly paid off here (get the raw evidence - kurtosis logs with ANSI stripped, raw Prometheus counters - before believing a check's summary), the Known traps (555/556 synthetic codes, helix enabled_routes/GetPayloadV2, wrong detail strings, the kurtosis pin, the fork submodule push requirement, never pattern-kill), the Law-4 classify_* seam for adding checks, and an explicit keep-this-updated-in-the-same-commit contract. Writing it immediately caught real drift, including mine: cb_relay_v2_unsupported shipped in the previous commit without its catalog entry or CHECKS.md section - the exact failure the discipline exists to prevent. Both synced here (catalog now 24 entries; CHECKS.md gains the row + a section noting a FAIL usually means the relay's route config, not a capability gap). README's stale "six scenarios" corrected to nine. * test(relay_pipeline): extract the 3 cross-relay aggregations as pure seams relay_pipeline.rs held tier-1/2 verdict logic at 24% coverage: the aggregations that decide relay.builder_blocks_received, relay.mev_delivery_rate and relay.validator_registrations were embedded inside the async run_relay_checks, so they could only be exercised by a live devnet. That is the Law-4 violation the law exists to prevent - these decide whether a release is gated. Extracted (behavior-identical, pure): - aggregate_builder_blocks: any-Pass wins and sums counts (one silent relay is not a pipeline failure since the builder only submits where configured), else worst-status. Empty input now SKIPs instead of panicking on .max().unwrap(). - aggregate_mev_rate: BEST-of (Pass wins) and collapses Skip => Fail, both deliberately opposite to CheckStatus::Ord - a Skip means no relay answered the data API, i.e. a failure to MEASURE delivery. The comment explaining why it is not unified with .max() now sits on the seam it describes. - aggregate_registrations: worst-status, per-relay labelled detail, and the documented "empty => omit the check entirely, not even SKIP" as an Option. 11 tests covering both sides of every boundary incl. the two panic guards (empty vec, urls/results length mismatch). relay_pipeline 24.5% -> 59.3% regions; lib 125 -> 136 tests; total coverage 62.3% -> 64.3%. * docs(plan): bank the researched signer-on-Kurtosis runtime contract Researched from commit-boost-client source: the container shape (same image, CMD overridden to signer, uid 10001), the four required env vars incl. the CB_SIGNER_ENDPOINT devnet trap (host defaults to 127.0.0.1), the minimum config TOML, and four failure modes that would bite a naive launcher - the worst being that a missing [[modules]] makes the service exit 0 SILENTLY, which Kurtosis reads as a clean shutdown. Also the assertion ladder (status -> loaded_consensus count -> JWT-authed get_pubkeys -> negative controls). Gated behind the MEV scenario matrix: a signer test riding on an unreliable MEV harness proves nothing. * test(beacon): pin block-hash extraction against a REAL prysm response beacon.rs sat at 6.6% coverage while owning the parse that payload_hash_match and chain_health depend on. The risk is not a loud error: MinimalBlockBody extracts execution_payload.block_hash through an Option, so a shape mismatch silently yields None, which payload_hash_match reports as "missed" - a real hash mismatch would be indistinguishable from a missing block. Fixture captured LIVE from prysm v7.1.8 on the nethermind+prysm devnet rather than invented, which matters now that Law 7 has us running more than one CL: - parses block_hash from the real prysm response shape - the extraction is insensitive to which sibling body fields are present (a body carries ~12 more keys and grows every fork) - a payload-less (phase0) block yields a clean None, not a parse error that would abort the whole slot scan - the {"data": ...} envelope unwraps - trailing-slash base URLs normalize (string-concat URLs would 404) beacon.rs 6.6% -> 41.8%; lib 136 -> 141 tests; total 64.3% -> 64.5%. * fix(discovery): a relay's POSTGRES container was classified as a relay data API is_relay_api_service excluded support services with ends_with("-postgres"), but the N-relay-instance topology suffixes every service with its index, so the real service name is `helix-relay-postgres-2` - which does not END with "-postgres" and therefore passed the filter. Any 2-helix scenario could hand a postgres container to the relay data-API checks. Switched to `contains`. Found by extracting the service-SELECTION logic out of discover() into a pure classify_services(services, port_fallback) seam (Law 4) - it decides WHAT gets checked, so a misclassification silently invalidates every downstream check, and it was previously unreachable without a live enclave. The extraction also restored a port-precedence contract: try every already-parsed port name BEFORE any `kurtosis port print` fallback. My first version interleaved them per-name, which would shell out for "http" before trying helix's parsed "endpoint" - one subprocess per relay per run, the same regression an earlier perf pass removed. Now asserted by call-counting tests. 10 tests incl. client-pair agnosticism (Law 7: names carry the pair), all three relay flavours vs their support services, pbs-over-http preference, optional CB metrics, and the two precedence guards. discovery 58.4% -> 79.8%; lib 141 -> 151. * test(report): pin the save->read interchange contract + render smoke report.rs sat at 40% while owning the artifact every downstream consumer eats. The load-bearing new test is the round trip: the lib WRITES the report and the sim bin READS it, connected only by serde derives, so nothing but a save->parse cycle proves the pipeline composes (incl. provenance survival and that a check's status serializes as `result`, the key sim diff and CI gate on). Plus: save-to-a-missing-dir must not panic (a finished verify must never die on a bad --output-dir), short_hash determinism/width/empty-input, and a render smoke over a report carrying every status + a FAIL with data. lib 151 -> 156 tests. * docs: Law 7 alt-pair layer 2 — GetPayloadV2 fix confirmed, block now rejected for a new reason cb_relay_v2_unsupported PASSes (404-on-v2 gone, the route fix landed) but submit_blinded_block still FAILs with all 25 blinded blocks rejected - the failure moved from route-not-enabled to block-refused. Both new diagnostics worked as designed. Parked as a known-fail scenario: it documents a real interop gap, and the full MEV sweep is the priority. * test(health,metrics): cover the two 0%-coverage modules Both had zero tests while owning failure modes that are SILENT rather than loud: health.rs - probe() counts any HTTP response as alive (only transport errors mean death), so a typo in a probe path returns 404 and reads as ALIVE. That would make the mid-run death detector permanently blind without any error. Now pins all three liveness paths, the trailing-slash normalization (// 404s on some servers, same blindness), and that a refused connection IS reported as death. metrics.rs - a parse regression makes every matrix check SKIP, which is indistinguishable from the normal devnet state (metrics usually absent) and is non-fatal, so it would never surface. Now parses a real CB scrape shape incl. label addressability (endpoint/http_status_code/relay_id are what every check keys on) and histograms, plus empty/comment-only bodies parsing cleanly rather than erroring, and a dead endpoint erroring rather than hanging. lib 156 -> 167 tests. * fix(test): health probe_all test used the wrong return type probe_all returns Vec<(label, error)>, not Vec<label>. The previous commit shipped a test that did not compile: I grepped for 'test result:' lines and treated their ABSENCE as silence rather than as failure, so a broken build read as green. Same class of mistake the harness itself keeps finding - a check that cannot distinguish 'no signal' from 'bad signal'. Test now asserts the labels AND that the death reason is carried rather than discarded. Gates re-run with explicit exit codes (test/clippy/fmt all 0): lib 160, sim 72, cb-verify 10. * docs(CLAUDE.md): add the silence-is-not-success gate trap * fix(cb_metrics): two checks read metric names that cannot exist — one produced a false PASS I built on An independent audit of CB's metric surface caught both. They matter because neither failed loudly; both reported green forever. 1. cb_relay_v2_unsupported never fired. CB's PBS registry is Registry::new_custom(Some("cb_pbs")), and this counter is REGISTERED as `pbs_submit_block_v2_unsupported_total`, so the EXPOSED name carries a doubled prefix: `cb_pbs_pbs_submit_block_v2_unsupported_total`. Every sibling is registered bare (`relay_status_code_total` -> `cb_pbs_relay_status_code_total`), which is exactly why the odd one out slipped through. We matched the registered name, so the check PASSed by construction. RETRACTION: that false PASS was my only evidence that enabling helix's GetPayloadV2 route fixed the nethermind+prysm failure. It was structural, not evidence. submit_blinded_block failed identically before and after (26 vs 25 rejected), so the fix's EFFECT is now unverified - re-test before claiming it. Adding the route is still correct on its own merits; its consequence is not established. 2. cb_v2_fallback was permanently green. It read `cb_pbs_submit_block_v2_fallback_to_v1_total`; commit-boost registers no `*fallback*` metric anywhere. "Counter absent" was treated as "zero fallbacks == PASS", so it could never fail - and it had also asserted "relays support v2" on a run where the relay was 404ing v2. Now SKIPs, naming itself inert and pointing at the check that owns v2 support. Method rule: verify metric names against a REAL scrape, not against the registration constant in CB source - the registry prefix is applied at gather time. Catalog + backlog synced; tests re-pointed at the exposed names. * docs(plan): signer grill verdict — build modified, two choices killed An independent adversarial review killed the key layout (secrets/ is chmod 0600 -R, so uid 10001 cannot traverse it and the signer silently loads ZERO keys; use teku-keys/teku-secrets, which web3signer already proves readable) and killed launch Option A (the CB config artifact is not rendered until downstream in main.star). It also corrected an error in my own research: a MISSING [[modules]] bails loudly, not silently. Plus: pbs.with_signer is dead code in the shipped binary, so 'PBS uses the signer' is not an available escalation; the assertion ladder was mostly vanity (/status is an unconditional 200 and there are two of them; loaded_consensus is log-only and ANSI-mangled) and collapses to a JWT-authed get_pubkeys COUNT assertion; and two afternoon-burning traps (negative controls rate-limit our own IP for 300s; a commit-boost-* service name adds a 200k-line log fetch per check). * feat(scenarios): cb-min-bid — prove the min_bid_eth floor drops bids (+ flatten-trap canary) First scenario from the CB config-surface audit: 5 of 21 PbsConfig fields were exercised by any scenario. min_bid_eth ranked highest because a wrong value degrades SILENTLY (too high => every bid dropped, which reads as "no MEV") and because it is observable. The audit's own premise was stale, though, and the scenario only works once corrected: it assumed ~0.05 ETH bids from old fixtures, but our scenarios set mev_builder_subsidy: 1, so real bids land near 1.04 ETH (measured: 1.0439 / 2.0439 in the divergent run) - and CB validates min_bid_wei < 1 ETH, so NO legal floor could ever reject a subsidized bid. cb-min-bid therefore runs with subsidy 0 (bids ~0.04 ETH of spamoor MEV) against a 0.5 ETH floor. feature.min_bid FAILs iff an auction winner's value is BELOW the floor - the definitive falsifier, since that is only possible if the floor was not applied. That makes this the canary for CB's silent-flatten trap: [pbs] cannot carry deny_unknown_fields (it must flatten PbsConfig), so a renamed/misspelled key there is ignored rather than rejected. Nothing-rejected is a WARN, not a red: "key ignored" and "every bid cleared the floor" look identical. 5 tests; the catalog's feature-invariant test caught the new id, as designed. lib 157 -> 162; 10 scenarios; catalog + CHECKS.md synced. * docs(plan): empirically confirm the signer keystore permissions KILL Checked on a live enclave before writing any starlark: secrets/ is mode 600 root:root (no execute bit, so uid 10001 cannot traverse it) while teku-secrets is 755 and teku-keys is 777. The originally-planned lighthouse layout would have produced a healthy signer holding ZERO keys. Kurtosis does not chown on mount, which is also why six other launchers force User(uid=0). * docs: un-retract the GetPayloadV2 claim with delivery-counter evidence Pre-fix runs (x3): submit_blinded_block 222 v1 (200), 0 v2 (202). Post-fix cb-basic: 0 v1 (200), 222 v2 (202). A complete flip, on LIGHTHOUSE - so enabling helix's GetPayloadV2 changed the relay-side path for every scenario, not only the prysm one. This is counter evidence, not the structurally-broken check that produced the earlier false PASS. Also records a sweep caveat: run-and-verify.sh rebuilds cb-verify per scenario, so a sweep spanning code changes mixes binary versions - cb-basic's v2 checks came from the pre-fix build. * docs: sharpen the prysm question — same v2 route, opposite outcome per CL lighthouse over the relay's v2 route gets 202 Accepted (222 deliveries, PASS); nethermind+prysm gets 4xx on all 25. So the variable is what CB forwards when the request ORIGINATES from prysm (which calls CB's own v2 endpoint at 256ms into the slot, ruling out timing), not the route or relay capability. Next: read helix's rejection reason for a v2 submission and diff the content-type/body between the lighthouse-origin and prysm-origin paths. * feat(signer): config-gen half of the signer-on-Kurtosis North Star Adds an OPT-IN CbParams.signer that appends [signer] + [signer.local.loader] + [[modules]] to the CB config, plus a cb-signer scenario. Built to the adversarial grill's verdict, not to my original plan - it killed two of my choices: - **teku keystores, not lighthouse.** Verified live on an enclave: the package's secrets/ dir is mode 600 root:root with NO execute bit, so CB's uid 10001 cannot traverse it and the signer would start healthy holding ZERO keys (the loader is filter_map + warn!). teku-secrets is 755 and teku-keys 777 - the same pair the package's own web3signer launcher already relies on. - **Key paths stay placeholders.** They are per-participant (node-<idx>-keystores/...) and the config template only carries .Network/.Port/.Relays/.Timestamp, so they cannot be templated. CB's CB_SIGNER_LOADER_{KEYS,SECRETS}_DIR env vars override the TOML at runtime. - **Opt-in so the nine existing goldens stay byte-identical**, and appended AFTER [logs.file] because interleaving top-level tables is invalid TOML once [[relays]] has opened an array-of-tables (asserted by test). - **A [[modules]] entry is mandatory**: with none CB bails loudly, with an empty list it exits 0 SILENTLY. Every field is required, and signing_id must be non-zero (asserted). Generated TOML verified to parse and to match CB's schema shape. 4 tests; sim 72 -> 76. Launcher/starlark half still to come. * feat(scenarios): cb-signer emits commit_boost_signer + the [signer] config Completes the config-gen half: the cb-signer scenario now also sets mev_params.commit_boost_signer so the fork launches the signer container. All 11 configs regenerate clean; the other 10 goldens are untouched. * feat(checks): signer assertions — JWT-authed get_pubkeys with a key COUNT The third piece of the signer North Star: what actually falsifies. Built to the grill's collapsed ladder, deliberately NOT asserting the obvious: GET /status is `Ok(StatusCode::OK)` with no logic (200 with zero keys loaded), and the metrics server exposes a SECOND unconditional /status, so probing the wrong port is an even emptier green. The startup log's loaded_consensus=N is log-only (the signer registers exactly one metric, signer_status_code_total, with no key-count gauge) and ANSI-colored, so the field is not even a contiguous substring. Instead: a JWT-authenticated GET /signer/v1/get_pubkeys with a COUNT assertion. One HTTP call subsumes liveness, module registration, JWT auth AND key loading - and it is by construction the assertion that fails if the keystore mount is unreadable, which is this feature's most likely failure (CB's loader is filter_map + warn!, so a permissions problem yields a healthy signer holding nothing). Zero keys therefore FAILs and names the teku fix in the detail. The JWT is hand-rolled HS256 rather than pulling `jsonwebtoken`, because the claims are what must be exactly right: `route` binds to the exact request path, and `payload_hash` must be NULL on a bodyless request (CB enforces both directions). All of it is a pure, deterministic function with `now` injected - 5 tests decode the claims back and assert route binding, null payload_hash, expiry and secret-sensitivity. Also: the negative control classifies 401 PASS / 200 FAIL (auth not enforced) / 429 WARN naming the self-poisoning trap - CB rate-limits a source IP for 300s after 3 failures, and every harness request shares one NAT address, so negatives must run last. Discovery gains signer_urls on a SEPARATE `cb-signer-*` pattern, asserted not to leak into cb_service_names (three checks iterate that list shelling a 200k-line log fetch each). 11 tests; lib 162 -> 173. * feat(signer): wire the signer check into the run pipeline The check module existed but nothing called it, so cb-signer would have run and asserted nothing - a scenario that cannot fail is the exact defect class this harness keeps finding. Now: for each discovered cb-signer-* service, mint a module JWT and assert the get_pubkeys COUNT against the devnet's active validator set. A non-200 or an unreachable signer FAILs at tier 1 rather than being silently absent. Catalog + CHECKS.md synced (the catalog's feature-invariant test caught the new id again, as designed). * docs: mark the signer BUILT-not-validated + add the uid-10001 keystore trap to CLAUDE.md * docs: record the two-sweep overnight plan + cb-basic/cb-mux PASS * test(cb_metrics): pin CB's exposed metric names after the dead-check audit Audited every metric name the checks read against CB's registered names plus its registry prefixes. Result: the two bad ones are already handled and the rest are correct - a clean negative result worth banking rather than re-deriving. The rule that produced the bug: Registry::new_custom(Some(..)) prefixes every metric at gather time, and most PBS metrics are registered bare - but two carry their own prefix and end up DOUBLED (cb_pbs_pbs_submit_block_v2_unsupported_total, and cb_signer_signer_status_code_total, which we do not read yet but will if a signer metrics check is added). The test pins them so an 'obvious tidy-up' of the doubled prefix breaks loudly instead of silently disabling a check. It deliberately cannot detect a CB-side rename - only a real scrape can, which is the standing rule: verify metric names against a scrape, never against the source constant. * fix(cb_metrics): judge submit_blinded_block on the BEACON side — a false red my own feature caused cb-multiple-relays FAILED the sweep on submit_blinded_block 186/626 5xx (29.7%) while the pipeline was flawless: 65/65 payloads delivered across 2 relays, 100% MEV rate, 65/65 payload hashes matched, best_bid verified over 65 competitive slots, 0 missed slots. The per-relay split shows why: mev_relay_0 (subsidy 1, LOSES every auction): 202x1, 4xx x219, 5xx x185 mev_relay_1 (subsidy 2, WINS every auction): 202x219, 4xx x1, 5xx x1 beacon side: 202x220 <- CB served the CL every time CB asks EVERY configured relay for the payl…
1 parent 1818057 commit 424ccfb

79 files changed

Lines changed: 13997 additions & 2385 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.env.example‎

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,19 @@
22
# cb-testing Docker image overrides
33
# ---------------------------------------------------------------------------
44
# Copy this file to .env and customize for your local images.
5-
# All values have defaults in generate_kurtosis_configs.py, so this file
6-
# is entirely optional — only needed when using custom Docker images.
5+
# All values have baked defaults in `sim generate` (Images::default), so this
6+
# file is entirely optional — only needed when using custom Docker images.
77
# ---------------------------------------------------------------------------
88

9-
# Helix relay image (your custom relay build)
10-
# Default: helix-relay:kurtosis
11-
# HELIX_RELAY_IMAGE=helix-relay:kurtosis
9+
# Helix relay image
10+
# Default: ghcr.io/gattaca-com/helix-relay:main
11+
# HELIX_RELAY_IMAGE=ghcr.io/gattaca-com/helix-relay:main
1212

13-
# mev-boost relay image (used in multi-relay scenarios)
13+
# flashbots mev-boost-relay image — NO LONGER USED as a relay (multi-relay now runs
14+
# two helix instances; see docs/fork-delta.md). Still emitted into configs but inert.
1415
# Default: ethpandaops/mev-boost-relay:main
1516
# MEV_RELAY_IMAGE=ethpandaops/mev-boost-relay:main
1617

17-
# Commit-Boost PBS image (sub latest for your local build)
18-
# Default: commit-boost/commit-boost:latest
19-
# MEV_BOOST_IMAGE=commit-boost/commit-boost:latest
18+
# Commit-Boost sidecar image (sub your local `just build-all <tag>` build)
19+
# Default: commit-boost/commit-boost:kurtosis
20+
# MEV_BOOST_IMAGE=commit-boost/commit-boost:kurtosis

‎.github/workflows/integration.yml‎

Lines changed: 48 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,16 +14,34 @@ on:
1414
description: 'Observation window in epochs'
1515
required: false
1616
default: '1'
17-
18-
env:
19-
ENCLAVE: cb-ci-${{ github.run_id }}
17+
cb_image:
18+
description: 'Commit-Boost PBS image (MEV_BOOST_IMAGE). Override to pin a specific tag/digest.'
19+
required: false
20+
default: 'ghcr.io/commit-boost/pbs:latest'
2021

2122
jobs:
2223
verify:
23-
name: devnet + verify
24+
name: devnet + verify (${{ matrix.scenario }})
25+
# RUNNER MEMORY: a full devnet (geth+lighthouse + helix relay(s) + reth-rbuilder
26+
# + commit-boost + dora/spamoor/prometheus) needs ~15-20GB; cb-mux runs TWO
27+
# helix instances (8GB cap each) → ~25GB+. A GitHub-hosted ubuntu-latest has
28+
# only ~7GB RAM, so this nightly requires a LARGE or SELF-HOSTED runner. Change
29+
# runs-on accordingly before relying on it (cb-mux will OOM on the hosted runner).
2430
runs-on: ubuntu-latest
2531
timeout-minutes: 90
2632

33+
strategy:
34+
# Don't cancel cb-mux just because cb-basic failed (or vice versa); we
35+
# want independent e2e signal from each scenario every night.
36+
fail-fast: false
37+
matrix:
38+
scenario: [cb-basic, cb-mux]
39+
40+
env:
41+
# One enclave per (run, scenario) so matrix legs never collide and each
42+
# leg's Teardown removes exactly the enclave it launched.
43+
ENCLAVE: cb-ci-${{ github.run_id }}-${{ matrix.scenario }}
44+
2745
steps:
2846
- name: Checkout
2947
uses: actions/checkout@v6
@@ -56,21 +74,44 @@ jobs:
5674
kurtosis analytics disable
5775
echo "$(dirname $(which kurtosis))" >> $GITHUB_PATH
5876
77+
- name: Generate ${{ matrix.scenario }} config
78+
# The Python generator + its checked-in example config were retired (P2);
79+
# `sim generate` is now the source. The nightly builds no commit-boost
80+
# image, so it pins the public CB image via .env (MEV_BOOST_IMAGE).
81+
#
82+
# REPRODUCIBILITY GAP: the default below is a moving `:latest` tag, so a
83+
# replay can silently pick up a different PBS binary. commit-boost-client
84+
# is NOT checked out here (only ethereum-package is a submodule), so we
85+
# can't build the image from `main` in-workflow without adding that repo.
86+
# For a reproducible run, override the `cb_image` workflow_dispatch input
87+
# with a pinned tag/digest, e.g.
88+
# ghcr.io/commit-boost/pbs@sha256:<digest>
89+
# TODO: promote a pinned digest to the default here once the SSZ-current
90+
# PBS image is confirmed (see the review NOTE this replaced), so the
91+
# scheduled nightly is reproducible too, not just manual dispatches.
92+
env:
93+
# On schedule there are no inputs, so fall back to the pinned default.
94+
CB_IMAGE: ${{ github.event.inputs.cb_image || 'ghcr.io/commit-boost/pbs:latest' }}
95+
run: |
96+
echo "MEV_BOOST_IMAGE=${CB_IMAGE}" > .env
97+
cargo run --bin sim -- generate ${{ matrix.scenario }}
98+
5999
- name: Launch devnet + verify
60100
timeout-minutes: 60
61101
run: |
62102
./scripts/run-and-verify.sh \
63-
--config configs/example-kurtosis-config.yml \
103+
--enclave "$ENCLAVE" \
104+
--config configs/generated/${{ matrix.scenario }}.yml \
64105
--json \
65106
--live-metrics \
66107
--min-epochs "${{ github.event.inputs.min_epochs }}" \
67-
--target-epoch "${{ github.event.inputs.target_epoch }}" \
108+
--target-epoch "${{ github.event.inputs.target_epoch }}"
68109
69110
- name: Upload artifacts
70111
if: always()
71112
uses: actions/upload-artifact@v5
72113
with:
73-
name: verify-${{ github.run_id }}
114+
name: verify-${{ matrix.scenario }}-${{ github.run_id }}
74115
path: |
75116
verify-report.json
76117
kurtosis-run.log

‎.gitignore‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,6 @@ kurtosis-configs/
77
scripts/kurtosis-configs/
88
.env
99
*/__pycache__
10+
11+
CB-Testnet.json
12+
.agent/

‎.gitmodules‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
[submodule "ethereum-package"]
22
path = ethereum-package
3-
url = https://github.com/JasonVranek/ethereum-package.git
3+
url = https://github.com/Commit-Boost/ethereum-package.git

0 commit comments

Comments
 (0)