Skip to content

feat(core): implement validatorapi beacon/sync committee selections handlers#459

Open
varex83agent wants to merge 11 commits into
bohdan/validatorapi-plumbingfrom
bohdan/validatorapi-selections
Open

feat(core): implement validatorapi beacon/sync committee selections handlers#459
varex83agent wants to merge 11 commits into
bohdan/validatorapi-plumbingfrom
bohdan/validatorapi-selections

Conversation

@varex83agent
Copy link
Copy Markdown
Collaborator

Summary

  • Ports BeaconCommitteeSelections (core/validatorapi/validatorapi.go:798-864) and SyncCommitteeSelections (validatorapi.go:1072-1138) into crates/core/src/validatorapi/component.rs, replacing both unimplemented!() arms.
  • Adds a register_active_validators hook on Component so the handlers can translate per-selection validator_index values to DV root pubkeys, mirroring Go's c.eth2Cl.ActiveValidators(ctx) lookup.
  • Replaces the BeaconCommitteeSelection {} / SyncCommitteeSelection {} placeholder structs in validatorapi/types.rs with type aliases for pluto_eth2api::v1::BeaconCommitteeSelection / v1::SyncCommitteeSelection, matching the established AttestationData = phase0::AttestationData pattern.
  • Bounds each await_agg_sig_db lookup with a new SELECTIONS_AGG_SIG_DB_TIMEOUT (~two slots) so a stalled AggSigDB cannot pin a selections request forever; bounds the active_validators hook with UPSTREAM_REQUEST_TIMEOUT.
  • No secrets/upstream-body leakage into client-visible messages: verification failures, unknown validator indices, subscriber failures, and AggSigDB lookup failures all use generic strings; underlying errors are attached on ApiError::source for debug logs only.

Go reference

Endpoint Go (Charon v1.7.1) Rust
beacon_committee_selections core/validatorapi/validatorapi.go:798-864 Component::beacon_committee_selections in validatorapi/component.rs
sync_committee_selections core/validatorapi/validatorapi.go:1072-1138 Component::sync_committee_selections in validatorapi/component.rs
ActiveValidators lookup c.eth2Cl.ActiveValidators(ctx) (called at the top of both handlers) new register_active_validators hook on Component returning HashMap<ValidatorIndex, BLSPubKey>
verifyPartialSig validatorapi.go:1352-1368 Component::verify_partial_sig (PR-1 plumbing)
NewPartialSignedBeaconCommitteeSelection core/signeddata.go:1241 signeddata::BeaconCommitteeSelection::new_partial
NewPartialSignedSyncCommitteeSelection core/signeddata.go:1303 signeddata::SyncCommitteeSelection::new_partial
NewPrepareAggregatorDuty(slot) duty fanout key Duty::new_prepare_aggregator_duty(SlotNumber::new(slot))
NewPrepareSyncContributionDuty(slot) duty fanout key Duty::new_prepare_sync_contribution_duty(SlotNumber::new(slot))
awaitAggSigDBFunc(ctx, duty, pk) validatorapi.go:849 / :1123 await_agg_sig_db_fn hook (PR-1 plumbing) bounded by SELECTIONS_AGG_SIG_DB_TIMEOUT

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 - 384/384 passing (10 new tests covering: happy path / multi-selection fanout+stitching / unknown validator-index 400 / verification-failure short-circuit / await timeout - for each of the two handlers)

varex83 and others added 11 commits May 28, 2026 14:08
Threads the Handler through Axum state via AppState<H> + with_state,
wires the node_version route to the real handler, and adds a TestHandler
mock that future PRs will extend per-endpoint.
Re-uses the auto-generated pluto_eth2api envelopes
(GetProposerDutiesResponseResponse, GetVersionResponseResponse) as the
on-the-wire shape rather than hand-rolling parallel types. node_version
is migrated to the same pattern; the body.rs hand-rolled wrapper module
is removed.
Drops the per-handler generic parameter and routes through
Arc<dyn Handler> via AppState. The Handler trait is object-safe
(Send + Sync + 'static + async_trait-generated methods), so this
is a pure type change with no surface impact.
Adds the Handler impl that the router has been calling through.
node_version returns the obolnetwork/pluto/{version}-{commit}/{arch}-{os}
identity string; proposer_duties calls the upstream beacon node and
rewrites known DV root public keys to this node's public share so the
validator client sees keys matching its keystore. The remaining 17
trait methods are unimplemented!() stubs that land per-PR as their
router handlers are ported.
Wires POST /eth/v1/validator/duties/attester/{epoch}: dual-format
(numeric or string-encoded) validator index body, upstream call,
pubshare swap.
Wires POST /eth/v1/validator/duties/sync/{epoch}, reusing the
ValIndexes dual-format body extractor.
Wires GET /eth/v1/validator/attestation_data. The Component now
holds an Arc<MemDB> and awaits unsigned attestation data from the
local DutyDB rather than hitting upstream.
Bug fixes (must-fix per review):

- attestation_data: wrap MemDB::await_attestation in tokio::time::timeout
  (24s) so a request for a slot that never produces consensus output
  cannot hold a handler task indefinitely. delete_duty now records
  evicted keys per duty type and notifies waiters, so await_data returns
  Error::AwaitDutyExpired immediately when the awaited duty is gone
  instead of spinning until the timeout fires. Maps to 408 on the wire.
- Stop leaking upstream BlindedBlock400Response Debug output (incl.
  stacktraces) into the client-visible ApiError.message. The variant
  payload is now attached as `source` for debug logs; the message stays
  generic.

Hardening:

- new_insecure is gated behind #[cfg(test)] so the insecure_test flag
  cannot reach production builds.
- new_router applies DefaultBodyLimit::max(64 KiB) on the two
  POST /duties/{attester,sync}/{epoch} routes — defends against the
  Vec<u64> parse amplification on the ValIndexes deserializer.
- All upstream eth2_cl calls are wrapped in tokio::time::timeout(12s)
  so a hanging beacon node cannot stall handler tasks.
- proposer_duties / attester_duties / sync_committee_duties propagate
  upstream BadRequest as 400 and ServiceUnavailable as 503 instead of
  collapsing every non-Ok variant to 502 — the VC can now back off on
  upstream syncing instead of treating it as a gateway failure.
- swap_attester_pubshares / swap_sync_committee_pubshares now return
  500 (cluster misconfig) instead of 502 when a pubshare is missing —
  the upstream returned well-formed data, the failure is local.

ValIndexes:

- Replace #[serde(untagged)] with a streaming Visitor that validates
  each element via SeqAccess::next_element. Avoids the speculative
  Vec<u64> parse and the serde Content cache. Now accepts mixed
  numeric/string elements and rejects negative integers.
- Hard cap at 8192 indices per request.

ApiError:

- with_boxed_source for sources that aren't std::error::Error (e.g.
  anyhow::Error from auto-gen request builders).

Router:

- attestation_data uses Result<Query<...>, QueryRejection> so 4xx
  responses from missing/malformed query params share the same
  { code, message } envelope as the rest of the router.

Tests (+13):

- attestation_data: timeout when data never arrives; 408 when duty is
  evicted while a waiter is parked; cancellation cleanup when the
  handler future is dropped; negative lookup on wrong committee_index.
- Status-mapping helpers: confirm upstream Debug output is never
  serialized into the message.
- Router: ApiError envelope on bad query; oversized body rejection;
  ValIndexes empty/mixed/oversized/negative cases.

Co-Authored-By: Bohdan Ohorodnii <35969035+varex83@users.noreply.github.com>
Adds the plumbing every subsequent submit/await handler needs without
implementing any of the unimplemented!() arms. Mirrors Charon's
core/validatorapi/validatorapi.go:196-256 (subscriber list + six
Register* hooks) plus :1352 (verifyPartialSig).

- New Component fields: subs, await_proposal_fn, await_agg_attestation_fn,
  await_sync_contribution_fn, await_agg_sig_db_fn, duty_def_fn,
  pub_key_by_att_fn. All Option<Arc<…>> so registration before the
  Component is shared in an Arc, then read-only thereafter.
- subscribe() wraps the user closure with a set-clone step so each
  subscriber receives its own ParSignedDataSet — matches Go's
  Subscribe clone-before-fanout at validatorapi.go:249-256.
- register_* methods replace any prior registration, matching Go's
  single-function input semantics.
- verify_partial_sig() honours insecure_test, looks up this node's
  public share from pub_share_by_pubkey, then delegates to
  pluto_eth2util::signing::verify. Unlike Go — which projects domain /
  epoch / message-root through the core.Eth2SignedData interface — the
  Rust hook takes those three values directly so we don't have to port
  the Eth2SignedData trait in this plumbing PR; submit handlers in PRs
  3-6 will derive the triple from their concrete signed-data wrapper.

Tests: subscribe fanout clones per subscriber; the six register hooks
all overwrite on re-register; unregistered hooks default to None;
verify_partial_sig accepts a real BLS signature, rejects a tampered
one, rejects an unknown DV pubkey, and short-circuits in insecure_test
mode.

Co-Authored-By: Bohdan Ohorodnii <35969035+varex83@users.noreply.github.com>
…andlers

Ports `BeaconCommitteeSelections` and `SyncCommitteeSelections` from
`core/validatorapi/validatorapi.go` (lines 798-864 and 1072-1138). Each
handler resolves every selection's validator-index to a DV root pubkey
via a new `register_active_validators` hook, builds the matching
`signeddata::*::new_partial` wrapper, verifies the per-share selection
proof, groups the partial-signed data by slot, fans the per-slot
`ParSignedDataSet` out to every subscriber under the corresponding
`PrepareAggregator` / `PrepareSyncContribution` duty, then pulls each
aggregated reply back out of the AggSigDB via `await_agg_sig_db` and
stitches it into the response.

The `BeaconCommitteeSelection` / `SyncCommitteeSelection` placeholder
structs in `validatorapi/types.rs` are now aliases for the consensus
`v1::BeaconCommitteeSelection` / `v1::SyncCommitteeSelection`, matching
the established pattern (`AttestationData = phase0::AttestationData`)
so the handler signatures carry the real validator-index / slot /
selection-proof tuple Go uses.

`fetch_active_validators` is bounded by `UPSTREAM_REQUEST_TIMEOUT`; each
`await_agg_sig_db` lookup is bounded by `SELECTIONS_AGG_SIG_DB_TIMEOUT`
(~two slots). Upstream stalls surface as 504/408; unknown validator
indices surface as 400 with a generic "validator not found" message;
verification failures surface as 400; subscriber / lookup errors
surface as 500 with the original error attached as `source` for debug
logging only.

Tests (10 new):
- happy path for each handler (one selection in, one aggregated out)
- multi-selection input — per-slot subscriber fanout + response
  stitching
- unknown validator-index short-circuits with 400 before the AggSigDB
  is touched
- tampered (zero) selection proof short-circuits with 400 before the
  AggSigDB is touched
- stalled `await_agg_sig_db` trips `SELECTIONS_AGG_SIG_DB_TIMEOUT` and
  returns 408

Co-Authored-By: Bohdan Ohorodnii <35969035+varex83@users.noreply.github.com>
@varex83agent varex83agent force-pushed the bohdan/validatorapi-plumbing branch from f5c3b49 to 8eedb3f Compare June 2, 2026 14:09
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