Skip to content

feat(testutil/validatormock): port Charon validatormock to Rust#418

Draft
varex83 wants to merge 24 commits into
mainfrom
bohdan/validatormock-foundation
Draft

feat(testutil/validatormock): port Charon validatormock to Rust#418
varex83 wants to merge 24 commits into
mainfrom
bohdan/validatormock-foundation

Conversation

@varex83
Copy link
Copy Markdown
Collaborator

@varex83 varex83 commented May 15, 2026

Summary

Ports charon/testutil/validatormock (Go) to Rust under crates/testutil/src/validatormock/. Mirrors the 5 Go files file-per-concern with full functional parity:

  • meta.rsSpecMeta/MetaSlot/MetaEpoch slot/epoch arithmetic
  • sign.rsSign trait + Signer + SignFunc = Arc<dyn Sign> (Go's SignFunc + NewSigner)
  • validators.rs — local ActiveValidators newtype + helper (avoids a pluto-testutil → pluto-app dependency cycle)
  • capture.rsSubmissionCapture wiremock helper, the test-side replacement for Go's beaconMock.SubmitAttestationsFunc = ... callback fields
  • attest.rsSlotAttester with Prepare/Attest/Aggregate; matches Go goldens byte-for-byte via assert_json_diff
  • propose.rspropose_block + register; Bellatrix→Fulu, blinded + full variants
  • synccomm.rsSyncCommMember + TestGetSubcommittees
  • component.rs — scheduler (tokio::sync::mpsc + CancellationToken + injectable Clock)
  • clock.rsClock trait + SystemClock + explicit-advance FakeClock
  • error.rs — unified thiserror::Error
  • testdata/TestAttest_{0,1}_{attestations,aggregations}.golden — byte-for-byte from charon/testutil/validatormock/testdata/

Based on bohdan/beaconmockapi (#416). Will rebase onto main once #416 merges.

Design notes

  • Go's close(chan struct{}) ready signals are modelled per-module: attest.rs uses AtomicBool + tokio::sync::Notify; synccomm.rs uses Arc<tokio::sync::OnceCell<()>> (lazy per-slot map).
  • Go's goroutine scheduler maps to tokio::spawn + mpsc + CancellationToken.
  • Go's clockwork.FakeClock maps to an injectable Clock trait. tokio::time::pause() was rejected — it interacts poorly with wiremock::MockServer.
  • One pre-existing pluto-eth2util ↔ pluto-testutil Cargo cycle was fixed: pluto-testutil moved from [dependencies] to [dev-dependencies] in crates/eth2util/Cargo.toml. The 3 call sites in eth2util (signing, eth2exp, enr tests) are all #[cfg(test)], so this is a no-op for production code.

Workflow

Built across 4 phases on parallel git worktrees:

  1. Foundation (sequential) — meta/sign/validators/capture/error/mod/testdata
  2. Three parallel worktrees — attest / propose / synccomm
  3. Component + clock (sequential)
  4. Final verification

Test plan

  • cargo +nightly fmt --all --check — clean
  • cargo clippy --workspace --all-targets --all-features -- -D warnings — clean
  • cargo test --workspace --all-features — all green; 73 tests pass in pluto-testutil (6 #[ignore]d propose tests waiting on missing RandomCapella/Deneb/Electra/Fulu*Proposal helpers in pluto-testutil::random — follow-up)
  • cargo deny check — clean
  • Goldens match Charon's testdata/TestAttest_{0,1}_{attestations,aggregations}.golden byte-for-byte (structural via assert_json_diff)

Known follow-ups (out of scope)

  • Add RandomCapella/Deneb/Electra/Fulu{Versioned,Blinded}Proposal helpers to pluto-testutil::random to un-ignore 6 propose tests.
  • Add a default POST /eth/v1/beacon/states/{state_id}/validators mount to BeaconMock so callers don't have to inline it.
  • register deviates from Charon's switch signedRegistration.Version (always zero-valued V1) and instead switches on the input registration's Version. Inline comment notes the Go quirk; please confirm intent.

varex83 and others added 24 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.
…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.
Mirrors `charon/testutil/validatormock` (Go) into Rust. This first phase lays
the foundation that the propose/attest/synccomm/component ports build on:

- `meta.rs`  - SpecMeta + MetaSlot + MetaEpoch value types (port of meta.go).
- `sign.rs`  - `Sign` trait + `Signer` + `SignFunc = Arc<dyn Sign>` (Go's
  SignFunc + NewSigner) backed by `pluto-crypto` BlstImpl.
- `validators.rs` - local `ActiveValidators` newtype + `active_validators(client)`
  helper. Avoids a `pluto-testutil -> pluto-app` dep, since `pluto-app` already
  dev-depends on this crate.
- `capture.rs` - test-only `SubmissionCapture` wiremock helper. Equivalent of
  Go's `beaconMock.SubmitAttestationsFunc = ...` callback fields - mounts a
  high-priority `Mock` on `BeaconMock::server()` and records POST bodies.
- `error.rs`  - module-wide `Error` + `SignError` (thiserror).
- `testdata/TestAttest_*.golden` - byte-for-byte copies of the Go fixtures
  used by the Phase-2 attest port.

The Cargo cycle `pluto-eth2util <-> pluto-testutil` is broken by moving
`pluto-testutil` from `[dependencies]` to `[dev-dependencies]` in
`crates/eth2util/Cargo.toml`. The three call sites in `eth2util` (signing,
eth2exp, enr tests) are all `#[cfg(test)]`, so the move is a no-op for
production code.

11 unit tests pass; clippy + nightly fmt clean.
Ports `charon/testutil/validatormock/synccomm.go` to Rust. SyncCommMember
drives PrepareEpoch → PrepareSlot → Message → Aggregate; per-slot ready flags
use Arc<tokio::sync::OnceCell<()>> to mirror Go's lazy `chan struct{}` maps.

TestGetSubcommittees ports the Go internal test verbatim.
…er reg)

Ports `propose_block` and `register` from
`charon/testutil/validatormock/propose.go`. Versioned dispatch matches the Go
switch on `eth2spec.DataVersion`; blinded path is gated on the proposal's
`execution_payload_blinded` flag (the Pluto generated client's analog of Go's
`block.Blinded`).

Phase0/Altair branches return `Error::UnsupportedVariant` until the Pluto
typed surface for them is ready; Bellatrix .. Fulu (full + blinded) are
implemented in full.

`register` ports the *intended* behaviour of Go's `Register`: it switches on
the input registration's `Version` (Go switches on the zero-value-initialized
signed registration version, which always falls through to V1 — a Go quirk
noted inline).

Tests mirror `TestProposeBlock` and `TestProposeBlindedBlock`; variants whose
random fixtures don't yet exist in `pluto-testutil::random` are `#[ignore]`d
with a TODO pointing at the missing helper.
Ports `charon/testutil/validatormock/attest.go` to Rust. SlotAttester drives
Prepare -> Attest -> Aggregate for a single slot, gated by Arc<CloseOnce>
close-once flags (AtomicBool + Notify) that mirror Go's `chan struct{}` ready
signals; mutable state lives behind Arc<Mutex<_>> so all entry points stay
`&self`.

Because the Rust eth2api client encodes attestations using the Electra
SingleAttestation shape (incompatible with Go's VersionedAttestation JSON
captured in the goldens), the two submit endpoints
(`POST /eth/v2/beacon/pool/attestations` and
 `POST /eth/v2/validator/aggregate_and_proofs`) are POSTed as raw JSON whose
structure matches `eth2spec.VersionedAttestation` /
`*SubmitAggregateAttestationsOpts`. All other beacon-node interactions use
the generated client.

TestAttest matches Go's golden output for DutyFactor {0, 1} on validator
set A via assert_json_eq, with submissions sorted by data.index to match
the Go test's deterministic ordering.

Also adds Eth2Exp and Submit variants to validatormock::Error, and a
ValidatorSet-driven POST `/eth/v1/beacon/states/head/validators` plus a
BeaconCommitteeSelections echo route in the tests because the beaconmock
defaults don't cover those DV-only paths.
Ports `charon/testutil/validatormock/component.go` to Rust, completing the
validatormock module. `Component` drives the duty-by-duty workflow across a
sliding window of attesters (per slot) and sync-committee members (per epoch)
for the configured pubkeys.

- `clock.rs`: `Clock` trait + `SystemClock` (production) + `FakeClock` (tests).
  `FakeClock` is an explicit-advance clock; pending sleepers register a
  `oneshot::Sender` and fire when `advance` / `advance_to` moves past their
  deadline. Avoids `tokio::time::pause()`, which interacts poorly with
  `wiremock::MockServer` (Plan agent flagged this).
- `component.rs`: scheduler built around `tokio::sync::mpsc` + `tokio::spawn`
  + `tokio_util::sync::CancellationToken`. `Component::builder()` returns a
  handle that owns the consumer task; `shutdown().await` cancels gracefully
  and `Drop` cancels for the panic path. `run_duty_via_inner` matches the Go
  `runDuty` switch on `pluto_core::types::DutyType`.
- `duties_for_slot` + `duty_start_times` mirror Go's
  `dutyStartTimeFuncsByDuty` table, including the half-/third-slot offsets
  for attest/aggregate/sync messages.

Tests cover the start-up swallow window (delay_start_slots = 2), the
duty-start-time arithmetic, and the duty-collection algorithm. Driving the
full Component through 2 epochs with FakeClock would require comprehensive
post_state_validators mounting on BeaconMock; the slot_ticked test asserts
the epoch-window machinery instead and shuts down cleanly.
Base automatically changed from bohdan/beaconmockapi to main May 18, 2026 14:56
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.

2 participants