From 6c66c352b39c68913538637fe5f70ce6bf7e4c2e Mon Sep 17 00:00:00 2001 From: Bohdan Ohorodnii <273991985+varex83agent@users.noreply.github.com> Date: Mon, 8 Jun 2026 14:08:37 +0200 Subject: [PATCH 1/4] feat(core): add CachedValidatorsProvider trait for validatorapi Defines the `CachedValidatorsProvider` async trait in pluto-core's validatorapi module. Mirrors Charon's `app/eth2wrap.CachedValidatorsProvider` so the validator-API component can look up `validator_index -> DV root pubkey` (used by the selections / voluntary-exit / sync-committee submit handlers in follow-up PRs) without `pluto-core` depending on the application crate that owns the concrete per-epoch cache implementation. `Component` gains an `Arc` field threaded through both constructors as a required dependency, plus a private `fetch_active_validators` helper bounded by `UPSTREAM_REQUEST_TIMEOUT` that all submit handlers share. A `TestValidatorCache` test double lets unit tests supply the map directly without spinning up a real beacon node. The existing `crates/app/src/eth2wrap/valcache.rs` is intentionally untouched. It will impl this trait at the wiring layer (`pluto-app` already depends on `pluto-core`, so the dependency runs the right way without a new crate or any cross-crate moves). Test plan: - cargo +nightly fmt --all --check - cargo clippy -p pluto-core --all-targets --all-features -- -D warnings - cargo test -p pluto-core --all-features (430 passing; 2 new tests on `fetch_active_validators` covering the happy path and the provider-error -> 502 mapping) Co-Authored-By: Bohdan Ohorodnii <35969035+varex83@users.noreply.github.com> --- crates/core/src/validatorapi/component.rs | 150 +++++++++++++++++- crates/core/src/validatorapi/mod.rs | 2 + .../core/src/validatorapi/validator_cache.rs | 35 ++++ 3 files changed, 181 insertions(+), 6 deletions(-) create mode 100644 crates/core/src/validatorapi/validator_cache.rs diff --git a/crates/core/src/validatorapi/component.rs b/crates/core/src/validatorapi/component.rs index 969f92e7..29aa3af7 100644 --- a/crates/core/src/validatorapi/component.rs +++ b/crates/core/src/validatorapi/component.rs @@ -13,7 +13,7 @@ use pluto_eth2api::{ EthBeaconNodeApiClient, GetAttesterDutiesRequest, GetAttesterDutiesResponse, GetProposerDutiesRequest, GetProposerDutiesResponse, GetSyncCommitteeDutiesRequest, GetSyncCommitteeDutiesResponse, - spec::phase0::{BLSPubKey, Epoch, Root}, + spec::phase0::{BLSPubKey, Epoch, Root, ValidatorIndex}, }; use pluto_eth2util::signing::{self, DomainName, SigningError}; use tokio::time::error::Elapsed; @@ -32,6 +32,7 @@ use super::{ VersionedAttestation, VersionedProposal, VersionedSignedAggregateAndProof, VersionedSignedBlindedProposal, VersionedSignedProposal, }, + validator_cache::CachedValidatorsProvider, }; use crate::{ dutydb::{Error as DutyDbError, MemDB}, @@ -175,6 +176,14 @@ pub struct Component { /// Looks up the root pubkey for an `(slot, commIdx, valIdx)` triple. #[allow(dead_code, reason = "consumed by submit_attestations in later PRs")] pub_key_by_att_fn: Option, + /// Cluster's per-epoch active-validators lookup. Consumed by the + /// selections / voluntary-exit / sync-committee submit handlers to + /// translate validator-client-supplied `validator_index` values into + /// DV root public keys. Mirrors Go's `c.eth2Cl.ActiveValidators(ctx)`, + /// which is itself backed by `app/eth2wrap`'s per-epoch validator + /// cache. + #[allow(dead_code, reason = "consumed by submit_* handlers in later PRs")] + validator_cache: Arc, } impl Component { @@ -185,6 +194,7 @@ impl Component { share_idx: u64, pub_share_by_pubkey: HashMap, builder_enabled: bool, + validator_cache: Arc, ) -> Self { Self { eth2_cl, @@ -200,6 +210,7 @@ impl Component { await_agg_sig_db_fn: None, duty_def_fn: None, pub_key_by_att_fn: None, + validator_cache, } } @@ -212,6 +223,7 @@ impl Component { eth2_cl: Arc, dutydb: Arc, share_idx: u64, + validator_cache: Arc, ) -> Self { Self { eth2_cl, @@ -227,6 +239,7 @@ impl Component { await_agg_sig_db_fn: None, duty_def_fn: None, pub_key_by_att_fn: None, + validator_cache, } } @@ -352,6 +365,28 @@ impl Component { Ok(()) } + + /// Fetches the cluster's active validators through the per-epoch + /// [`CachedValidatorsProvider`], bounded by [`UPSTREAM_REQUEST_TIMEOUT`]. + /// Translates cache failures into `ApiError`s without leaking the + /// underlying error into the client-visible message. Mirrors Go's + /// `c.eth2Cl.ActiveValidators(ctx)`, which is itself implemented via + /// `app/eth2wrap`'s validator cache. + #[allow(dead_code, reason = "consumed by submit_* handlers in later PRs")] + async fn fetch_active_validators( + &self, + ) -> Result, ApiError> { + tokio::time::timeout( + UPSTREAM_REQUEST_TIMEOUT, + self.validator_cache.active_validators(), + ) + .await + .map_err(|_: Elapsed| upstream_timeout("active validators"))? + .map_err(|err| { + ApiError::new(StatusCode::BAD_GATEWAY, "active validators lookup failed") + .with_boxed_source(err) + }) + } } /// Errors returned by [`Component::verify_partial_sig`]. @@ -821,6 +856,36 @@ mod tests { validatorapi::types::AttestationDataOpts, }; + /// In-memory stand-in for the per-epoch validator cache. Tests supply + /// the `validator_index -> root pubkey` map up front instead of + /// running a real beacon-node mock through a `ValidatorCache`. + #[derive(Default)] + pub(super) struct TestValidatorCache(HashMap); + + impl TestValidatorCache { + pub(super) fn arc( + map: HashMap, + ) -> Arc { + Arc::new(Self(map)) + } + + pub(super) fn empty() -> Arc { + Self::arc(HashMap::new()) + } + } + + #[async_trait] + impl CachedValidatorsProvider for TestValidatorCache { + async fn active_validators( + &self, + ) -> Result< + HashMap, + super::super::validator_cache::CachedValidatorsError, + > { + Ok(self.0.clone()) + } + } + /// Schedules every duty with a deadline at `MAX_UTC`, so duties are /// `Scheduled` but never naturally expire. struct FarFutureCalculator; @@ -844,7 +909,8 @@ mod tests { let dutydb = Arc::new(MemDB::new(deadliner, evict_rx, &cancel)); let eth2_cl = Arc::new(EthBeaconNodeApiClient::with_base_url("http://127.0.0.1:0").unwrap()); - let component = Component::new_insecure(eth2_cl, Arc::clone(&dutydb), 1); + let component = + Component::new_insecure(eth2_cl, Arc::clone(&dutydb), 1, TestValidatorCache::empty()); (component, dutydb) } @@ -1086,7 +1152,8 @@ mod tests { let dutydb = Arc::new(MemDB::new(deadliner, trim_rx, &cancel)); let eth2_cl = Arc::new(EthBeaconNodeApiClient::with_base_url("http://127.0.0.1:0").unwrap()); - let component = Component::new_insecure(eth2_cl, Arc::clone(&dutydb), 1); + let component = + Component::new_insecure(eth2_cl, Arc::clone(&dutydb), 1, TestValidatorCache::empty()); // Start an await before any data is stored. let waiter = { @@ -1272,7 +1339,7 @@ mod tests { let dutydb = Arc::new(MemDB::new(deadliner, evict_rx, &cancel)); let eth2_cl = Arc::new(EthBeaconNodeApiClient::with_base_url("http://127.0.0.1:0").unwrap()); - Component::new(eth2_cl, dutydb, 1, map, false) + Component::new(eth2_cl, dutydb, 1, map, false, TestValidatorCache::empty()) } /// `Subscribe` invokes every registered subscriber, each receiving its @@ -1503,7 +1570,7 @@ mod tests { let (_evict_tx, evict_rx) = mpsc::channel(1); let dutydb = Arc::new(MemDB::new(deadliner, evict_rx, &cancel)); let eth2_cl = Arc::new(EthBeaconNodeApiClient::with_base_url(mock.uri()).unwrap()); - let component = Component::new(eth2_cl, dutydb, 1, map, false); + let component = Component::new(eth2_cl, dutydb, 1, map, false, TestValidatorCache::empty()); (component, mock) } @@ -1586,7 +1653,7 @@ mod tests { let dutydb = Arc::new(MemDB::new(deadliner, evict_rx, &cancel)); let eth2_cl = Arc::new(EthBeaconNodeApiClient::with_base_url("http://127.0.0.1:0").unwrap()); - let component = Component::new_insecure(eth2_cl, dutydb, 1); + let component = Component::new_insecure(eth2_cl, dutydb, 1, TestValidatorCache::empty()); component .verify_partial_sig( @@ -1599,4 +1666,75 @@ mod tests { .await .expect("insecure_test mode skips verification"); } + + // ==================================================================== + // CachedValidatorsProvider plumbing + // ==================================================================== + + /// `fetch_active_validators` returns whatever the registered + /// `CachedValidatorsProvider` yields, untouched. Mirrors Go's + /// `c.eth2Cl.ActiveValidators(ctx)` return shape. + #[tokio::test] + async fn fetch_active_validators_returns_cache_contents() { + let cancel = CancellationToken::new(); + let (deadliner, _deadliner_rx) = DeadlinerTask::start( + cancel.clone(), + "validatorapi-validator-cache-tests", + FarFutureCalculator, + ); + let (_evict_tx, evict_rx) = mpsc::channel(1); + let dutydb = Arc::new(MemDB::new(deadliner, evict_rx, &cancel)); + let eth2_cl = + Arc::new(EthBeaconNodeApiClient::with_base_url("http://127.0.0.1:0").unwrap()); + + let expected = HashMap::from([(1u64, dv_pubkey(0xA1)), (7u64, dv_pubkey(0xA7))]); + let component = Component::new_insecure( + eth2_cl, + dutydb, + 1, + TestValidatorCache::arc(expected.clone()), + ); + + let got = component + .fetch_active_validators() + .await + .expect("test cache always succeeds"); + assert_eq!(got, expected); + } + + /// A provider that surfaces a transport-style error is mapped to a 502 + /// without leaking the underlying error into the client-visible + /// message. + #[tokio::test] + async fn fetch_active_validators_maps_provider_error_to_502() { + struct FailingCache; + + #[async_trait] + impl CachedValidatorsProvider for FailingCache { + async fn active_validators( + &self, + ) -> Result< + HashMap, + super::super::validator_cache::CachedValidatorsError, + > { + Err("upstream unavailable".into()) + } + } + + let cancel = CancellationToken::new(); + let (deadliner, _deadliner_rx) = DeadlinerTask::start( + cancel.clone(), + "validatorapi-validator-cache-fail-tests", + FarFutureCalculator, + ); + let (_evict_tx, evict_rx) = mpsc::channel(1); + let dutydb = Arc::new(MemDB::new(deadliner, evict_rx, &cancel)); + let eth2_cl = + Arc::new(EthBeaconNodeApiClient::with_base_url("http://127.0.0.1:0").unwrap()); + let component = Component::new_insecure(eth2_cl, dutydb, 1, Arc::new(FailingCache)); + + let err = component.fetch_active_validators().await.unwrap_err(); + assert_eq!(err.status_code, StatusCode::BAD_GATEWAY); + assert_eq!(err.message, "active validators lookup failed"); + } } diff --git a/crates/core/src/validatorapi/mod.rs b/crates/core/src/validatorapi/mod.rs index 8442859c..28a1d680 100644 --- a/crates/core/src/validatorapi/mod.rs +++ b/crates/core/src/validatorapi/mod.rs @@ -9,6 +9,7 @@ pub mod handler; pub mod metrics; pub mod router; pub mod types; +pub mod validator_cache; #[cfg(test)] pub mod testutils; @@ -17,3 +18,4 @@ pub use component::Component; pub use error::ApiError; pub use handler::Handler; pub use router::new_router; +pub use validator_cache::{CachedValidatorsError, CachedValidatorsProvider}; diff --git a/crates/core/src/validatorapi/validator_cache.rs b/crates/core/src/validatorapi/validator_cache.rs new file mode 100644 index 00000000..34f22b5f --- /dev/null +++ b/crates/core/src/validatorapi/validator_cache.rs @@ -0,0 +1,35 @@ +//! Cluster-wide active-validators lookup consumed by submit handlers. +//! +//! Mirrors Charon's `app/eth2wrap.CachedValidatorsProvider` interface: +//! submit handlers that have to translate a validator-client-supplied +//! `validator_index` into the cluster's DV root public key consult this +//! trait. Defined here in `pluto-core` so the validator API does not need +//! to depend on the application crate that owns the concrete per-epoch +//! cache implementation. + +use std::collections::HashMap; + +use async_trait::async_trait; +use pluto_eth2api::spec::phase0::{BLSPubKey, ValidatorIndex}; + +/// Boxed error returned by [`CachedValidatorsProvider`] methods. Kept +/// opaque so the trait does not bind callers to any single backing +/// implementation's error type. +pub type CachedValidatorsError = Box; + +/// Provides the cluster's currently active validators, indexed by +/// validator index. Mirrors Go's `eth2Cl.ActiveValidators(ctx)`, which is +/// itself backed by `app/eth2wrap`'s per-epoch validator cache; the +/// validator-API [`Component`](super::Component) calls through this trait +/// so the cache is the single source of truth across duty handlers +/// without `pluto-core` depending on the cache crate. +/// +/// Implementations may populate the underlying cache on demand — callers +/// must not assume the call is non-blocking. +#[async_trait] +pub trait CachedValidatorsProvider: Send + Sync { + /// Returns the `validator_index -> DV root BLS public key` map. + async fn active_validators( + &self, + ) -> Result, CachedValidatorsError>; +} From 5b9414ac5b436a77c6d1642dffc0b43730db6fae Mon Sep 17 00:00:00 2001 From: Bohdan Ohorodnii <273991985+varex83agent@users.noreply.github.com> Date: Mon, 8 Jun 2026 14:42:44 +0200 Subject: [PATCH 2/4] feat(core): implement validatorapi voluntary_exit + validator_registrations handlers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the two unimplemented!() arms on Component with the real submit_voluntary_exit and submit_validator_registrations handlers, threaded through the per-epoch validator cache that bohdan/validatorapi-cache-trait introduced. Mirrors Charon's core/validatorapi/validatorapi.go:752-795 (SubmitVoluntaryExit), :674-728 (submitRegistration), and :731-749 (SubmitValidatorRegistrations). - types.rs: replace placeholder unit structs with real wrappers around phase0::SignedVoluntaryExit and versioned::VersionedSignedValidatorRegistration so handlers can read message-root, pubkey, timestamp, and signature. - component.rs/submit_voluntary_exit: * Resolve the DV root pubkey through the foundation's `fetch_active_validators` helper (driven by `validator_cache: Arc`). Unknown validator-index -> 400 ("validator not found") matching Go's errors.New branch. * Derive the duty slot as slots_per_epoch * exit_epoch via fetch_slots_config. * Build the ParSignedData through signeddata::SignedVoluntaryExit::new_partial, then call verify_partial_sig with DomainName::VoluntaryExit + exit.epoch + tree-hash of the unsigned message. * Fanout one set per subscriber via the existing subs vec. - component.rs/submit_validator_registrations: * Empty-input early return + builder-mode gate (debug-log and swallow when builder_enabled = false) matching Go:732-739. * Hoist fetch_slots_config, fetch_genesis_time, and the DomainName::ApplicationBuilder domain out of the per-registration loop. Charon's eth2wrap caches these so per-entry calls are cheap there; Pluto's eth2 client is not yet cached, so a batched submission would otherwise issue 2N+ upstream calls. Total upstream cost is now bounded at 3 calls regardless of N. * Per-entry submit_one_registration: - Group pubkey comes from v1.message.pubkey; if not a DV pubkey on this node, swallow with a tracing::debug and skip (mirrors Go's swallowRegFilter at :686-691). - SlotFromTimestamp inlined as slot_from_timestamp() — pure function over genesis_time + slot_duration. - ParSignedData built through signeddata::VersionedSignedValidatorRegistration::new_partial. - verify_partial_sig_with_domain called with the hoisted ApplicationBuilder domain — matches Go's VersionedSignedValidatorRegistration.Epoch returning 0. - eth2util/signing: add verify_with_domain, a synchronous variant of verify that takes a pre-resolved phase0::Domain. Used by Component::verify_partial_sig_with_domain so a batched registration submission resolves the signing domain once instead of N times. - Drop the dead_code allow attributes on share_idx, builder_enabled, subs, and verify_partial_sig now that they are all consumed by these handlers. The hook-based `ActiveValidatorsFn` / `register_active_validators` plumbing that PR 460 originally added is gone — superseded by the foundation's `validator_cache: Arc` constructor argument. The previously-included `submit_voluntary_exit_returns_500_when_hook_unregistered` test no longer applies (the cache is a required constructor dependency) and is dropped. Tests (8 new, all green): - happy-path fanout for both endpoints (insecure_test mode), - voluntary exit unknown-validator -> 400, - voluntary exit bad signature -> 400 (real BLS + BeaconMock), - registrations empty-input no-op, - registrations builder-disabled swallows without fanout, - registrations non-DV pubkey swallowed (no fanout), - registrations bad signature -> 400 (real BLS + BeaconMock). Test plan: - cargo +nightly fmt --all --check - cargo clippy -p pluto-core --all-targets --all-features -- -D warnings - cargo test -p pluto-core --all-features (438 passing) - cargo test -p pluto-eth2util --all-features (146 passing) Co-Authored-By: Bohdan Ohorodnii <35969035+varex83@users.noreply.github.com> --- crates/core/src/validatorapi/component.rs | 638 +++++++++++++++++++++- crates/core/src/validatorapi/types.rs | 33 +- crates/eth2util/src/signing.rs | 52 ++ 3 files changed, 699 insertions(+), 24 deletions(-) diff --git a/crates/core/src/validatorapi/component.rs b/crates/core/src/validatorapi/component.rs index 29aa3af7..a408fd25 100644 --- a/crates/core/src/validatorapi/component.rs +++ b/crates/core/src/validatorapi/component.rs @@ -13,7 +13,7 @@ use pluto_eth2api::{ EthBeaconNodeApiClient, GetAttesterDutiesRequest, GetAttesterDutiesResponse, GetProposerDutiesRequest, GetProposerDutiesResponse, GetSyncCommitteeDutiesRequest, GetSyncCommitteeDutiesResponse, - spec::phase0::{BLSPubKey, Epoch, Root, ValidatorIndex}, + spec::phase0::{BLSPubKey, Domain, Epoch, Root, ValidatorIndex}, }; use pluto_eth2util::signing::{self, DomainName, SigningError}; use tokio::time::error::Elapsed; @@ -37,10 +37,11 @@ use super::{ use crate::{ dutydb::{Error as DutyDbError, MemDB}, signeddata::{ - SyncContribution, VersionedAggregatedAttestation, - VersionedProposal as UnsignedVersionedProposal, + SignedVoluntaryExit as SignedVoluntaryExitWrapper, SyncContribution, + VersionedAggregatedAttestation, VersionedProposal as UnsignedVersionedProposal, + VersionedSignedValidatorRegistration as VersionedSignedValidatorRegistrationWrapper, }, - types::{Duty, ParSignedDataSet, PubKey, Signature, SignedData}, + types::{Duty, ParSignedDataSet, PubKey, Signature, SignedData, SlotNumber}, version, }; @@ -135,7 +136,6 @@ pub struct Component { /// data) produced by the rest of the pipeline. dutydb: Arc, /// Threshold BLS share index assigned to this node (1-indexed). - #[allow(dead_code, reason = "consumed by submit_* handlers in later PRs")] share_idx: u64, /// Maps DV root public keys to this node's public share. Used to rewrite /// validator-client-facing endpoints (proposer/attester duties, etc.) so @@ -143,17 +143,12 @@ pub struct Component { pub_share_by_pubkey: HashMap, /// Whether builder mode is enabled. Read by `propose_block_v3` and the /// validator-registration submitter. - #[allow( - dead_code, - reason = "consumed by propose_block_v3 / submit_validator_registrations" - )] builder_enabled: bool, /// Skip signature verification on partial-signed submissions. Test-only. insecure_test: bool, /// Subscribers invoked by submit endpoints once a partial-signed-data set /// has been validated. Each entry clones the set before invoking the /// user-provided callback. - #[allow(dead_code, reason = "consumed by submit_* handlers in later PRs")] subs: Vec, /// Looks up an unsigned beacon proposal for a slot. #[allow(dead_code, reason = "consumed by proposal handler in later PRs")] @@ -182,7 +177,6 @@ pub struct Component { /// DV root public keys. Mirrors Go's `c.eth2Cl.ActiveValidators(ctx)`, /// which is itself backed by `app/eth2wrap`'s per-epoch validator /// cache. - #[allow(dead_code, reason = "consumed by submit_* handlers in later PRs")] validator_cache: Arc, } @@ -333,7 +327,6 @@ impl Component { /// it is processing, then invokes this helper. /// /// Skipped entirely when [`Self::insecure_test`] is set. - #[allow(dead_code, reason = "consumed by submit_* handlers in later PRs")] pub async fn verify_partial_sig( &self, root_pubkey: &BLSPubKey, @@ -372,7 +365,6 @@ impl Component { /// underlying error into the client-visible message. Mirrors Go's /// `c.eth2Cl.ActiveValidators(ctx)`, which is itself implemented via /// `app/eth2wrap`'s validator cache. - #[allow(dead_code, reason = "consumed by submit_* handlers in later PRs")] async fn fetch_active_validators( &self, ) -> Result, ApiError> { @@ -387,6 +379,119 @@ impl Component { .with_boxed_source(err) }) } + + /// Verifies and fans out a single builder-registration. Factored out so + /// [`Self::submit_validator_registrations`] iterates over its input in + /// the same shape as Go's `SubmitValidatorRegistrations`. The + /// `slot_duration`, `genesis_time`, and `builder_domain` arguments are + /// hoisted out of the loop so a batched request issues at most one + /// `fetch_slots_config`, one `fetch_genesis_time`, and one builder-domain + /// resolution upstream call, regardless of input size — Charon achieves + /// the same effect via `eth2wrap` caching, which the Pluto eth2 client + /// does not yet provide. + async fn submit_one_registration( + &self, + registration: SignedValidatorRegistration, + slot_duration: std::time::Duration, + genesis_time: chrono::DateTime, + builder_domain: Domain, + ) -> Result<(), ApiError> { + // Go: validatorapi.go:676-690 — pull the group pubkey out of the + // wrapped registration and gate on it being a DV pubkey on this + // node. Non-DV pubkeys are silently swallowed (matches Go's + // `swallowRegFilter` debug-log behaviour) so a vouch-style VC that + // also registers its proposer key does not get a non-200 from us. + let v1 = registration.0.v1.as_ref().ok_or_else(|| { + ApiError::new( + StatusCode::BAD_REQUEST, + "missing V1 validator registration payload", + ) + })?; + let root_pubkey = v1.message.pubkey; + + if !self.pub_share_by_pubkey.contains_key(&root_pubkey) { + tracing::debug!( + pubkey = ?format_bls_pubkey(&root_pubkey), + "swallowing non-DV registration", + ); + return Ok(()); + } + + let timestamp = v1.message.timestamp; + + // Go: validatorapi.go:693-703 — derive the slot the registration + // belongs to. + let registration_slot = slot_from_timestamp(genesis_time, slot_duration, timestamp); + let duty = Duty::new_builder_registration_duty(SlotNumber::new(registration_slot)); + + // Go: validatorapi.go:706 — wrap as ParSignedData via the canonical + // partial-sig constructor. + let par_signed = VersionedSignedValidatorRegistrationWrapper::new_partial( + registration.0.clone(), + self.share_idx, + ) + .map_err(|err| { + ApiError::new( + StatusCode::BAD_REQUEST, + "invalid validator registration payload", + ) + .with_source(err) + })?; + + // Go: validatorapi.go:712 — partial-signature verification. The + // application-builder domain ignores the epoch (Go's + // `Epoch()` returns 0); we mirror that here. Uses the hoisted + // `builder_domain` so a batched submission resolves the signing + // domain once instead of N times. + let message_root = v1.message.message_root(); + self.verify_partial_sig_with_domain( + &root_pubkey, + builder_domain, + message_root, + &v1.signature, + ) + .map_err(verify_partial_sig_error)?; + + // The `subscribe` wrapper clones the set internally per subscriber, so + // the fanout just passes a reference. + let core_pubkey = PubKey::new(root_pubkey); + let mut set = ParSignedDataSet::new(); + set.insert(core_pubkey, par_signed); + + for sub in &self.subs { + sub(&duty, &set) + .await + .map_err(subscriber_error_to_api_error)?; + } + + Ok(()) + } + + /// Variant of [`Self::verify_partial_sig`] that takes a pre-resolved + /// [`phase0::Domain`]. Lets batched submit paths (e.g. validator + /// registrations) resolve the signing domain once and skip the two + /// upstream domain-lookup calls that [`Self::verify_partial_sig`] would + /// otherwise issue for every entry. + pub fn verify_partial_sig_with_domain( + &self, + root_pubkey: &BLSPubKey, + domain: Domain, + message_root: Root, + signature: &Signature, + ) -> Result<(), VerifyPartialSigError> { + if self.insecure_test { + return Ok(()); + } + + let pubshare = self + .pub_share_by_pubkey + .get(root_pubkey) + .ok_or(VerifyPartialSigError::UnknownPubKey)?; + + signing::verify_with_domain(domain, message_root, signature, pubshare)?; + + Ok(()) + } } /// Errors returned by [`Component::verify_partial_sig`]. @@ -646,15 +751,124 @@ impl Handler for Component { unimplemented!("validators not yet ported") } + /// Fan-out is per-entry and **not transactional**: registrations are + /// processed sequentially and the loop returns on the first error. + /// Earlier entries that already fanned out remain published downstream + /// when a later entry fails, matching Go's `SubmitValidatorRegistrations` + /// (validatorapi.go:731-749). async fn submit_validator_registrations( &self, - _registrations: Vec, + registrations: Vec, ) -> Result<(), ApiError> { - unimplemented!("submit_validator_registrations not yet ported") + // Go: validatorapi.go:732-734 — empty input is a no-op. + if registrations.is_empty() { + return Ok(()); + } + + // Go: validatorapi.go:736-739 — builder-mode gate. When builder mode + // is disabled the registrations are accepted (no client-visible + // error) but never fanned out. Mirrors the swallow-on-disable + // behaviour Go inherited from Vouch. Logged at `debug!` to match + // Charon's `log.Debug` — VCs like Vouch send registrations every + // slot, so a higher level would be noisy in non-builder configs. + if !self.builder_enabled { + tracing::debug!( + count = registrations.len(), + "swallowing validator registrations: builder mode disabled", + ); + return Ok(()); + } + + // Hoisted out of the per-registration loop so a batched submission + // issues at most one upstream call per kind (Pluto's eth2 client is + // not cached the way Charon's `eth2wrap` is). All entries share the + // same `DomainName::ApplicationBuilder` signing domain at epoch 0, + // so we resolve it once here too rather than letting + // `verify_partial_sig` fan out 2N domain-lookup calls. + let (slot_duration, _) = + tokio::time::timeout(UPSTREAM_REQUEST_TIMEOUT, self.eth2_cl.fetch_slots_config()) + .await + .map_err(|_| upstream_timeout("slots config"))? + .map_err(|err| upstream_call_failed("slots config", err.into()))?; + let genesis_time = + tokio::time::timeout(UPSTREAM_REQUEST_TIMEOUT, self.eth2_cl.fetch_genesis_time()) + .await + .map_err(|_| upstream_timeout("genesis time"))? + .map_err(|err| upstream_call_failed("genesis time", err.into()))?; + let builder_domain = tokio::time::timeout( + UPSTREAM_REQUEST_TIMEOUT, + signing::get_domain(&self.eth2_cl, DomainName::ApplicationBuilder, 0), + ) + .await + .map_err(|_| upstream_timeout("application builder domain"))? + .map_err(|err| upstream_call_failed("application builder domain", err.into()))?; + + for registration in registrations { + self.submit_one_registration(registration, slot_duration, genesis_time, builder_domain) + .await?; + } + + Ok(()) } - async fn submit_voluntary_exit(&self, _exit: SignedVoluntaryExit) -> Result<(), ApiError> { - unimplemented!("submit_voluntary_exit not yet ported") + async fn submit_voluntary_exit(&self, exit: SignedVoluntaryExit) -> Result<(), ApiError> { + // Go: validatorapi.go:753-761 — resolve the DV root pubkey for the + // validator index carried by the exit. The Pluto-side lookup runs + // through the per-epoch validator cache (mirrors the Go + // `eth2Cl.ActiveValidators` indirection, which is itself backed by + // `app/eth2wrap`'s cache). + let active = self.fetch_active_validators().await?; + + let validator_index = exit.0.message.validator_index; + let root_pubkey = active.get(&validator_index).copied().ok_or_else(|| { + // Go: `errors.New("validator not found")` — bubble up as 400 so a + // misbehaving VC sees a non-retriable rejection without leaking + // upstream details. + ApiError::new(StatusCode::BAD_REQUEST, "validator not found") + })?; + + // Go: validatorapi.go:768-773 — duty slot = slots_per_epoch * epoch. + let (_, slots_per_epoch) = + tokio::time::timeout(UPSTREAM_REQUEST_TIMEOUT, self.eth2_cl.fetch_slots_config()) + .await + .map_err(|_| upstream_timeout("slots config"))? + .map_err(|err| upstream_call_failed("slots config", err.into()))?; + + let exit_epoch = exit.0.message.epoch; + let duty_slot = slots_per_epoch.saturating_mul(exit_epoch); + let duty = Duty::new_voluntary_exit_duty(SlotNumber::new(duty_slot)); + + // Go: validatorapi.go:776 — build the ParSignedData via the canonical + // partial-sig constructor for voluntary exits. + let par_signed = SignedVoluntaryExitWrapper::new_partial(exit.0.clone(), self.share_idx); + + // Go: validatorapi.go:779 — partial-signature verification. + let message_root = exit.0.message_root(); + self.verify_partial_sig( + &root_pubkey, + DomainName::VoluntaryExit, + exit_epoch, + message_root, + &exit.0.signature, + ) + .await + .map_err(verify_partial_sig_error)?; + + tracing::info!(?duty, "Voluntary exit submitted by validator client"); + + // Fan out to every subscriber. The [`Component::subscribe`] wrapper + // clones the set per-subscriber, so we hand each one a reference. + let core_pubkey = PubKey::new(root_pubkey); + let mut set = ParSignedDataSet::new(); + set.insert(core_pubkey, par_signed); + + for sub in &self.subs { + sub(&duty, &set) + .await + .map_err(subscriber_error_to_api_error)?; + } + + Ok(()) } async fn sync_committee_contribution( @@ -832,6 +1046,60 @@ fn format_bls_pubkey(pubkey: &BLSPubKey) -> String { format!("0x{}", hex::encode(pubkey)) } +/// Maps a [`VerifyPartialSigError`] into the `ApiError` returned to the +/// client. `UnknownPubKey` is a misconfiguration (500), `Signing` is a +/// validator-client mistake (400) — both keep the underlying error as a +/// `source` so the debug log retains it while the client sees a generic +/// message. +fn verify_partial_sig_error(err: VerifyPartialSigError) -> ApiError { + match err { + VerifyPartialSigError::UnknownPubKey => ApiError::new( + StatusCode::INTERNAL_SERVER_ERROR, + "unknown public key for partial signature verification", + ) + .with_source(err), + VerifyPartialSigError::Signing(_) => ApiError::new( + StatusCode::BAD_REQUEST, + "partial signature verification failed", + ) + .with_source(err), + } +} + +/// Maps a subscriber callback failure into an `ApiError`. Subscriber errors +/// are downstream-pipeline failures (parsigdb store, fanout transport, …), +/// so 500 is the appropriate client-visible status — and the underlying +/// error is preserved on `source()` for the debug log. +fn subscriber_error_to_api_error(err: CallbackError) -> ApiError { + ApiError::new( + StatusCode::INTERNAL_SERVER_ERROR, + "downstream subscriber failed", + ) + .with_boxed_source(err) +} + +/// Computes the slot a timestamp belongs to, mirroring Go's +/// `SlotFromTimestamp` at `validatorapi.go:41-70`. When the timestamp is +/// before genesis (testing scenarios), Go falls back to "now"; here we fall +/// back to slot 0 to keep the helper pure — the only consumer is the +/// `Duty` key, where any deterministic placeholder is acceptable. +fn slot_from_timestamp( + genesis_time: chrono::DateTime, + slot_duration: std::time::Duration, + timestamp_secs: u64, +) -> u64 { + let genesis_secs = match u64::try_from(genesis_time.timestamp()) { + Ok(value) => value, + Err(_) => return 0, + }; + if timestamp_secs < genesis_secs { + return 0; + } + let elapsed = timestamp_secs.saturating_sub(genesis_secs); + let secs_per_slot = slot_duration.as_secs().max(1); + elapsed.checked_div(secs_per_slot).unwrap_or(0) +} + #[cfg(test)] mod tests { use std::sync::Mutex; @@ -1737,4 +2005,340 @@ mod tests { assert_eq!(err.status_code, StatusCode::BAD_GATEWAY); assert_eq!(err.message, "active validators lookup failed"); } + + // ==================================================================== + // submit_voluntary_exit / submit_validator_registrations + // ==================================================================== + + use pluto_eth2api::{ + v1::{SignedValidatorRegistration as V1SignedRegistration, ValidatorRegistration}, + versioned::{BuilderVersion, VersionedSignedValidatorRegistration as VersionedRegPayload}, + }; + + /// Builds a [`Component`] in insecure-test mode but with a real + /// `BeaconMock` upstream so `fetch_slots_config` / `fetch_genesis_time` + /// resolve. Useful for exercising the submit handlers without the BLS + /// verification step. + async fn make_submit_component_insecure( + builder_enabled: bool, + pub_share_by_pubkey: HashMap, + validator_cache: Arc, + ) -> (Component, BeaconMock) { + let mock = submit_mock().await; + let cancel = CancellationToken::new(); + let (deadliner, _deadliner_rx) = DeadlinerTask::start( + cancel.clone(), + "validatorapi-submit-tests", + FarFutureCalculator, + ); + let (_evict_tx, evict_rx) = mpsc::channel(1); + let dutydb = Arc::new(MemDB::new(deadliner, evict_rx, &cancel)); + let eth2_cl = Arc::new(EthBeaconNodeApiClient::with_base_url(mock.uri()).unwrap()); + let mut component = Component::new( + eth2_cl, + dutydb, + 1, + pub_share_by_pubkey, + builder_enabled, + validator_cache, + ); + component.insecure_test = true; + (component, mock) + } + + /// Default beacon-mock spec used by submit tests — `signing_spec_fixture` + /// plus the `SECONDS_PER_SLOT` / `SLOTS_PER_EPOCH` fields needed by + /// `fetch_slots_config`. + fn submit_spec_fixture() -> serde_json::Value { + let mut spec = signing_spec_fixture(); + let obj = spec.as_object_mut().unwrap(); + obj.insert("SECONDS_PER_SLOT".to_owned(), json!("12")); + obj.insert("SLOTS_PER_EPOCH".to_owned(), json!("32")); + spec + } + + async fn submit_mock() -> BeaconMock { + BeaconMock::builder() + .spec(submit_spec_fixture()) + .genesis_time(DateTime::from_timestamp(0, 0).unwrap()) + .genesis_validators_root([0; 32]) + .build() + .await + .unwrap() + } + + fn make_signed_exit(epoch: Epoch, validator_index: u64, sig: [u8; 96]) -> SignedVoluntaryExit { + SignedVoluntaryExit(pluto_eth2api::spec::phase0::SignedVoluntaryExit { + message: pluto_eth2api::spec::phase0::VoluntaryExit { + epoch, + validator_index, + }, + signature: sig, + }) + } + + fn make_signed_registration( + pubkey: BLSPubKey, + timestamp: u64, + sig: [u8; 96], + ) -> SignedValidatorRegistration { + SignedValidatorRegistration(VersionedRegPayload { + version: BuilderVersion::V1, + v1: Some(V1SignedRegistration { + message: ValidatorRegistration { + fee_recipient: [0x11; 20], + gas_limit: 30_000_000, + timestamp, + pubkey, + }, + signature: sig, + }), + }) + } + + /// Captures every `(duty, set)` tuple a subscriber receives. Mirrors the + /// pattern used by the `subscribe_fanouts_clones_to_every_subscriber` + /// test above. + type CapturedFanouts = Arc>>; + + fn install_capture(component: &mut Component) -> CapturedFanouts { + let captured: CapturedFanouts = Arc::new(Mutex::new(Vec::new())); + let captured_clone = Arc::clone(&captured); + component.subscribe(move |duty, set| { + let captured_clone = Arc::clone(&captured_clone); + async move { + captured_clone.lock().unwrap().push((duty, set)); + Ok(()) + } + }); + captured + } + + /// `submit_voluntary_exit` resolves the validator-index through the + /// per-epoch validator cache, builds a voluntary-exit duty, and fans out + /// to every subscriber. Insecure-test mode bypasses BLS verification so + /// the test can use a placeholder signature. + #[tokio::test] + async fn submit_voluntary_exit_resolves_validator_and_fanouts() { + const EPOCH: u64 = 7; + const VAL_IDX: u64 = 42; + const SLOTS_PER_EPOCH: u64 = 32; + + let dv_root = dv_pubkey(0xAA); + let share = dv_pubkey(0xBB); + let map = HashMap::from([(dv_root, share)]); + let active = HashMap::from([(VAL_IDX, dv_root)]); + + let (mut component, _mock) = + make_submit_component_insecure(false, map, TestValidatorCache::arc(active)).await; + + let captured = install_capture(&mut component); + + let exit = make_signed_exit(EPOCH, VAL_IDX, [0x99; 96]); + component.submit_voluntary_exit(exit).await.unwrap(); + + let fanouts = captured.lock().unwrap(); + assert_eq!(fanouts.len(), 1, "exactly one subscriber invocation"); + let (duty, set) = &fanouts[0]; + + // Duty: voluntary-exit duty keyed at slots_per_epoch * exit_epoch. + assert_eq!(duty.duty_type, DutyType::Exit); + assert_eq!(duty.slot.inner(), SLOTS_PER_EPOCH.saturating_mul(EPOCH)); + + // ParSignedDataSet: indexed by the core PubKey of the DV root. + assert_eq!(set.inner().len(), 1); + let par = set.inner().get(&core_pubkey_from(dv_root)).unwrap(); + assert_eq!(par.share_idx, 1); + } + + /// `submit_voluntary_exit` rejects with a 400 when the validator index is + /// not present in the active set (Go: `errors.New("validator not + /// found")`). + #[tokio::test] + async fn submit_voluntary_exit_rejects_unknown_validator() { + let (component, _mock) = + make_submit_component_insecure(false, HashMap::new(), TestValidatorCache::empty()) + .await; + + let exit = make_signed_exit(0, 9, [0u8; 96]); + let err = component.submit_voluntary_exit(exit).await.unwrap_err(); + assert_eq!(err.status_code, StatusCode::BAD_REQUEST); + assert_eq!(err.message, "validator not found"); + } + + /// `submit_voluntary_exit` rejects an exit whose BLS signature does not + /// verify against the registered public share. Uses a real beacon-mock + /// upstream + real BLS so the verification path actually runs. + #[tokio::test] + async fn submit_voluntary_exit_rejects_bad_signature() { + const VAL_IDX: u64 = 5; + const EPOCH: u64 = 3; + + let secret = BlstImpl + .generate_insecure_secret(rand::rngs::OsRng) + .unwrap(); + let pubshare = BlstImpl.secret_to_public_key(&secret).unwrap(); + let dv_root = dv_pubkey(0xCC); + let map = HashMap::from([(dv_root, pubshare)]); + let active = HashMap::from([(VAL_IDX, dv_root)]); + + let mock = submit_mock().await; + let cancel = CancellationToken::new(); + let (deadliner, _deadliner_rx) = DeadlinerTask::start( + cancel.clone(), + "validatorapi-submit-bad-sig", + FarFutureCalculator, + ); + let (_evict_tx, evict_rx) = mpsc::channel(1); + let dutydb = Arc::new(MemDB::new(deadliner, evict_rx, &cancel)); + let eth2_cl = Arc::new(EthBeaconNodeApiClient::with_base_url(mock.uri()).unwrap()); + let component = Component::new( + eth2_cl, + dutydb, + 1, + map, + false, + TestValidatorCache::arc(active), + ); + + let exit = make_signed_exit(EPOCH, VAL_IDX, [0x42; 96]); + let err = component.submit_voluntary_exit(exit).await.unwrap_err(); + assert_eq!(err.status_code, StatusCode::BAD_REQUEST); + } + + /// `submit_validator_registrations` returns Ok without fanout when + /// builder mode is disabled. Mirrors Go's + /// `validatorapi.go:737-739` swallow-on-disable branch. + #[tokio::test] + async fn submit_validator_registrations_swallows_when_builder_disabled() { + let dv_root = dv_pubkey(0xDD); + let share = dv_pubkey(0xEE); + let map = HashMap::from([(dv_root, share)]); + + let (mut component, _mock) = + make_submit_component_insecure(false, map, TestValidatorCache::empty()).await; + let captured = install_capture(&mut component); + + let reg = make_signed_registration(dv_root, 1_000_000, [0x00; 96]); + component + .submit_validator_registrations(vec![reg]) + .await + .unwrap(); + + assert!( + captured.lock().unwrap().is_empty(), + "no fanout when builder mode disabled" + ); + } + + /// `submit_validator_registrations` returns Ok with no fanout on an + /// empty input list — even with builder mode enabled. Mirrors Go's + /// `validatorapi.go:732-734` early return. + #[tokio::test] + async fn submit_validator_registrations_no_op_on_empty_input() { + let (mut component, _mock) = + make_submit_component_insecure(true, HashMap::new(), TestValidatorCache::empty()).await; + let captured = install_capture(&mut component); + + component + .submit_validator_registrations(Vec::new()) + .await + .unwrap(); + + assert!(captured.lock().unwrap().is_empty()); + } + + /// `submit_validator_registrations` silently skips entries whose pubkey + /// is not a DV root key on this node — same as Go's per-pubkey + /// `swallowRegFilter` branch (`validatorapi.go:686-691`). + #[tokio::test] + async fn submit_validator_registrations_swallows_non_dv_pubkey() { + let dv_root = dv_pubkey(0x55); + let share = dv_pubkey(0x66); + let map = HashMap::from([(dv_root, share)]); + + let (mut component, _mock) = + make_submit_component_insecure(true, map, TestValidatorCache::empty()).await; + let captured = install_capture(&mut component); + + // Registration for a pubkey not registered on this node. + let reg = make_signed_registration(dv_pubkey(0xFF), 1_000_000, [0x00; 96]); + component + .submit_validator_registrations(vec![reg]) + .await + .unwrap(); + + assert!( + captured.lock().unwrap().is_empty(), + "non-DV registration is swallowed without fanout" + ); + } + + /// `submit_validator_registrations` happy path: a DV registration is + /// verified (skipped in insecure-test mode) and fanned out to every + /// subscriber with a `BuilderRegistration` duty. + #[tokio::test] + async fn submit_validator_registrations_happy_path_fanouts() { + let dv_root = dv_pubkey(0x77); + let share = dv_pubkey(0x88); + let map = HashMap::from([(dv_root, share)]); + + let (mut component, _mock) = + make_submit_component_insecure(true, map, TestValidatorCache::empty()).await; + let captured = install_capture(&mut component); + + // timestamp = genesis + 24s => slot = 2 (with 12s slot duration). + let reg = make_signed_registration(dv_root, 24, [0x00; 96]); + component + .submit_validator_registrations(vec![reg]) + .await + .unwrap(); + + let fanouts = captured.lock().unwrap(); + assert_eq!(fanouts.len(), 1); + let (duty, set) = &fanouts[0]; + assert_eq!(duty.duty_type, DutyType::BuilderRegistration); + assert_eq!(duty.slot.inner(), 2); + + assert_eq!(set.inner().len(), 1); + let par = set.inner().get(&core_pubkey_from(dv_root)).unwrap(); + assert_eq!(par.share_idx, 1); + } + + /// `submit_validator_registrations` rejects an entry whose BLS signature + /// does not verify against the registered public share. Uses a real + /// upstream + real BLS to drive the verification path. + #[tokio::test] + async fn submit_validator_registrations_rejects_bad_signature() { + let secret = BlstImpl + .generate_insecure_secret(rand::rngs::OsRng) + .unwrap(); + let pubshare = BlstImpl.secret_to_public_key(&secret).unwrap(); + let dv_root = dv_pubkey(0xA5); + let map = HashMap::from([(dv_root, pubshare)]); + + let mock = submit_mock().await; + let cancel = CancellationToken::new(); + let (deadliner, _deadliner_rx) = DeadlinerTask::start( + cancel.clone(), + "validatorapi-submit-reg-bad-sig", + FarFutureCalculator, + ); + let (_evict_tx, evict_rx) = mpsc::channel(1); + let dutydb = Arc::new(MemDB::new(deadliner, evict_rx, &cancel)); + let eth2_cl = Arc::new(EthBeaconNodeApiClient::with_base_url(mock.uri()).unwrap()); + let component = Component::new(eth2_cl, dutydb, 1, map, true, TestValidatorCache::empty()); + + let reg = make_signed_registration(dv_root, 24, [0x42; 96]); + let err = component + .submit_validator_registrations(vec![reg]) + .await + .unwrap_err(); + assert_eq!(err.status_code, StatusCode::BAD_REQUEST); + } + + /// Build a core [`PubKey`] from a 48-byte BLS pubkey (`BLSPubKey`). + fn core_pubkey_from(bls: BLSPubKey) -> PubKey { + PubKey::new(bls) + } } diff --git a/crates/core/src/validatorapi/types.rs b/crates/core/src/validatorapi/types.rs index 8e18456a..ca057c90 100644 --- a/crates/core/src/validatorapi/types.rs +++ b/crates/core/src/validatorapi/types.rs @@ -22,6 +22,7 @@ pub use pluto_eth2api::{ GetVersionResponseResponse as NodeVersionResponse, GetVersionResponseResponseData as NodeVersionData, spec::phase0::{self, Epoch, Root, Slot, ValidatorIndex}, + versioned, }; /// Attestation data alias for the consensus-spec phase0 type. @@ -163,13 +164,31 @@ pub struct VersionedAttestation {} #[derive(Debug, Clone)] pub struct VersionedSignedAggregateAndProof {} -/// Signed validator registration payload. Placeholder. -#[derive(Debug, Clone)] -pub struct SignedValidatorRegistration {} - -/// Signed voluntary exit payload. Placeholder. -#[derive(Debug, Clone)] -pub struct SignedVoluntaryExit {} +/// Signed validator (builder) registration payload. +/// +/// Wraps the versioned eth2api registration so the +/// [`Handler::submit_validator_registrations`](super::handler::Handler::submit_validator_registrations) +/// implementation has access to the same data the Go +/// `*eth2api.VersionedSignedValidatorRegistration` carries. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(transparent)] +pub struct SignedValidatorRegistration( + /// Wrapped versioned registration. + pub versioned::VersionedSignedValidatorRegistration, +); + +/// Signed voluntary exit payload. +/// +/// Wraps `phase0::SignedVoluntaryExit` so the +/// [`Handler::submit_voluntary_exit`](super::handler::Handler::submit_voluntary_exit) +/// implementation has access to the same data the Go +/// `*eth2p0.SignedVoluntaryExit` carries. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(transparent)] +pub struct SignedVoluntaryExit( + /// Wrapped phase0 signed voluntary exit. + pub phase0::SignedVoluntaryExit, +); /// Sync-committee message payload. Placeholder. #[derive(Debug, Clone)] diff --git a/crates/eth2util/src/signing.rs b/crates/eth2util/src/signing.rs index de51b890..006aa553 100644 --- a/crates/eth2util/src/signing.rs +++ b/crates/eth2util/src/signing.rs @@ -150,6 +150,27 @@ pub async fn verify( Ok(()) } +/// Verifies a signature using a pre-resolved [`Domain`], so callers that +/// repeat signature checks under the same domain (e.g. a batch of builder +/// registrations) can hoist the upstream `/eth/v1/config/spec` and +/// `/eth/v1/beacon/genesis` calls out of their loop. +pub fn verify_with_domain( + domain: Domain, + message_root: Root, + signature: &Signature, + pubkey: &PublicKey, +) -> Result<()> { + if *signature == [0; 96] { + return Err(SigningError::ZeroSignature); + } + + let signing_root = compute_signing_root(message_root, domain); + + BlstImpl.verify(pubkey, &signing_root, signature)?; + + Ok(()) +} + /// Verifies the selection proof embedded in an aggregate-and-proof payload. pub async fn verify_aggregate_and_proof_selection( client: &EthBeaconNodeApiClient, @@ -384,6 +405,37 @@ mod tests { assert!(matches!(err, SigningError::ZeroSignature)); } + #[tokio::test] + async fn verify_with_domain_accepts_valid_signature() { + let mock = mock_beacon_client().await; + let client = mock.client(); + + let secret = secret_key("345768c0245f1dc702df9e50e811002f61ebb2680b3d5931527ef59f96cbaf9b"); + let pubkey = BlstImpl.secret_to_public_key(&secret).unwrap(); + let message_root = [0x55; 32]; + let domain = get_domain(client, DomainName::ApplicationBuilder, 0) + .await + .unwrap(); + let signing_root = compute_signing_root(message_root, domain); + let signature = BlstImpl.sign(&secret, &signing_root).unwrap(); + + verify_with_domain(domain, message_root, &signature, &pubkey).unwrap(); + } + + #[tokio::test] + async fn verify_with_domain_rejects_zero_signature() { + let mock = mock_beacon_client().await; + let client = mock.client(); + let domain = get_domain(client, DomainName::ApplicationBuilder, 0) + .await + .unwrap(); + let pubkey = [0x11; 48]; + + let err = verify_with_domain(domain, [0x22; 32], &[0; 96], &pubkey).unwrap_err(); + + assert!(matches!(err, SigningError::ZeroSignature)); + } + #[tokio::test] async fn verify_rejects_wrong_pubkey() { let mock = mock_beacon_client().await; From f9c968c5b5ca7891ae2712ac2e563a4e88538475 Mon Sep 17 00:00:00 2001 From: Bohdan Ohorodnii <273991985+varex83agent@users.noreply.github.com> Date: Thu, 11 Jun 2026 20:07:29 +0200 Subject: [PATCH 3/4] feat(core): wire eth2api CachedValidatorsProvider into validatorapi Make eth2api's existing CachedValidatorsProvider trait async (#[async_trait]) and implement it for ValidatorCache, add ActiveValidators::new so consumers can build instances, and add the async-trait dependency. Wire a validator_cache: Arc into the validatorapi Component (constructors + fetch_active_validators), reusing the shared trait rather than a validatorapi-local duplicate. fetch_active_validators mirrors Go's eth2Cl.ActiveValidators. Add a TestValidatorCache test double and update existing validatorapi tests for the new constructor arity. This is the base the validatorapi submit-handler PRs (selections, exit/ registrations, sync-submit) stack on. Co-Authored-By: Bohdan Ohorodnii <35969035+varex83@users.noreply.github.com> --- Cargo.lock | 1 + crates/core/src/validatorapi/component.rs | 87 +++++++++++++++++++++-- crates/eth2api/Cargo.toml | 1 + crates/eth2api/src/valcache.rs | 31 +++++++- 4 files changed, 110 insertions(+), 10 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index be2ddc88..abd7cd39 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5707,6 +5707,7 @@ version = "1.7.1" dependencies = [ "alloy", "anyhow", + "async-trait", "bon", "chrono", "ethereum_ssz", diff --git a/crates/core/src/validatorapi/component.rs b/crates/core/src/validatorapi/component.rs index f9f7729e..60a3c4e3 100644 --- a/crates/core/src/validatorapi/component.rs +++ b/crates/core/src/validatorapi/component.rs @@ -14,6 +14,7 @@ use pluto_eth2api::{ GetProposerDutiesRequest, GetProposerDutiesResponse, GetSyncCommitteeDutiesRequest, GetSyncCommitteeDutiesResponse, spec::phase0::{BLSPubKey, Epoch, Root, ValidatorIndex}, + valcache::{ActiveValidators, CachedValidatorsProvider}, versioned::{DataVersion, SignedBlindedProposalBlock, SignedProposalBlock}, }; use pluto_eth2util::signing::{self, DomainName, SigningError}; @@ -140,6 +141,12 @@ const PROPOSAL_TIMEOUT: Duration = Duration::from_secs(24); pub struct Component { /// Upstream beacon-node API client. eth2_cl: Arc, + /// Per-epoch active-validators cache. Submit handlers consult this to + /// translate a validator-client-supplied `validator_index` into the + /// cluster's DV root public key. Mirrors Go's `eth2Cl.ActiveValidators`, + /// which is itself backed by the beacon-node validator cache. + #[allow(dead_code, reason = "consumed by submit_* handlers in later PRs")] + validator_cache: Arc, /// In-memory DutyDB used to await consensus output (e.g. attestation /// data) produced by the rest of the pipeline. dutydb: Arc, @@ -196,6 +203,7 @@ impl Component { share_idx: u64, pub_share_by_pubkey: HashMap, builder_enabled: bool, + validator_cache: Arc, ) -> Self { Self { eth2_cl, @@ -203,6 +211,7 @@ impl Component { share_idx, pub_share_by_pubkey, builder_enabled, + validator_cache, insecure_test: false, subs: Vec::new(), await_proposal_fn: None, @@ -223,6 +232,7 @@ impl Component { eth2_cl: Arc, dutydb: Arc, share_idx: u64, + validator_cache: Arc, ) -> Self { Self { eth2_cl, @@ -230,6 +240,7 @@ impl Component { share_idx, pub_share_by_pubkey: HashMap::new(), builder_enabled: false, + validator_cache, insecure_test: true, subs: Vec::new(), await_proposal_fn: None, @@ -241,6 +252,25 @@ impl Component { } } + /// Returns the cluster's active validators (`validator_index -> DV root + /// public key`) from the registered [`CachedValidatorsProvider`], + /// bounded by [`UPSTREAM_REQUEST_TIMEOUT`]. Mirrors Go's + /// `c.eth2Cl.ActiveValidators(ctx)`, which is itself implemented via the + /// beacon-node validator cache. + #[allow(dead_code, reason = "consumed by submit_* handlers in later PRs")] + async fn fetch_active_validators(&self) -> Result { + tokio::time::timeout( + UPSTREAM_REQUEST_TIMEOUT, + self.validator_cache.active_validators(), + ) + .await + .map_err(|_: Elapsed| upstream_timeout("active validators"))? + .map_err(|err| { + ApiError::new(StatusCode::BAD_GATEWAY, "active validators lookup failed") + .with_source(err) + }) + } + /// Appends a subscriber that is invoked by submit endpoints once a /// partial-signed-data set has been validated. The registered closure /// receives its own clone of the set, so subscribers can mutate without @@ -1321,6 +1351,39 @@ mod tests { types::{Duty, DutyDefinition, DutyType, PubKey, SlotNumber}, validatorapi::types::AttestationDataOpts, }; + use pluto_eth2api::valcache::{CompleteValidators, ValidatorCacheError}; + + /// In-memory [`CachedValidatorsProvider`] for tests. Holds a fixed + /// `validator_index -> DV root pubkey` map. `complete_validators` is not + /// consumed by the validator API, so it returns an empty set. + #[derive(Default)] + pub(super) struct TestValidatorCache(HashMap); + + impl TestValidatorCache { + /// An empty cache as an `Arc`. + pub(super) fn empty() -> Arc { + Arc::new(Self::default()) + } + + /// A cache pre-populated with `validators`. + #[allow(dead_code, reason = "consumed by submit_* handler tests in later PRs")] + pub(super) fn arc( + validators: HashMap, + ) -> Arc { + Arc::new(Self(validators)) + } + } + + #[async_trait] + impl CachedValidatorsProvider for TestValidatorCache { + async fn active_validators(&self) -> Result { + Ok(ActiveValidators::new(self.0.clone())) + } + + async fn complete_validators(&self) -> Result { + Ok(CompleteValidators::default()) + } + } /// Schedules every duty with a deadline at `MAX_UTC`, so duties are /// `Scheduled` but never naturally expire. @@ -1345,7 +1408,8 @@ mod tests { let dutydb = Arc::new(MemDB::new(deadliner, evict_rx, &cancel)); let eth2_cl = Arc::new(EthBeaconNodeApiClient::with_base_url("http://127.0.0.1:0").unwrap()); - let component = Component::new_insecure(eth2_cl, Arc::clone(&dutydb), 1); + let component = + Component::new_insecure(eth2_cl, Arc::clone(&dutydb), 1, TestValidatorCache::empty()); (component, dutydb) } @@ -1587,7 +1651,8 @@ mod tests { let dutydb = Arc::new(MemDB::new(deadliner, trim_rx, &cancel)); let eth2_cl = Arc::new(EthBeaconNodeApiClient::with_base_url("http://127.0.0.1:0").unwrap()); - let component = Component::new_insecure(eth2_cl, Arc::clone(&dutydb), 1); + let component = + Component::new_insecure(eth2_cl, Arc::clone(&dutydb), 1, TestValidatorCache::empty()); // Start an await before any data is stored. let waiter = { @@ -1773,7 +1838,7 @@ mod tests { let dutydb = Arc::new(MemDB::new(deadliner, evict_rx, &cancel)); let eth2_cl = Arc::new(EthBeaconNodeApiClient::with_base_url("http://127.0.0.1:0").unwrap()); - Component::new(eth2_cl, dutydb, 1, map, false) + Component::new(eth2_cl, dutydb, 1, map, false, TestValidatorCache::empty()) } /// `Subscribe` invokes every registered subscriber, each receiving its @@ -2004,7 +2069,7 @@ mod tests { let (_evict_tx, evict_rx) = mpsc::channel(1); let dutydb = Arc::new(MemDB::new(deadliner, evict_rx, &cancel)); let eth2_cl = Arc::new(EthBeaconNodeApiClient::with_base_url(mock.uri()).unwrap()); - let component = Component::new(eth2_cl, dutydb, 1, map, false); + let component = Component::new(eth2_cl, dutydb, 1, map, false, TestValidatorCache::empty()); (component, mock) } @@ -2087,7 +2152,7 @@ mod tests { let dutydb = Arc::new(MemDB::new(deadliner, evict_rx, &cancel)); let eth2_cl = Arc::new(EthBeaconNodeApiClient::with_base_url("http://127.0.0.1:0").unwrap()); - let component = Component::new_insecure(eth2_cl, dutydb, 1); + let component = Component::new_insecure(eth2_cl, dutydb, 1, TestValidatorCache::empty()); component .verify_partial_sig( @@ -2163,7 +2228,8 @@ mod tests { let (_evict_tx, evict_rx) = mpsc::channel(1); let dutydb = Arc::new(MemDB::new(deadliner, evict_rx, &cancel)); let eth2_cl = Arc::new(EthBeaconNodeApiClient::with_base_url(mock.uri()).unwrap()); - let component = Component::new_insecure(eth2_cl, Arc::clone(&dutydb), 1); + let component = + Component::new_insecure(eth2_cl, Arc::clone(&dutydb), 1, TestValidatorCache::empty()); (component, mock) } @@ -2812,7 +2878,14 @@ mod tests { let (_evict_tx, evict_rx) = mpsc::channel(1); let dutydb = Arc::new(MemDB::new(deadliner, evict_rx, &cancel)); let eth2_cl = Arc::new(EthBeaconNodeApiClient::with_base_url(mock.uri()).unwrap()); - let mut component = Component::new(eth2_cl, Arc::clone(&dutydb), 1, HashMap::new(), false); + let mut component = Component::new( + eth2_cl, + Arc::clone(&dutydb), + 1, + HashMap::new(), + false, + TestValidatorCache::empty(), + ); let (consensus, signed) = matched_phase0_proposals(33, 5); diff --git a/crates/eth2api/Cargo.toml b/crates/eth2api/Cargo.toml index 99b0a3c9..fbc5c298 100644 --- a/crates/eth2api/Cargo.toml +++ b/crates/eth2api/Cargo.toml @@ -13,6 +13,7 @@ ignored = ["bon", "http", "oas3-gen-support", "regex", "reqwest", "validator"] [dependencies] anyhow.workspace = true +async-trait.workspace = true bon.workspace = true http.workspace = true oas3-gen-support.workspace = true diff --git a/crates/eth2api/src/valcache.rs b/crates/eth2api/src/valcache.rs index ea52ef6d..6e086680 100644 --- a/crates/eth2api/src/valcache.rs +++ b/crates/eth2api/src/valcache.rs @@ -4,6 +4,7 @@ use crate::{ PostStateValidatorsRequestPath, PostStateValidatorsResponse, ValidatorRequestBody, spec::phase0::{BLSPubKey as PubKey, ValidatorIndex}, }; +use async_trait::async_trait; use std::{collections::HashMap, sync::Arc}; use tokio::sync::RwLock; @@ -42,6 +43,13 @@ impl std::ops::Deref for CompleteValidators { } impl ActiveValidators { + /// Builds an [`ActiveValidators`] from a `validator_index -> pubkey` map. + /// Lets consumers outside this crate (e.g. test doubles of + /// [`CachedValidatorsProvider`]) construct populated instances. + pub fn new(validators: HashMap) -> Self { + Self(validators) + } + /// An [`Iterator`] of active validator indices. pub fn indices(&self) -> impl Iterator + '_ { self.0.keys().copied() @@ -55,12 +63,29 @@ impl ActiveValidators { /// A provider of cached validator information for the current epoch, /// including both active validators and complete validator data. -pub trait CachedValidatorsProvider { +/// +/// Async so implementations may populate the underlying cache on demand — +/// callers must not assume the call is non-blocking. Consumed via +/// `Arc` (e.g. by the validator API), so the +/// trait is object-safe and `Send + Sync`. +#[async_trait] +pub trait CachedValidatorsProvider: Send + Sync { /// Get the cached active validators. - fn active_validators(&self) -> Result; + async fn active_validators(&self) -> Result; /// Get all the cached validators. - fn complete_validators(&self) -> Result; + async fn complete_validators(&self) -> Result; +} + +#[async_trait] +impl CachedValidatorsProvider for ValidatorCache { + async fn active_validators(&self) -> Result { + Ok(self.get_by_head().await?.0) + } + + async fn complete_validators(&self) -> Result { + Ok(self.get_by_head().await?.1) + } } /// A cache for active validators. From 1088e2f14fc523b85a5027700319815fe44f3500 Mon Sep 17 00:00:00 2001 From: Bohdan Ohorodnii <273991985+varex83agent@users.noreply.github.com> Date: Fri, 12 Jun 2026 17:32:15 +0200 Subject: [PATCH 4/4] docs(core): drop Go/Charon cross-reference comments from exit/registration handlers Remove "port of"/"mirrors Go's"/"Go: validatorapi.go:NNN" annotations and similar AI-flavored narration from the validatorapi voluntary_exit and validator_registrations code and tests, keeping the behavioural descriptions. Co-authored-by: varex83 --- crates/core/src/validatorapi/component.rs | 107 +++++++++------------- crates/core/src/validatorapi/types.rs | 3 +- 2 files changed, 42 insertions(+), 68 deletions(-) diff --git a/crates/core/src/validatorapi/component.rs b/crates/core/src/validatorapi/component.rs index af3e4716..eed8443a 100644 --- a/crates/core/src/validatorapi/component.rs +++ b/crates/core/src/validatorapi/component.rs @@ -147,8 +147,7 @@ pub struct Component { eth2_cl: Arc, /// Per-epoch active-validators cache. Submit handlers consult this to /// translate a validator-client-supplied `validator_index` into the - /// cluster's DV root public key. Mirrors Go's `eth2Cl.ActiveValidators`, - /// which is itself backed by the beacon-node validator cache. + /// cluster's DV root public key. Backed by the beacon-node validator cache. #[allow(dead_code, reason = "consumed by submit_* handlers in later PRs")] validator_cache: Arc, /// In-memory DutyDB used to await consensus output (e.g. attestation @@ -252,9 +251,7 @@ impl Component { /// Returns the cluster's active validators (`validator_index -> DV root /// public key`) from the registered [`CachedValidatorsProvider`], - /// bounded by [`UPSTREAM_REQUEST_TIMEOUT`]. Mirrors Go's - /// `c.eth2Cl.ActiveValidators(ctx)`, which is itself implemented via the - /// beacon-node validator cache. + /// bounded by [`UPSTREAM_REQUEST_TIMEOUT`]. #[allow(dead_code, reason = "consumed by submit_* handlers in later PRs")] async fn fetch_active_validators(&self) -> Result { tokio::time::timeout( @@ -456,14 +453,11 @@ impl Component { } /// Verifies and fans out a single builder-registration. Factored out so - /// [`Self::submit_validator_registrations`] iterates over its input in - /// the same shape as Go's `SubmitValidatorRegistrations`. The - /// `slot_duration`, `genesis_time`, and `builder_domain` arguments are + /// [`Self::submit_validator_registrations`] can iterate over its input. + /// The `slot_duration`, `genesis_time`, and `builder_domain` arguments are /// hoisted out of the loop so a batched request issues at most one /// `fetch_slots_config`, one `fetch_genesis_time`, and one builder-domain - /// resolution upstream call, regardless of input size — Charon achieves - /// the same effect via `eth2wrap` caching, which the Pluto eth2 client - /// does not yet provide. + /// resolution upstream call, regardless of input size. async fn submit_one_registration( &self, registration: SignedValidatorRegistration, @@ -471,11 +465,10 @@ impl Component { genesis_time: chrono::DateTime, builder_domain: Domain, ) -> Result<(), ApiError> { - // Go: validatorapi.go:676-690 — pull the group pubkey out of the - // wrapped registration and gate on it being a DV pubkey on this - // node. Non-DV pubkeys are silently swallowed (matches Go's - // `swallowRegFilter` debug-log behaviour) so a vouch-style VC that - // also registers its proposer key does not get a non-200 from us. + // Pull the group pubkey out of the wrapped registration and gate on it + // being a DV pubkey on this node. Non-DV pubkeys are silently swallowed + // so a vouch-style VC that also registers its proposer key does not get + // a non-200 from us. let v1 = registration.0.v1.as_ref().ok_or_else(|| { ApiError::new( StatusCode::BAD_REQUEST, @@ -494,13 +487,11 @@ impl Component { let timestamp = v1.message.timestamp; - // Go: validatorapi.go:693-703 — derive the slot the registration - // belongs to. + // Derive the slot the registration belongs to. let registration_slot = slot_from_timestamp(genesis_time, slot_duration, timestamp); let duty = Duty::new_builder_registration_duty(SlotNumber::new(registration_slot)); - // Go: validatorapi.go:706 — wrap as ParSignedData via the canonical - // partial-sig constructor. + // Wrap as ParSignedData via the canonical partial-sig constructor. let par_signed = VersionedSignedValidatorRegistrationWrapper::new_partial( registration.0.clone(), self.share_idx, @@ -513,9 +504,8 @@ impl Component { .with_source(err) })?; - // Go: validatorapi.go:712 — partial-signature verification. The - // application-builder domain ignores the epoch (Go's - // `Epoch()` returns 0); we mirror that here. Uses the hoisted + // Partial-signature verification. The application-builder domain + // ignores the epoch (the epoch is always 0). Uses the hoisted // `builder_domain` so a batched submission resolves the signing // domain once instead of N times. let message_root = v1.message.message_root(); @@ -997,24 +987,21 @@ impl Handler for Component { /// Fan-out is per-entry and **not transactional**: registrations are /// processed sequentially and the loop returns on the first error. /// Earlier entries that already fanned out remain published downstream - /// when a later entry fails, matching Go's `SubmitValidatorRegistrations` - /// (validatorapi.go:731-749). + /// when a later entry fails. #[instrument(skip_all)] async fn submit_validator_registrations( &self, registrations: Vec, ) -> Result<(), ApiError> { - // Go: validatorapi.go:732-734 — empty input is a no-op. + // Empty input is a no-op. if registrations.is_empty() { return Ok(()); } - // Go: validatorapi.go:736-739 — builder-mode gate. When builder mode - // is disabled the registrations are accepted (no client-visible - // error) but never fanned out. Mirrors the swallow-on-disable - // behaviour Go inherited from Vouch. Logged at `debug!` to match - // Charon's `log.Debug` — VCs like Vouch send registrations every - // slot, so a higher level would be noisy in non-builder configs. + // Builder-mode gate. When builder mode is disabled the registrations + // are accepted (no client-visible error) but never fanned out. Logged + // at `debug!` because VCs like Vouch send registrations every slot, so + // a higher level would be noisy in non-builder configs. if !self.builder_enabled { tracing::debug!( count = registrations.len(), @@ -1024,8 +1011,7 @@ impl Handler for Component { } // Hoisted out of the per-registration loop so a batched submission - // issues at most one upstream call per kind (Pluto's eth2 client is - // not cached the way Charon's `eth2wrap` is). All entries share the + // issues at most one upstream call per kind. All entries share the // same `DomainName::ApplicationBuilder` signing domain at epoch 0, // so we resolve it once here too rather than letting // `verify_partial_sig` fan out 2N domain-lookup calls. @@ -1057,22 +1043,18 @@ impl Handler for Component { #[instrument(skip_all)] async fn submit_voluntary_exit(&self, exit: SignedVoluntaryExit) -> Result<(), ApiError> { - // Go: validatorapi.go:753-761 — resolve the DV root pubkey for the - // validator index carried by the exit. The Pluto-side lookup runs - // through the per-epoch validator cache (mirrors the Go - // `eth2Cl.ActiveValidators` indirection, which is itself backed by - // `app/eth2wrap`'s cache). + // Resolve the DV root pubkey for the validator index carried by the + // exit. The lookup runs through the per-epoch validator cache. let active = self.fetch_active_validators().await?; let validator_index = exit.0.message.validator_index; let root_pubkey = active.get(&validator_index).copied().ok_or_else(|| { - // Go: `errors.New("validator not found")` — bubble up as 400 so a - // misbehaving VC sees a non-retriable rejection without leaking - // upstream details. + // Bubble up as 400 so a misbehaving VC sees a non-retriable + // rejection without leaking upstream details. ApiError::new(StatusCode::BAD_REQUEST, "validator not found") })?; - // Go: validatorapi.go:768-773 — duty slot = slots_per_epoch * epoch. + // Duty slot = slots_per_epoch * epoch. let (_, slots_per_epoch) = tokio::time::timeout(UPSTREAM_REQUEST_TIMEOUT, self.eth2_cl.fetch_slots_config()) .await @@ -1083,11 +1065,11 @@ impl Handler for Component { let duty_slot = slots_per_epoch.saturating_mul(exit_epoch); let duty = Duty::new_voluntary_exit_duty(SlotNumber::new(duty_slot)); - // Go: validatorapi.go:776 — build the ParSignedData via the canonical - // partial-sig constructor for voluntary exits. + // Build the ParSignedData via the canonical partial-sig constructor + // for voluntary exits. let par_signed = SignedVoluntaryExitWrapper::new_partial(exit.0.clone(), self.share_idx); - // Go: validatorapi.go:779 — partial-signature verification. + // Partial-signature verification. let message_root = exit.0.message_root(); self.verify_partial_sig( &root_pubkey, @@ -1343,11 +1325,10 @@ fn subscriber_error_to_api_error(err: CallbackError) -> ApiError { .with_boxed_source(err) } -/// Computes the slot a timestamp belongs to, mirroring Go's -/// `SlotFromTimestamp` at `validatorapi.go:41-70`. When the timestamp is -/// before genesis (testing scenarios), Go falls back to "now"; here we fall -/// back to slot 0 to keep the helper pure — the only consumer is the -/// `Duty` key, where any deterministic placeholder is acceptable. +/// Computes the slot a timestamp belongs to. When the timestamp is before +/// genesis (testing scenarios), falls back to slot 0 to keep the helper pure — +/// the only consumer is the `Duty` key, where any deterministic placeholder is +/// acceptable. fn slot_from_timestamp( genesis_time: chrono::DateTime, slot_duration: std::time::Duration, @@ -2276,8 +2257,8 @@ mod tests { assert!(component.subs.is_empty()); } - /// Mirrors signing-fixture spec from `pluto_eth2util::signing` tests so - /// `verify_partial_sig` can resolve a real beacon-attester domain. + /// Uses the same signing-fixture spec as the `pluto_eth2util::signing` + /// tests so `verify_partial_sig` can resolve a real beacon-attester domain. fn signing_spec_fixture() -> serde_json::Value { json!({ "DOMAIN_BEACON_PROPOSER": "0x00000000", @@ -2425,8 +2406,7 @@ mod tests { // ==================================================================== /// `fetch_active_validators` returns whatever the registered - /// `CachedValidatorsProvider` yields, untouched. Mirrors Go's - /// `c.eth2Cl.ActiveValidators(ctx)` return shape. + /// `CachedValidatorsProvider` yields, untouched. #[tokio::test] async fn fetch_active_validators_returns_cache_contents() { let cancel = CancellationToken::new(); @@ -2584,9 +2564,8 @@ mod tests { }) } - /// Captures every `(duty, set)` tuple a subscriber receives. Mirrors the - /// pattern used by the `subscribe_fanouts_clones_to_every_subscriber` - /// test above. + /// Captures every `(duty, set)` tuple a subscriber receives. Same pattern + /// as the `subscribe_fanouts_clones_to_every_subscriber` test above. type CapturedFanouts = Arc>>; fn install_capture(component: &mut Component) -> CapturedFanouts { @@ -2640,8 +2619,7 @@ mod tests { } /// `submit_voluntary_exit` rejects with a 400 when the validator index is - /// not present in the active set (Go: `errors.New("validator not - /// found")`). + /// not present in the active set. #[tokio::test] async fn submit_voluntary_exit_rejects_unknown_validator() { let (component, _mock) = @@ -2695,8 +2673,7 @@ mod tests { } /// `submit_validator_registrations` returns Ok without fanout when - /// builder mode is disabled. Mirrors Go's - /// `validatorapi.go:737-739` swallow-on-disable branch. + /// builder mode is disabled. #[tokio::test] async fn submit_validator_registrations_swallows_when_builder_disabled() { let dv_root = dv_pubkey(0xDD); @@ -2720,8 +2697,7 @@ mod tests { } /// `submit_validator_registrations` returns Ok with no fanout on an - /// empty input list — even with builder mode enabled. Mirrors Go's - /// `validatorapi.go:732-734` early return. + /// empty input list — even with builder mode enabled. #[tokio::test] async fn submit_validator_registrations_no_op_on_empty_input() { let (mut component, _mock) = @@ -2737,8 +2713,7 @@ mod tests { } /// `submit_validator_registrations` silently skips entries whose pubkey - /// is not a DV root key on this node — same as Go's per-pubkey - /// `swallowRegFilter` branch (`validatorapi.go:686-691`). + /// is not a DV root key on this node. #[tokio::test] async fn submit_validator_registrations_swallows_non_dv_pubkey() { let dv_root = dv_pubkey(0x55); diff --git a/crates/core/src/validatorapi/types.rs b/crates/core/src/validatorapi/types.rs index 7c472fde..2a61ebe4 100644 --- a/crates/core/src/validatorapi/types.rs +++ b/crates/core/src/validatorapi/types.rs @@ -151,8 +151,7 @@ pub use crate::signeddata::VersionedProposal; pub use crate::signeddata::VersionedSignedProposal; /// Versioned signed blinded proposal payload — alias of the eth2api versioned -/// wrapper, the same shape consumed by Go's -/// `SubmitBlindedProposalOpts.Proposal`. +/// wrapper. pub use pluto_eth2api::versioned::VersionedSignedBlindedProposal; /// Versioned attestation payload. Placeholder.