Skip to content

feat(core): wire validatorapi proxy + proposal/validators router handlers#488

Open
varex83agent wants to merge 2 commits into
bohdan/validatorapi-validatorsfrom
bohdan/validatorapi-pr1-proxy-wiring
Open

feat(core): wire validatorapi proxy + proposal/validators router handlers#488
varex83agent wants to merge 2 commits into
bohdan/validatorapi-validatorsfrom
bohdan/validatorapi-pr1-proxy-wiring

Conversation

@varex83agent

Copy link
Copy Markdown
Collaborator

Summary

PR 1 of the validatorapi port — "make the current surface usable". The proposal,
blinded-proposal, submit, and validators component logic already landed on the base
branch (bohdan/validatorapi-validators); this PR wires the axum router handlers that were
todo!() and adds the two missing infra pieces, so the already-merged logic is reachable end
to end.

Go reference: Charon v1.7.1, core/validatorapi/router.go + eth2types.go.

Scope

Handlers implemented in crates/core/src/validatorapi/router.rs:

Endpoint Go ref (v1.7.1)
proxy_handler (.fallback) — reverse-proxy to the beacon node, basic-auth + Host rewrite + proxy-latency metric proxyHandler
POST /eth/v1/validator/prepare_beacon_proposer — no-op swallow (200) submitProposalPreparations
GET /eth/v3/validator/blocks/{slot} — versioned block + Eth-Consensus-Version/payload-value headers; builder_enabled maxes the boost factor proposeBlockV3 + createProposeBlockResponse
POST /eth/v{1,2}/beacon/blocks submitProposal
POST /eth/v{1,2}/beacon/blinded_blocks submitBlindedBlock
GET,POST /eth/v1/beacon/states/{state_id}/validators[/{validator_id}] getValidators / getValidator

No todo!() remains for PR-1-scoped handlers. The PR-2/3/4 endpoints keep their stubs.

Parity notes

  • Submit (de)serialization: the per-fork block body is parsed (JSON or SSZ per
    Content-Type) keyed by the Eth-Consensus-Version header, mirroring Charon's
    submitProposal/submitBlindedBlock version switch. Header match is case-insensitive
    (go-eth2-client DataVersion.UnmarshalJSON). Unknown content type → 415; unknown/missing
    version → 400.
  • proposeBlockV3 response: data is the bare block for pre-Deneb / blinded forks and the
    BlockContents object ({block, kzg_proofs, blobs}) for Deneb/Electra/Fulu full blocks,
    matching go-eth2-client's apiv1<fork>.BlockContents. The four Eth-* headers are set as in
    Go.
  • get_validators: the whole id batch is dispatched on ids[0]'s 0x prefix exactly as Go
    getValidatorsByID (router.go:1930) — all-pubkeys or all-indices, no per-id bucketing.
    Empty result serializes to [] not null; get_validator returns 404 on none and 500
    on more-than-one.
  • graffiti: length-lenient (any-length hex truncated/zero-padded into 32 bytes) like Go's
    copy(graffiti[:], graffitiBytes); randao_reveal is strict 96 bytes.
  • proxy: strips URL userinfo into a basic-auth header, rewrites Host, and drops
    hop-by-hop + Content-Length headers in both directions. Charon cancels in-flight proxied
    requests via the lifecycle context; here the proxied request inherits the axum request's
    lifetime (no cluster-wide CancellationToken is plumbed into new_router yet — out of PR1
    scope).

Surface changes for downstream PRs (2/3/4)

  • new_router(handler, builder_enabled, upstream_base_url: reqwest::Url) — added the upstream
    URL; AppState now carries upstream_base_url + a reqwest::Client. Added reqwest + url
    to crates/core/Cargo.toml.
  • crates/core/src/ssz_codec.rs gained public decode_signed_proposal_block_body /
    decode_signed_blinded_proposal_block_body (bare beacon-API SSZ block bodies — distinct from
    the Charon-header decode_versioned_signed_proposal).
  • testutils.rs TestHandler gained with_proposal/with_validators setters and
    Arc<Mutex<…>> recording fields for submit endpoints. Router tests gained test_state /
    test_router helpers.

Tests

Router-level tower::ServiceExt::oneshot tests covering happy path, content-type / version
negotiation, and error shapes for each new endpoint: prepare-proposer swallow, propose_block_v3
(+ headers, builder boost, graffiti padding, missing randao), submit_proposal (JSON, capitalised
version, missing/invalid version, unsupported content type, bad body), submit_blinded_block
(happy + phase0-rejected), get_validators (query id / pubkey dispatch / JSON body ids / empty →
[]), get_validator (single / 404 / 500), and the reverse proxy (forward + basic-auth via a
wiremock upstream, error-status propagation).

Quality gates

cargo +nightly fmt --all --check, cargo clippy --workspace --all-targets --all-features -D warnings, and cargo deny check pass. Workspace tests pass; the eth2api integration
feature tests (Docker/Lighthouse via testcontainers) are not exercised here and are untouched by
this change.

🤖 Generated with Claude Code

varex83agent and others added 2 commits June 17, 2026 12:54
…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>
@varex83agent

Copy link
Copy Markdown
Collaborator Author

/loop-review-pr summary

Ran 1 review-and-fix iteration against this PR (four parallel review agents: functional-parity, security, rust-style, code-quality). Terminated by: completion_promise (no remaining bug/major findings; only deferred minors/nits below).

Quality gates (final)

  • cargo fmt — pass
  • cargo clippy — pass (--workspace --all-targets --all-features -D warnings)
  • cargo test — pass (--workspace; the eth2api integration feature spins up a Lighthouse Docker container via testcontainers and is not exercised here)

Resolved during the loop

Major (3)

  • Reverse-proxy buffered the full upstream body, which would hang the long-lived SSE /eth/v1/events stream — now streams the body through (reqwest::Response::bytes_streamaxum::body::Body::from_stream; enabled reqwest's stream feature). Charon achieves the same with a flushing reverse-proxy writer.
  • Proxy forwarded the client's Authorization header while also applying the upstream basic-auth, producing a duplicate/conflicting credential — the client Authorization is now stripped when the upstream URL carries credentials.
  • use blocks sat after the const declarations in router.rs, violating the use-before-items rule — moved above the consts.

Minor (4)

  • propose_block_v3 sent builder_boost_factor = None when builder mode was off; Charon always sends the factor (0 when disabled, u64::MAX when enabled) — now Some(0) / Some(u64::MAX).
  • decode_signed_proposal_block_body carried a dead blinded param (always false at the only call site) — dropped it; the blinded endpoint uses its own decoder.
  • request_is_ssz silently treated a non-ASCII Content-Type as JSON — now returns 415.
  • Removed Charon router.go:NN line-number anchors from the new doc comments (kept the function-name reference), per the porting convention.

Nits (1)

  • Added a missing SSZ-path submit test (submit_proposal_decodes_ssz_body) exercising the new public SSZ block-body decoder via application/octet-stream.

Outstanding (deferred, all minor/nit with reasons)

  • X-Forwarded-For not appended by the proxy — Go's httputil adds it; requires ConnectInfo client-IP plumbing not currently wired into the router. Operational-only; can follow up.
  • Repeated randao_reveal/graffiti query param — Charon's hexQuery treats a parameter present more than once as absent; this impl takes the first value. Edge case; well-behaved VCs send each param once.
  • Content-type vs version-header check order on submit — only differs for a doubly-malformed request (both unsupported content type and missing version); status code (415 vs 400) differs only in that corner.
  • Proxy replaces upstream base path instead of joining — beacon-node addresses are conventionally root, so no practical impact.

Verdict

PR is ideal — all bug/major findings resolved, gates green. The deferred items are minor edge-case parity / operational-only differences documented above.

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.

1 participant