Skip to content

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

Open
varex83 wants to merge 8 commits into
mainfrom
bohdan/validatormock-foundation
Open

feat(testutil/validatormock): port Charon validatormock to Rust#418
varex83 wants to merge 8 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.

Base automatically changed from bohdan/beaconmockapi to main May 18, 2026 14:56
@varex83agent varex83agent force-pushed the bohdan/validatormock-foundation branch from 9abe29e to 9f412fb Compare May 20, 2026 20:46
@varex83 varex83 marked this pull request as ready for review May 21, 2026 11:45
@varex83 varex83 linked an issue May 21, 2026 that may be closed by this pull request
varex83 and others added 8 commits May 22, 2026 16:22
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/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.
…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/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.
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.
… signal

The previous `wait_ready(cell)` design parked consumers inside
`OnceCell::get_or_init(noop_pending)`. The first waiter acquired the
OnceCell's init semaphore permit and held it forever inside a never-
resolving `std::future::pending` future. A producer's subsequent
`cell.set(())` returned `SetError::InitializingError` because the
permit was held, leaving the waiter parked indefinitely — a hard
deadlock reachable whenever `aggregate`/`message`/`prepare_slot` ran
ahead of its corresponding setter (legal under the scheduler's
spawn-order non-determinism).

Hoist `attest.rs`'s `CloseOnce` (AtomicBool + Notify) into a shared
`validatormock::close_once` module and apply it to synccomm's
`duties_ok`, `selections_ok`, and `block_root_ok` signals. The primitive
notifies *every* registered waiter on close (`notify_waiters`) and the
`wait` loop re-checks `closed` after registering interest, so the
producer-before-consumer and consumer-before-producer schedules both
resolve correctly.

`set_*` helpers lose their `Result` return type — `CloseOnce::close` is
idempotent (matching the deliberate Go divergence already documented on
`attest.rs::SlotAttester::prepare`).

Includes a regression test `waiter_ahead_of_producer_still_wakes` that
exercises eight waiters parked before close.
…shutdown

`run_scheduler` previously dropped each `tokio::spawn` JoinHandle, so duty
tasks could outlive `Component::shutdown().await`. In tests this leaks
in-flight HTTP requests against `wiremock::MockServer` across test
boundaries, surfacing as `received unexpected request` warnings or
order-dependent failures.

Switch to `tokio::task::JoinSet` to track duty tasks; reap finished ones
inside the main select, and drain the set before the scheduler returns.
`Component::shutdown` awaits the scheduler task, so once the scheduler
returns every duty has been joined.

Also documents the `Drop` impl to make explicit that `shutdown().await`
is required for clean drainage — `Drop` cannot `.await`.
…egistration

Go's `runDuty` has no `case` for `core.DutyBuilderRegistration`, so it
falls through to the `default:` arm and returns
`errors.New("unexpected duty")`
(charon/testutil/validatormock/component.go:305-306). The Rust port was
silently returning `Ok(())`, swallowing what Go surfaces as a logged
warning every epoch (the duty is scheduled by `duty_start_times` —
matching Go's `dutyStartTimeFuncsByDuty[BuilderRegistration]`).

Return `Error::Malformed("unexpected duty: DutyBuilderRegistration")`
so the scheduler's `warn!` line emits a duty-failed warning each epoch,
restoring functional equivalence with Go.
@varex83agent varex83agent force-pushed the bohdan/validatormock-foundation branch from 9f412fb to 92e1eed Compare May 22, 2026 14:28
Copy link
Copy Markdown
Collaborator

@iamquang95 iamquang95 left a comment

Choose a reason for hiding this comment

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

Other parts LGTM

// `cancel.cancelled()` in its outer select; bodies already running run
// to completion (mirroring Go's `Run`, which lets the duty finish
// before returning from the loop).
while duties.join_next().await.is_some() {}
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.

if prepare() fails before closing readiness signals, later attest/aggregate/sync tasks wait forever; shutdown() cancels then drains JoinSet, so it can block forever

atts: &[VersionedAttestationJson],
) -> Result<()> {
const ENDPOINT: &str = "/eth/v2/beacon/pool/attestations";
submit_json(eth2_cl, ENDPOINT, atts).await
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.

In Charon this goes through the eth2 client SubmitAttestations, so it return the real submit error. This is only json from go golder file, which may not match the actual beacon API shape

common: CommonOpts::default(),
signed_aggregate_and_proofs: aggs,
};
submit_json(eth2_cl, ENDPOINT, &body).await
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.

same as submit_attestations

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/validatormock

3 participants