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.
| "balance": rng.r#gen::<u64>().to_string(), | ||
| "status": random_validator_status(&mut rng), | ||
| "validator": { | ||
| "pubkey": format!("0x{}", hex::encode([rng.r#gen::<u8>(); 48])), |
There was a problem hiding this comment.
bug — [rng.r#gen::<u8>(); 48] evaluates the random byte exactly once and copies it 48 times (Rust array-repeat Copy semantics). Every emitted pubkey is therefore one of only 256 distinct values (0xaaaa…aa, 0xbbbb…bb, …), and validators generated in the same (0..count).map(...) chain typically share the same pubkey. This drastically reduces the value of the fuzzer for any code that branches on pubkey content (lookup by pubkey, hex parsing, BLS validation paths).
The same pattern is repeated in this file at lines 213, 242, and 264.
Fix:
let mut pk = [0u8; 48];
rng.fill_bytes(&mut pk);
// …
"pubkey": format!("0x{}", hex::encode(pk)),Note the deliberate zero arrays at lines 331/334 ([0u8; 20], [0u8; 256]) are intentional — only the random-pubkey sites need to change.
|
|
||
| impl Drop for HeadProducer { | ||
| fn drop(&mut self) { | ||
| self.shutdown.notify_waiters(); |
There was a problem hiding this comment.
major — Notify::notify_waiters() only wakes tasks that have already started polling notified(). Between HeadProducer::spawn returning and the spawned task at line 140 actually being polled by the scheduler, there is a window where Drop can fire notify_waiters() and no waiter exists. The signal is then lost and the ticker runs indefinitely (one leaked tokio task per dropped BeaconMock).
This happens to be invisible today because tokio::pin!(shutdown_fut) is created up-front and the subsequent Notified future captures the notify generation at construction. But the invariant is fragile — moving the pin! after any .await, or freshly constructing notified() inside the loop, would silently reintroduce the race.
Recommended: switch to tokio_util::sync::CancellationToken (cancellation is persistent and lets you also cancel_token.is_cancelled() defensively in the body), or use Notify::notify_one() which stores a permit.
As a side benefit, capturing the JoinHandle from tokio::spawn and .abort()-ing it on drop adds belt-and-braces shutdown that doesn't depend on cooperative select!.
| exit_epoch: u64::MAX.to_string(), | ||
| pubkey, | ||
| slashed: false, | ||
| withdrawable_epoch: u64::MAX.to_string(), |
There was a problem hiding this comment.
major — exit_epoch and withdrawable_epoch are set to u64::MAX, but Charon's ValidatorSetA (charon/testutil/beaconmock/options.go:103-140) leaves these fields zero — only PublicKey, EffectiveBalance, ActivationEligibilityEpoch, ActivationEpoch, and WithdrawalCredentials are populated. The u64::MAX value comes from the Go fuzzer (beaconmock_fuzz.go:44-45), not from ValidatorSetA.
Consequence: any test using ValidatorSet::validator_set_a() will see different validator JSON than Charon's mock, with exit_epoch: "18446744073709551615" instead of "0". Either set these to 0 to match Charon, or document the intentional deviation.
| ] | ||
| }) | ||
| }) | ||
| .await; |
There was a problem hiding this comment.
major — This hand-rolled fork_schedule returns epoch="0" for every entry, while Charon serves the response embedded in static.json (which has Capella→Deneb at epoch 256 and Deneb→Electra at 29696). The Rust port reads the embedded snapshot for the spec (line 496) but ignores its fork_schedule entry in favor of this stub.
Clients that branch on fork transition epochs (e.g. to decide which fork's deserializer to use for a slot) will see different transition points under Pluto vs Charon. Either serve static_endpoint_data("/eth/v1/config/fork_schedule") directly, or hard-code the same epoch values that the snapshot uses.
|
|
||
| let epoch = epoch_from_path(request.url.path()); | ||
| let slots_per_epoch = slots_per_epoch(state); | ||
| let validators = read_lock(&state.validator_set).validators(); |
There was a problem hiding this comment.
major — Charon's WithDeterministicProposerDuties (options.go:363-405) iterates mock.ActiveValidators(ctx).Indices() — only validators where Status.IsActive(). This handler iterates state.validator_set.validators(), returning every validator regardless of status.
Today this is masked because Validator::active is the only constructor and always sets ActiveOngoing. But MockState::set_validator_set accepts arbitrary ValidatorSets, so any caller that inserts a non-active validator will see it receive proposer duties here but not under Charon. Filter on validator.status.is_active() (or document this deliberate divergence).
| "state": hex_0x(head.state), | ||
| "epoch_transition": false, | ||
| "current_duty_dependent_root": hex_0x(head.current_duty_dependent_root), | ||
| "previous_duty_dependent_root": hex_0x(head.previous_duty_dependent_root), |
There was a problem hiding this comment.
nit (deviation from Go): Charon's headJSON.PreviousDutyDependentRoot is set to currentHead.CurrentDutyDependentRoot (a Go bug — see headproducer.go:125). The Rust port emits the proper previous_duty_dependent_root value. Strictly more correct, but anyone diffing wire output against Charon will see a difference at every head event. Document the intentional deviation in the module preamble.
| let server = MockServer::start().await; | ||
|
|
||
| // Higher priority (lower number) mounts must register before the defaults | ||
| // so wiremock falls back to the default routes when no override matches. |
There was a problem hiding this comment.
nit: This comment claims wiremock falls back based on mount order, but wiremock's precedence is determined by with_priority first; registration order is only a tie-breaker between equally-priorities mocks. The current ordering matters only for the priority-50 cohort (endpoint_overrides mounted before no_*_duties). Reword to describe the actual reason (or remove).
| "committee_bits": hex_0x(committee_bits), | ||
| } | ||
| }) | ||
| } |
There was a problem hiding this comment.
nit (parity): Charon's AggregateAttestationFunc (charon/testutil/beaconmock/options.go:569-588) returns a VersionedAttestation whose top-level ValidatorIndex: &valIdx field is set to 0. The Rust response emits only the inner data object. If any consumer reads top-level validator_index from this endpoint, parity breaks. Probably no consumer does — flagging for awareness.
| "/eth/v1/config/fork_schedule", | ||
| "/eth/v1/node/version", | ||
| "/eth/v1/config/spec", | ||
| ]; |
There was a problem hiding this comment.
nit: REQUIRED_ENDPOINTS lists 5 endpoints; scripts/gen_static_beaconmock.sh fetches 6 (it also adds /eth/v2/beacon/blocks/0). The comment claims they mirror each other. Either drop the obsolete endpoint from the script or add it to the required-endpoints list so the snapshot stays self-consistent.
| .mount(server) | ||
| .await; | ||
| } | ||
| } |
There was a problem hiding this comment.
nit: The regex-form override (endpoint.starts_with('^')) is not exercised by any test. Add at least one test mounting e.g. ^/eth/v1/validator/duties/proposer/[0-9]+$ to lock in the dispatch. Also worth documenting that plain-path callers must escape regex specials if their path contains . or ? (they don't today, but it's a footgun).
No description provided.