Skip to content

feat(core): validatorapi voluntary exit + validator registration handlers#491

Open
varex83agent wants to merge 15 commits into
mainfrom
bohdan/validatorapi-pr4-validator-lifecycle
Open

feat(core): validatorapi voluntary exit + validator registration handlers#491
varex83agent wants to merge 15 commits into
mainfrom
bohdan/validatorapi-pr4-validator-lifecycle

Conversation

@varex83agent

Copy link
Copy Markdown
Collaborator

Summary

Ports the validator-lifecycle validatorapi endpoints from Charon v1.7.1, wiring both the Component handler logic and the axum router so the routes are reachable end-to-end (no more todo!()/unimplemented!() for these endpoints).

  • POST /eth/v1/beacon/pool/voluntary_exitssubmit_voluntary_exit
  • POST /eth/v1/validator/register_validatorsubmit_validator_registrations

Scope

Component (crates/core/src/validatorapi/component.rs)

  • submit_voluntary_exit: looks up the DV root pubkey from the active-validator cache ("validator not found" → 400 on miss), builds an Exit duty at slot = slots_per_epoch * exit.epoch, verifies the partial signature under DOMAIN_VOLUNTARY_EXIT (epoch = exit.message.epoch), and fans out the partial-signed data to subscribers.
  • submit_validator_registrations: empty list → no-op; builder mode disabled → swallow; per registration, non-distributed-validator keys are swallowed, the timestamp maps to a slot via slot_from_timestamp, a BuilderRegistration duty is built, the partial signature is verified under DOMAIN_APPLICATION_BUILDER (epoch 0), and the result is broadcast.

Router (router.rs)

  • submit_exit: JSON-only; SSZ/unrecognised content types → 415, empty body → 400, malformed JSON → 400.
  • submit_validator_registrations: JSON array or bare SSZ concatenation (180-byte fixed objects); misaligned SSZ → 415, empty body → 400.

Supporting

  • eth2util::helpers::slot_from_timestamp — genesis time + slot duration mapping with the pre-genesis current-time fallback.
  • eth2api v1: derive ssz Encode/Decode on ValidatorRegistration / SignedValidatorRegistration.
  • ssz_codec::decode_signed_validator_registrations — bare 180-byte array decode with invalid buffer size guard.
  • validatorapi::types: back SignedValidatorRegistration / SignedVoluntaryExit with the real consensus-spec payloads (were empty placeholders).

Go references (v1.7.1)

  • core/validatorapi/validatorapi.go: SubmitVoluntaryExit, SubmitValidatorRegistrations, submitRegistration, SlotFromTimestamp, verifyPartialSig.
  • core/validatorapi/router.go: submitExit, submitValidatorRegistrations, unmarshal, content negotiation in wrap.
  • core/validatorapi/eth2types.go: signedValidatorRegistrations (SSZ 180-byte element size).
  • core/eth2signeddata.go: exit domain DOMAIN_VOLUNTARY_EXIT / epoch = message epoch; registration domain DOMAIN_APPLICATION_BUILDER / epoch 0.

Test coverage

  • Component: exit verify+broadcast (slot = slots_per_epoch * epoch), unknown-validator 400, bad-signature reject; registration builder-disabled swallow, empty no-op, non-DV swallow, verify+broadcast (timestamp→slot), bad-signature reject.
  • Router: registration JSON array, registration SSZ array (360 bytes), misaligned SSZ → 415, empty body → 400; exit JSON happy path, SSZ content type → 415, empty body → 400, invalid JSON → 400.
  • ssz_codec: registration array round-trip, misaligned-buffer reject, empty-is-empty.
  • eth2util: slot_from_timestamp normal mapping + pre-genesis fallback.

Gates

  • cargo +nightly fmt --all --check
  • cargo clippy --workspace --all-targets --all-features -- -D warnings
  • cargo test --workspace ✅ (1612 passed, 0 failed)
  • cargo deny check

Note: cargo test --all-features is Docker-gated in this sandbox (eth2api integration feature spins a Lighthouse testcontainer that never readies). The functional gate is cargo test --workspace (default features); --all-features was confirmed to compile and is clippy-clean.

Stacks on PR #488 (PR 1 — proxy + proposal/validators wiring).

🤖 Generated with Claude Code

varex83 and others added 15 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>
Ports `Validators` from `core/validatorapi/validatorapi.go` (lines 1218–1296)
together with the `convertValidators` helper (lines 1305–1332). The handler
translates VC-supplied pubshares back to the cluster's root pubkeys before
calling the upstream `POST /eth/v1/beacon/states/{state_id}/validators`
endpoint, then rewrites each returned validator's inner pubkey from the
root key to this node's public share so the downstream VC sees the share
it is configured to sign with.

`ignoreNotFound` follows the Go semantics: when the request filtered by
indices, an upstream validator that is not part of this cluster surfaces
as 500 (`pubshare not found`); otherwise the entry passes through with
its root pubkey unchanged. Upstream timeouts surface as 504, transport
failures as 502, and a malformed pubkey from the upstream as 502.
Upstream 400 / 404 propagate faithfully without leaking the upstream
body into the client-visible message.

`Validator` is aliased to the auto-generated
`GetStateValidatorsResponseResponseDatum`, matching the established
pattern for the other duty types in `validatorapi/types.rs`.

Co-Authored-By: Bohdan Ohorodnii <35969035+varex83@users.noreply.github.com>
Resolve conflicts against squash-merged main:
- types.rs / component.rs: keep the new `validators` handler,
  `convert_validators`, and `invert_pub_share_map` (PR's unique work),
  adapting the upstream request to main's struct-literal
  `PostStateValidatorsRequest`/`Component::new` (now takes a
  `validator_cache`); alias `Validator` to the eth2api datum.
- router.rs / dutydb/memory.rs: take main's evolved versions (JSON
  rejection + content-type middleware; eviction high-water mark).

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

Implement the axum router handlers that were todo!() for the
already-merged component logic, plus the two infra pieces, so the
validator API surface is reachable end to end:

- proxy_handler fallback: reverse-proxy to the beacon node with
  basic-auth, Host rewrite, hop-by-hop header stripping, and a
  proxy-latency metric.
- submit_proposal_preparations: no-op swallow (200).
- propose_block_v3: versioned block response with the consensus
  version / payload-value headers; builder_enabled maxes the boost.
- submit_proposal / submit_blinded_block: per-fork (de)serialization
  keyed by the Eth-Consensus-Version header (JSON or SSZ body).
- get_validators / get_validator: id batch dispatched on the first
  element's 0x prefix, matching Charon's getValidatorsByID.

new_router gains an upstream_base_url argument and AppState carries a
reqwest client for the proxy. Adds public per-fork SSZ block-body
decoders in ssz_codec and extends the TestHandler stubs.

Go reference: charon core/validatorapi/router.go (v1.7.1).

Co-Authored-By: Bohdan Ohorodnii <35969035+varex83@users.noreply.github.com>
- proxy: stream the upstream response body instead of buffering, so the
  long-lived SSE /eth/v1/events stream proxies incrementally (reqwest
  bytes_stream + axum Body::from_stream; enable reqwest "stream").
- proxy: strip the client Authorization header when the upstream URL
  carries credentials, avoiding a duplicate/conflicting Authorization.
- propose_block_v3: always send builder_boost_factor (0 when builder
  mode is off, u64::MAX when on), matching Charon.
- request_is_ssz: reject a non-ASCII Content-Type with 415 instead of
  silently treating it as JSON.
- ssz_codec: drop the dead `blinded` parameter from
  decode_signed_proposal_block_body (full-block decoder only).
- move the use blocks above the const declarations in router.rs.
- add an SSZ-body submit test; drop Go line-number anchors from doc
  comments.

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

Port the validator-lifecycle endpoints from Charon v1.7.1:

- POST /eth/v1/beacon/pool/voluntary_exits (submit_exit): JSON-only, looks
  up the DV root pubkey from the active-validator cache, builds an Exit duty
  at slot = slots_per_epoch * exit.epoch, verifies the partial signature
  under DOMAIN_VOLUNTARY_EXIT, and broadcasts to subscribers.
- POST /eth/v1/validator/register_validator (submit_validator_registrations):
  JSON or SSZ array body. Empty list and builder-disabled inputs are
  swallowed; non-distributed-validator keys are swallowed; each managed
  registration maps its timestamp to a slot via slot_from_timestamp, is
  verified under DOMAIN_APPLICATION_BUILDER (epoch 0), and broadcast.

Supporting changes:
- eth2util: add helpers::slot_from_timestamp (genesis time + slot duration,
  with the pre-genesis current-time fallback).
- eth2api v1: derive ssz Encode/Decode on (Signed)ValidatorRegistration.
- core ssz_codec: decode_signed_validator_registrations for the bare 180-byte
  concatenation, with the invalid-buffer-size guard.
- validatorapi types: back SignedValidatorRegistration / SignedVoluntaryExit
  with the real consensus-spec payloads.

Co-Authored-By: Bohdan Ohorodnii <35969035+varex83@users.noreply.github.com>
- Bound register_validator: per-route body limit + REGISTRATIONS_MAX_LEN
  element cap so a single caller cannot drive unbounded upstream fan-out.
- Wrap the previously un-timed upstream calls (fetch_slots_config in
  submit_voluntary_exit, slot_from_timestamp in submit_registration) in
  UPSTREAM_REQUEST_TIMEOUT, matching the rest of the component.
- Add HelperError::SlotComputation so slot arithmetic failures are no longer
  reported as "fetch slots config".
- Go parity: voluntary-exit success logs at info!; JSON decode failures
  return "failed parsing json request body".
- Remove Go cross-reference comments and now-stale #[allow(dead_code)]
  attributes; collapse verify_par_signed_proposal onto the shared
  verify_par_signed helper.
- Tests: multi-registration batch (in-order success + halt-on-error);
  stronger before-genesis slot_from_timestamp assertion.

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

Copy link
Copy Markdown
Collaborator Author

/loop-review-pr summary

Ran 1 review-and-fix iteration against this PR (four parallel reviewers: functional-parity, security, rust-style, code-quality). Terminated by: completion_promise (PR ideal — no remaining bug/major findings, gates green).

Quality gates (final)

  • cargo +nightly fmt --all --check — pass
  • cargo clippy --workspace --all-targets --all-features -- -D warnings — pass
  • cargo test --workspace — pass (1614 passed, 0 failed)
  • cargo deny check — pass

(Note: cargo test --all-features is Docker-gated in the build sandbox — the eth2api integration feature spins a Lighthouse testcontainer that never readies — so the functional gate is cargo test --workspace on default features; --all-features was confirmed to compile and is clippy-clean.)

Resolved during the loop

Major (4)

  • register_validator had no body/element cap → unbounded per-request upstream fan-out — added a per-route DefaultBodyLimit plus a REGISTRATIONS_MAX_LEN count cap — crates/core/src/validatorapi/router.rs
  • submit_voluntary_exit issued an un-timed fetch_slots_config — wrapped in UPSTREAM_REQUEST_TIMEOUTcrates/core/src/validatorapi/component.rs
  • submit_registration issued un-timed slot_from_timestamp upstream calls — wrapped in UPSTREAM_REQUEST_TIMEOUTcrates/core/src/validatorapi/component.rs
  • HelperError::FetchSlotsConfig was reused for slot arithmetic failures — added a dedicated HelperError::SlotComputation variant — crates/eth2util/src/helpers.rs

Minor (6)

  • Go parity: voluntary-exit success now logs at info! (was debug!)
  • Go parity: JSON decode failures now return "failed parsing json request body" (exit + registrations)
  • Removed Go cross-reference comments from the new router doc/inline comments
  • Removed now-stale #[allow(dead_code)] on verify_partial_sig and TestValidatorCache::arc
  • Collapsed verify_par_signed_proposal onto the shared verify_par_signed helper (dedupe)
  • Added multi-registration batch tests (in-order success + halt-on-error)

Nits (1)

  • Strengthened the slot_from_timestamp before-genesis test assertion to check the fallback approximates the current slot

Outstanding

None at bug/major severity. A couple of nits were considered and intentionally not taken: replacing the SIGNED_VALIDATOR_REGISTRATION_SSZ_SIZE = 180 constant with a compile-time ssz_fixed_len() assertion (kept the documented constant + the round-trip test that already asserts 180), and unifying the two fetch_slots_config call sites (they serve different purposes).

Verdict

PR is ideal — all bug/major findings resolved, gates green.

Base automatically changed from bohdan/validatorapi-pr1-proxy-wiring to main June 19, 2026 17:47
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