Skip to content

feat(core): implement validatorapi sync committee contribution + submit handlers#462

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

feat(core): implement validatorapi sync committee contribution + submit handlers#462
varex83agent wants to merge 11 commits into
bohdan/validatorapi-plumbingfrom
bohdan/validatorapi-sync-submit

Conversation

@varex83agent
Copy link
Copy Markdown
Collaborator

Summary

  • sync_committee_contribution (read): replaces unimplemented!() in
    crates/core/src/validatorapi/component.rs. Delegates to the
    await_sync_contribution hook registered by PR-1, bounded by
    DUTY_AWAIT_TIMEOUT. Mirrors Charon's
    core/validatorapi/validatorapi.go:948-955.
  • submit_sync_committee_messages: fetches active validators, builds a
    partial SignedSyncMessage per VC message, verifies the outer partial
    signature against this node's share, then groups by slot and fans out to
    subscribers. Mirrors Charon's validatorapi.go:958-1003.
  • submit_sync_committee_contributions: same shape, plus the inner
    selection-proof verification against the root validator pubkey using
    DomainName::SyncCommitteeSelectionProof. Mirrors
    validatorapi.go:1009-1069.
  • New Component hook (extends PR-1 plumbing): ActiveValidatorsFn +
    active_validators_fn field + register_active_validators. This is the
    Rust analogue of Go's eth2Cl.ActiveValidators(ctx) — required by every
    submit handler that has to map a VC-provided validator_index into the
    cluster's root public key.
  • validatorapi/types.rs: replaces the SyncCommitteeMessage,
    SyncCommitteeContribution, and SignedContributionAndProof placeholder
    structs with the real altair spec types from pluto_eth2api. The
    Handler trait method signatures themselves are unchanged.

Go reference

Pluto handler Charon Go
sync_committee_contribution validatorapi.go:948-955 (SyncCommitteeContribution)
submit_sync_committee_messages validatorapi.go:958-1003 (SubmitSyncCommitteeMessages)
submit_sync_committee_contributions validatorapi.go:1009-1069 (SubmitSyncCommitteeContributions)
verify_partial_sig_for (helper) validatorapi.go:1352 (verifyPartialSig) + eth2signeddata.go:155-171
Inner selection-proof verify (contribs) validatorapi.go:1034-1039 (core.VerifyEth2SignedData(..., NewSyncContributionAndProof(...)))
ActiveValidatorsFn hook app/eth2wrap/valcache.go:18 (ActiveValidators map[ValidatorIndex]BLSPubKey)

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 — 388/388 passing (12 new
    tests covering: sync_committee_contribution happy path, hook-error
    408, hook-hang timeout, no-hook 500; submit_messages fanout/grouping,
    unknown index 400, hook 502, no hook 500, real BLS round-trip,
    invalid sig rejection; submit_contributions fanout/grouping, unknown
    aggregator 400, invalid outer partial-sig rejection; SyncCommittee
    domain round-trip via verify_partial_sig)

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>
…it handlers

Replaces the unimplemented!() arms for SyncCommitteeContribution (read),
SubmitSyncCommitteeMessages, and SubmitSyncCommitteeContributions in
crates/core/src/validatorapi/component.rs. Mirrors Charon's
core/validatorapi/validatorapi.go:948-1069.

- sync_committee_contribution: delegates to the await_sync_contribution
  hook registered by PR-1 plumbing, wrapped in a DUTY_AWAIT_TIMEOUT so a
  missing/expired duty surfaces as 408 instead of hanging.
- submit_sync_committee_messages: fetches active validators, builds a
  partial SignedSyncMessage per validator, verifies the outer partial
  signature against this node's share via verify_partial_sig_for, then
  groups by slot and fans out to subscribers — matches
  validatorapi.go:958.
- submit_sync_committee_contributions: same shape, plus the inner
  selection-proof verification against the **root** validator pubkey
  (not the share) via signing::verify with
  DomainName::SyncCommitteeSelectionProof — matches validatorapi.go:1032.
  Skipped when insecure_test is set, mirroring Go.

To support the validator-index → root-pubkey lookup that the Go submit
handlers do via eth2Cl.ActiveValidators(ctx), this PR adds one new hook
following the PR-1 pattern: ActiveValidatorsFn + active_validators_fn
field + register_active_validators method. The hook returns a
HashMap<ValidatorIndex, BLSPubKey>. Wiring it into the app layer is
deferred to a later PR; the wired callbacks live outside crates/core
and so are out of scope here.

Types: replaces the SyncCommitteeMessage / SyncCommitteeContribution /
SignedContributionAndProof placeholders in validatorapi/types.rs with
the real altair spec types from pluto_eth2api. The Handler trait
method signatures are unchanged.

Tests (12 new):
- sync_committee_contribution: happy path, hook error → 408, hook
  hang → 408 timeout, no hook registered → 500.
- submit_sync_committee_messages: groups by slot + fanout, rejects
  unknown validator index, 502 on active-validators hook error, 500
  when no hook registered, accepts a real BLS partial signature, and
  rejects when the share map has no entry for the root pubkey.
- submit_sync_committee_contributions: groups by slot + fanout,
  rejects unknown aggregator index, rejects when the outer partial sig
  fails to verify (no share registered).
- verify_partial_sig_round_trips_sync_committee_domain: confirms
  signing & verify agree on the SyncCommittee signing root under the
  shared mock-beacon spec fixture.

All gates pass: cargo +nightly fmt --all --check, cargo clippy
-p pluto-core --all-targets --all-features -- -D warnings, cargo test
-p pluto-core --all-features (388/388).

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