feat(testutil): add beaconmock#416
Conversation
Initial port of charon/testutil/beaconmock: BeaconMock with wiremock server, builder API, static endpoints, and deterministic attester/ proposer duties. Migrate eth2util, app/eth2wrap, core/deadline, and cli test code to the shared mock.
Prep for parallel work on headproducer, attestation store, options, validator-set API, and fuzzer. Public API (BeaconMock, MockState, Validator, ValidatorSet) unchanged.
…ions, fuzzer
Brings the Rust beaconmock to functional parity with charon/testutil/beaconmock:
- head producer: slot ticker + SSE on /eth/v1/events + deterministic
block-root endpoint (headproducer.rs).
- attestation store: deterministic AttestationData per (slot, committee),
by-root aggregate lookup, 32-slot trim (attestation.rs).
- builder options: endpoint_overrides, fork_version, sync committee
size/subnet count, no_proposer/attester/sync duties, deterministic
sync committee duties (options.rs + sync duties endpoint).
- fuzzer mode: random JSON for proposal, attestation, duties endpoints
behind a builder flag (fuzzer.rs).
- ValidatorSet: by_public_key, public_keys.
- default spec extended with mainnet keys real validator clients read.
- /eth/v2/beacon/blocks/{id} default endpoint.
Add Rust ports of the seven Go tests not covered by the agents' implementation-focused tests: deterministic attester/proposer duties, TestStatic, genesis_time/slots_per_epoch/slot_duration overrides, and default overrides.
…lidation Port charon's static.json + gen_static.sh approach: a Holesky beacon-node snapshot is committed in the crate and used as the baseline for the default spec; Charon-simnet overrides apply on top. A new build.rs validates the file at compile time (well-formed JSON, required endpoints present, required spec keys present) and triggers rebuilds whenever the snapshot changes — failures surface as compile errors instead of test failures. Drops the hand-curated ~80-key spec list previously inlined in defaults.rs in favor of the real beacon-node snapshot. scripts/gen_static_beaconmock.sh regenerates static.json from a live beacon node (`BEACON_URL=<url> ./scripts/gen_static_beaconmock.sh`), mirroring charon/testutil/beaconmock/gen_static.sh.
Restore the testdata/*.golden files from charon/testutil/beaconmock/ verbatim and switch the deterministic attester/proposer duties tests to golden assertions matching Go's RequireGoldenJSON. Adds a third golden test covering AttestationData for (slot=1, committee_index=2). Fixes a bug surfaced by the AttestationStore golden: the Rust port used saturating_sub(1) for previous_epoch, but Go wraps to u64::MAX at epoch 0 (see charon/testutil/beaconmock/attestation.go newAttestationData). Switch to wrapping_sub so the source.epoch and source.root match Go byte-for-byte.
varex83agent
left a comment
There was a problem hiding this comment.
Summary
Functionality-rich port of charon/testutil/beaconmock to Rust as pluto-testutil::beaconmock, with a wiremock-backed BeaconMock, deterministic duty handlers, an SSE/head-root producer, an attestation store, a fuzzer mode, an embedded Holesky static.json baseline (validated at compile time by build.rs), and three golden fixtures ported byte-for-byte from Charon. Existing tests in app/eth2wrap/valcache, cli/test/beacon, core/deadline, eth2util/eth2exp, and eth2util/signing are migrated to the shared mock. Quality of the port is high overall; the Go semantics are followed closely and the parity gaps that remain are mostly intentional Go-bug fixes worth documenting.
Bugs (must-fix)
fuzzer.rs:182,213,242,264— pubkeys are 48 copies of a single random byte.[rng.r#gen::<u8>(); 48]evaluates the random byte once and copies it 48 times (Copyarray-repeat semantics). Every fuzzed pubkey collapses to one of 256 values like0xaaaa...aa, gutting the fuzz coverage of any code that branches on pubkey content. Uselet mut pk = [0u8; 48]; rng.fill_bytes(&mut pk);.
Major / parity
pluto-testutilis a normal[dependencies]entry ofpluto-eth2util(crates/eth2util/Cargo.toml:24). Not modified in this PR but materially worsened by it:wiremock(added totestutil's[dependencies]here) now transitively links into every production crate that depends oneth2util(cli,cluster,core,p2p,app, theplutobinary). All current usages insideeth2utilare#[cfg(test)], so nothing is called in release, but the entire mock infrastructure is still compiled and shipped.crates/cluster/Cargo.tomlalready demonstrates the right fix:pluto-testutil = { workspace = true, optional = true }plus a feature flag.headproducer.rs:83—Drop::notify_waiters()races with the spawned ticker.notify_waitersonly wakes tasks already pollingnotified(). IfBeaconMockis constructed-then-dropped before the spawned task at line 140 first polls, the shutdown signal is lost and the ticker leaks. Switch totokio_util::sync::CancellationToken(persistent + shareable) orNotify::notify_one()(stores a permit).state.rs:46-49—Validator::activesetsexit_epoch/withdrawable_epochtou64::MAX, but Charon'sValidatorSetAleaves them at 0. Theseu64::MAXdefaults come from Go's fuzzer (beaconmock_fuzz.go:44-45), not fromValidatorSetA. Wire JSON forvalidator_set_a()will not match Charon's.defaults.rs:42-53—fork_scheduleis hand-rolled withepoch="0"everywhere, while the embeddedstatic.json(which Charon actually serves) has Capella→Deneb at epoch 256 and Deneb→Electra at 29696. Read the fork_schedule from the snapshot like the spec does.defaults.rs:295-341—proposer_duties_responseiterates the full validator set, but Charon'sWithDeterministicProposerDutiesiterates active validators only (mock.ActiveValidators(ctx).Indices()). Inactive validators inserted viaset_validator_setwill receive duties under the Rust mock but not under Charon's.
Notable minors
fuzzer.rs:26—FUZZ_MOCK_PRIORITY=10is lower (= higher precedence) thanOVERRIDE_PRIORITY=50, so userendpoint_overridesare silently shadowed by fuzzer routes when both are enabled. In Charon they cannot collide (fuzzer goes throughMock.Funcfields, overrides go through static JSON). Either flip the priorities or document the precedence.headproducer.rs:298—wait_for_first_headbusy-pollsstd::thread::sleep(1ms)inside a wiremock responder for up toslot_duration * 2. With the default 12s slot, an early/eth/v1/eventsrequest can stall the entire mock server thread for ~24s. Cap the budget at a small fixed value or convert to async.mod.rs:37—Error::Client(#[source] anyhow::Error)wraps a typed eth2 error inanyhow. Use#[from] pluto_eth2api::EthBeaconNodeApiClientError.headproducer.rs:347+state.rs:203—hex_0xis defined in both files. All other beaconmock modules already import thestate::hex_0xversion.
Verdict
The fuzzer pubkey bug is a clear must-fix, and several parity gaps (validator default fields, fork_schedule epochs, proposer-duty active-only filter, fuzz/override priority inversion) should be addressed or explicitly documented before merge. Once the bug and the parity items are resolved, this is a solid baseline.
…le epochs Charon's ValidatorSetA leaves ExitEpoch and WithdrawableEpoch unset, so they serialize as "0". The Rust port was forcing them to FAR_FUTURE_EPOCH (u64::MAX), which is more spec-correct but breaks parity with Charon's mock output.
…use CancellationToken Three intertwined issues are resolved by restructuring HeadProducer lifecycle: - Drop race: switching from Notify::notify_waiters() (which only wakes currently-registered waiters) to CancellationToken (cancel-then-await semantics) ensures shutdown is observed on the next poll regardless of registration timing. - Block-root race: HeadProducer::spawn now generates and stores the initial head event synchronously before returning, so /eth/v1/beacon/blocks/.../root and /eth/v1/events never observe a None current head. The 150ms test sleep workaround is removed. - std::thread::sleep in async handler: with the head guaranteed present, the busy-wait loop in wait_for_first_head is no longer needed and is deleted, eliminating the synchronous block on tokio worker threads.
… duty-root typo Charon's headproducer.go renders both current_duty_dependent_root and previous_duty_dependent_root from the same internal value (a Go typo carried in v1.7.1). Mirror that behavior by reducing HeadEvent to a single duty_dependent_root field and rendering it into both JSON fields. Also document that the Rust port uses ChaCha-based StdRng instead of Go's math/rand LCG. The byte sequences differ but Pluto does not assert on any specific head/state/dependent root value, and ChaCha is portable and well-tested; the head event JSON shape and per-slot determinism are preserved.
The arithmetic_side_effects lint denies + operator on SystemTime; the fallback already used checked_add elsewhere in the same function. Mirror that pattern.
…ties Charon's WithDeterministicProposerDuties iterates over mock.ActiveValidators, which only includes validators with an Active* status. Mirror that by filtering the validator set on validator.status.is_active() before assigning proposer duties. Add a regression test that adds a WithdrawalDone validator to set_a and verifies it is skipped while the three Active validators retain duties.
headproducer.rs had a private hex_0x copy identical to state::hex_0x. Import the shared pub(crate) helper instead. No behavior change.
Three identical parsers (epoch_from_path in defaults.rs, slot_from_path
and epoch_from_path in fuzzer.rs) all extract the trailing /{u64} segment
of a request path. Pull the parse into a single pub(crate) helper in
state.rs and delegate to it from the named wrappers (kept for call-site
readability).
…pect
Two defensive fallbacks were unreachable for inputs the code accepts:
- random_validator_status's unwrap_or("active_ongoing") covered an empty
STATUSES slice, which is a const &[&str; 9].
- default_genesis_time's None => Utc::now() covered DST ambiguity at a UTC
instant where DST does not apply.
Both swap to expect() with a message that explains the invariant, so a future
breakage prints a useful panic instead of silently producing surprising data.
varex83agent
left a comment
There was a problem hiding this comment.
Beaconmock port — overall assessment
This is a substantial, careful port of Charon's testutil/beaconmock to Rust. Functional parity with the Go reference is good after the prior round of fixes (zero-value exit/withdrawable epochs, active-validator filter on proposer duties, synchronous initial head publish via CancellationToken, mirrored duty-root typo, etc.).
No bug or major findings. What remains are minor parity divergences, a few perf/idiom cleanups, and nits. None of them block merge.
Themes worth addressing before or shortly after merge:
- Charon parity tweaks in defaults/handlers where Pluto is silently more lenient than Go (
MIN_GENESIS_TIMEoverride, fuzzer aggregate version,slot_duration=0andk=0fallbacks, emptytopics=returning a head event). Each one is small but they will add up if not tracked. - Code-dup cleanups — three near-identical
mount_json*helpers could collapse, and the duty-init locking on a freshArccan move into the constructor. - Test-helper hygiene —
default_spec()re-parses the embedded ~80-key static.json on every builder call; tickerJoinHandleis discarded;endpoint_overridesandno_*_dutiesshare priority 50 with no documented tie-break.
Note (out of scope): This PR also bundles .claude/skills/loop-review-pr/SKILL.md, settings, and .gitignore tweaks that aren't part of the beaconmock port. Worth splitting into a separate change to keep the port reviewable in isolation.
Verdict: COMMENT — leaving the inline notes for author discretion.
| } | ||
|
|
||
| async fn mount_events(server: &MockServer, state: Arc<SharedState>) { | ||
| Mock::given(method("GET")) |
There was a problem hiding this comment.
Do we need to handle stream here? the /eth/v1/events in charon create a stream, not a single response
| .and_then(Value::as_str) | ||
| .and_then(|value| value.parse().ok()) | ||
| .filter(|slots| *slots > 0) | ||
| .unwrap_or(16) |
There was a problem hiding this comment.
Should not unwrap_or, return error instead: https://github.com/ObolNetwork/charon/blob/2c42245a686ed61d44716be52a1e5d4a34529a6b/testutil/beaconmock/options.go#L709
| "/eth/v1/config/fork_schedule", | ||
| "/eth/v1/node/version", | ||
| "/eth/v1/config/spec", | ||
| ]; |
There was a problem hiding this comment.
Missing /eth/v2/beacon/blocks/0
No description provided.