Skip to content

feat(testutil): add beaconmock#416

Open
varex83 wants to merge 16 commits into
mainfrom
bohdan/beaconmockapi
Open

feat(testutil): add beaconmock#416
varex83 wants to merge 16 commits into
mainfrom
bohdan/beaconmockapi

Conversation

@varex83
Copy link
Copy Markdown
Collaborator

@varex83 varex83 commented May 14, 2026

No description provided.

varex83 added 6 commits May 14, 2026 18:46
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.
Copy link
Copy Markdown
Collaborator

@varex83agent varex83agent left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

  1. 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 (Copy array-repeat semantics). Every fuzzed pubkey collapses to one of 256 values like 0xaaaa...aa, gutting the fuzz coverage of any code that branches on pubkey content. Use let mut pk = [0u8; 48]; rng.fill_bytes(&mut pk);.

Major / parity

  1. pluto-testutil is a normal [dependencies] entry of pluto-eth2util (crates/eth2util/Cargo.toml:24). Not modified in this PR but materially worsened by it: wiremock (added to testutil's [dependencies] here) now transitively links into every production crate that depends on eth2util (cli, cluster, core, p2p, app, the pluto binary). All current usages inside eth2util are #[cfg(test)], so nothing is called in release, but the entire mock infrastructure is still compiled and shipped. crates/cluster/Cargo.toml already demonstrates the right fix: pluto-testutil = { workspace = true, optional = true } plus a feature flag.
  2. headproducer.rs:83Drop::notify_waiters() races with the spawned ticker. notify_waiters only wakes tasks already polling notified(). If BeaconMock is constructed-then-dropped before the spawned task at line 140 first polls, the shutdown signal is lost and the ticker leaks. Switch to tokio_util::sync::CancellationToken (persistent + shareable) or Notify::notify_one() (stores a permit).
  3. state.rs:46-49Validator::active sets exit_epoch/withdrawable_epoch to u64::MAX, but Charon's ValidatorSetA leaves them at 0. These u64::MAX defaults come from Go's fuzzer (beaconmock_fuzz.go:44-45), not from ValidatorSetA. Wire JSON for validator_set_a() will not match Charon's.
  4. defaults.rs:42-53fork_schedule is hand-rolled with epoch="0" everywhere, while the embedded static.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.
  5. defaults.rs:295-341proposer_duties_response iterates the full validator set, but Charon's WithDeterministicProposerDuties iterates active validators only (mock.ActiveValidators(ctx).Indices()). Inactive validators inserted via set_validator_set will receive duties under the Rust mock but not under Charon's.

Notable minors

  • fuzzer.rs:26FUZZ_MOCK_PRIORITY=10 is lower (= higher precedence) than OVERRIDE_PRIORITY=50, so user endpoint_overrides are silently shadowed by fuzzer routes when both are enabled. In Charon they cannot collide (fuzzer goes through Mock.Func fields, overrides go through static JSON). Either flip the priorities or document the precedence.
  • headproducer.rs:298wait_for_first_head busy-polls std::thread::sleep(1ms) inside a wiremock responder for up to slot_duration * 2. With the default 12s slot, an early /eth/v1/events request can stall the entire mock server thread for ~24s. Cap the budget at a small fixed value or convert to async.
  • mod.rs:37Error::Client(#[source] anyhow::Error) wraps a typed eth2 error in anyhow. Use #[from] pluto_eth2api::EthBeaconNodeApiClientError.
  • headproducer.rs:347 + state.rs:203hex_0x is defined in both files. All other beaconmock modules already import the state::hex_0x version.

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.

Comment thread crates/testutil/src/beaconmock/fuzzer.rs
Comment thread crates/testutil/src/beaconmock/headproducer.rs Outdated
Comment thread crates/testutil/src/beaconmock/state.rs Outdated
Comment thread crates/testutil/src/beaconmock/defaults.rs
Comment thread crates/testutil/src/beaconmock/defaults.rs Outdated
Comment thread crates/testutil/src/beaconmock/headproducer.rs Outdated
Comment thread crates/testutil/src/beaconmock/mod.rs
Comment thread crates/testutil/src/beaconmock/attestation.rs
Comment thread crates/testutil/build.rs
Comment thread crates/testutil/src/beaconmock/options.rs
varex83 and others added 10 commits May 14, 2026 22:33
…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.
Copy link
Copy Markdown
Collaborator

@varex83agent varex83agent left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. Charon parity tweaks in defaults/handlers where Pluto is silently more lenient than Go (MIN_GENESIS_TIME override, fuzzer aggregate version, slot_duration=0 and k=0 fallbacks, empty topics= returning a head event). Each one is small but they will add up if not tracked.
  2. Code-dup cleanups — three near-identical mount_json* helpers could collapse, and the duty-init locking on a fresh Arc can move into the constructor.
  3. Test-helper hygienedefault_spec() re-parses the embedded ~80-key static.json on every builder call; ticker JoinHandle is discarded; endpoint_overrides and no_*_duties share 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.

Comment thread crates/testutil/src/beaconmock/defaults.rs
Comment thread crates/testutil/src/beaconmock/fuzzer.rs
Comment thread crates/testutil/src/beaconmock/attestation.rs
Comment thread crates/testutil/src/beaconmock/defaults.rs
Comment thread crates/testutil/src/beaconmock/headproducer.rs
Comment thread crates/testutil/src/beaconmock/fuzzer.rs
Comment thread crates/testutil/src/beaconmock/mod.rs
Comment thread crates/testutil/src/beaconmock/headproducer.rs
Comment thread crates/testutil/build.rs
Comment thread crates/testutil/src/beaconmock/options.rs
@varex83 varex83 marked this pull request as ready for review May 15, 2026 08:09
@varex83 varex83 linked an issue May 15, 2026 that may be closed by this pull request
}

async fn mount_events(server: &MockServer, state: Arc<SharedState>) {
Mock::given(method("GET"))
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment thread crates/testutil/build.rs
"/eth/v1/config/fork_schedule",
"/eth/v1/node/version",
"/eth/v1/config/spec",
];
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing /eth/v2/beacon/blocks/0

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Implement testutil/beaconmock

3 participants