Skip to content

fix(app): make builder registrations work against real beacon nodes - #592

Draft
varex83agent wants to merge 4 commits into
mainfrom
fix/builder-registration-parity
Draft

fix(app): make builder registrations work against real beacon nodes#592
varex83agent wants to merge 4 commits into
mainfrom
fix/builder-registration-parity

Conversation

@varex83agent

@varex83agent varex83agent commented Aug 10, 2026

Copy link
Copy Markdown
Collaborator

🧩 What was the bug?

Pluto returned HTTP 502 on every POST /eth/v1/validator/register_validator, so builder registrations never worked against a real beacon node:

ERROR validator api error status=502 upstream application builder domain failed
      source=Domain type not found: DOMAIN_APPLICATION_BUILDER

DOMAIN_APPLICATION_BUILDER comes from the builder spec, not the consensus spec, so beacon nodes do not serve it in /eth/v1/config/spec — I confirmed the local Lighthouse serves DOMAIN_APPLICATION_MASK but not this key. Charon never hits it because go-eth2-client silently injects a fallback (http/spec.go).

Validator clients re-register on a timer, so the failure became an unbounded retry loop. Measured on a 2+2 mixed Charon/Pluto cluster: 12,665 and 12,935 registration attempts per Pluto VC in 10 minutes, versus 0 for the Charon VCs (3.63M vs 256 over the preceding 47h).

Two further defects sat behind it:

The duty path could never work in a mixed cluster. Charon v1.7 removed the VC-push architecture — SubmitValidatorRegistrations is a no-op and the scheduler submits pre-aggregated registrations straight to the beacon node once per epoch. Pluto still verified the VC's partial signature and fanned it out as a builder_registration duty requiring threshold aggregation. With a 3-of-4 threshold and only the two Pluto nodes contributing, the duty stalls forever — silently, since registration duties are deliberately given no deadline.

The failure was invisible in metrics. request_total, request_error_total and vc_user_agent were defined in validatorapi::metrics but incremented nowhere, and the router had no endpoint-name concept, so request_latency_seconds only ever emitted endpoint="proxy". A Pluto node exported zero core_validatorapi_request_* series where Charon exports 98.


🔧 What's been fixed?

1. Spec-default domain fallbacks (crates/eth2api/src/extensions.rs)

Inject the same three fallbacks go-eth2-client does (DOMAIN_APPLICATION_MASK, DOMAIN_BLS_TO_EXECUTION_CHANGE, DOMAIN_APPLICATION_BUILDER) inside fetch_spec_data — the single cached point where spec JSON enters the process, so it fixes resolve_domain_type, fetch_domain_type and signing::get_domain at once. Keys the node does serve always win.

2. Validator API metrics at Charon parity (crates/core/src/validatorapi/)

Every route carries Charon's endpoint label. One middleware records latency, request_total, vc_user_agent and any non-2xx, following the ordering in Charon's wrap: the content type is normalised first, and an unrecognised type is rejected 415 and deliberately not counted in request_total. Also bounds the proxy path label — it was a naive /_ replace, so /eth/v2/beacon/blocks/0x<root> minted a new histogram series per block root.

3. Port BuilderRegistrationService (crates/app/src/builderregistration.rs)

Serves the cluster lock's group-signed registrations, optionally overridden by an operator-managed JSON file (watched via notify) and by the Obol API's aggregated partial signatures. Adds the two /fee_recipient endpoints, and two slot subscribers: the per-epoch registration submission, and prepare_beacon_proposer — which Pluto never sent at all, so the beacon node was building local blocks against its own default fee recipient.

Removes the unreachable duty path, including the recaster (already dead code in Pluto), and drops futures from pluto-core, which recast.rs was the last user of.


🧠 Context worth flagging for review

Why registrations skip consensus entirely. The lock already contains a fully-aggregated, group-signed registration per validator (DistValidator::eth2_registration()), so there is nothing to agree on — every node holds the same signed message and submits it to its own beacon node. This is what makes the mixed-cluster deadlock go away: nothing needs quorum any more.

DutyType::BuilderRegistration is kept even though it is now unreachable — it is a wire-format enum value, and Charon keeps core.DutyBuilderRegistration too.

Deliberate deviations from Charon, all commented in code:

  • vise::Family exposes no removal API, so Charon's ResetGaugeVec single-series semantics for vc_user_agent can't be reproduced. The previous label is set to 0 instead; the stale series remains at zero.
  • Charon labels proxy errors with the raw request path, which is unbounded. This uses the constant proxy for the counter and keeps the collapsed path only on the latency histogram.
  • The service's run waits for cancellation even with nothing configured, because the node treats any long-lived task returning as a shutdown signal. Returning early stops the node — this was caught by the simnet test.

Test fixtures were masking the bug. The beaconmock default spec injected DOMAIN_APPLICATION_BUILDER even though the Holesky snapshot behind it does not contain the key. That override and three hand-written fixtures are removed, so the fallback — not the fixture — is what makes those tests pass.

Registrations use the submission client, not the scheduling one. Beacon nodes proxy register_validator to the builder relay and routinely take seconds; the local Lighthouse takes a flat 3s. The 2s general timeout aborted first and hid the beacon node's actual response, so this belongs under --beacon-node-submit-timeout like every other submission.

Four new CLI flags matching charon/cmd/run.go: --overrides-file, --publish-address, --publish-timeout, --fetch-feerecipient-updates (default false, so the default configuration makes no outbound calls). The last requires the second, as in Charon.


✅ Checklist

  • Confirmed the bug no longer occurs
  • Existing tests pass
  • Added new test(s) for the bug
  • The branch is named per repo convention (fix/<name>)

Gates: cargo +nightly fmt --all --check, cargo clippy --workspace --all-targets --all-features -- -D warnings, cargo test --workspace --all-features (2242 pass), cargo deny check, cargo machete.

Verified on a live 2+2 mixed cluster (node0/1 Charon v1.7.1, node2/3 Pluto, Lighthouse VCs, threshold 3-of-4, 256 validators):

before after
Registration attempts, Pluto VCs ~6,400 / 5 min each 0 / hour over 24h
502s on register_validator every slot, forever 0
core_validatorapi_request_* series 0 named endpoints, Charon's labels
Failed duties (Pluto nodes) 0.0 and 1.1 / 4 min

🤖 Generated with Claude Code

varex83agent and others added 4 commits August 7, 2026 12:08
Real beacon nodes do not serve DOMAIN_APPLICATION_BUILDER — it comes from
the builder spec, not the consensus spec — so every builder registration
failed with `Domain type not found`, returning 502 to the VC. Lighthouse
retries every slot, producing ~1,280 registration attempts per minute per
node against a Pluto middleware that never accepts one.

Charon does not hit this because go-eth2-client injects the same three
fallbacks (DOMAIN_APPLICATION_MASK, DOMAIN_BLS_TO_EXECUTION_CHANGE,
DOMAIN_APPLICATION_BUILDER) when the node omits them. Do the same in
`fetch_spec_data`, the single point where spec JSON enters the process.

The bug was masked by test fixtures: the beaconmock default spec injected
DOMAIN_APPLICATION_BUILDER even though the Holesky snapshot backing it
does not contain the key. Drop that override and the three hand-written
fixtures that did the same, so the fallback is what makes them pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`request_total`, `request_error_total` and `vc_user_agent` were defined in
validatorapi::metrics but incremented nowhere, and the router had no
endpoint-name concept, so `request_latency_seconds` only ever emitted
endpoint="proxy". A Pluto node exported zero core_validatorapi_request_*
series where Charon exports 98 — a sustained 502 storm on the validator
API produced no metric signal at all.

Give every route the endpoint label Charon uses, and record the metrics in
one middleware following `wrap` in Charon's router: normalise the content
type first (an unrecognised type is rejected 415 and deliberately not
counted in request_total), record the user agent, then count any non-2xx.
Counting from the final response status, rather than hooking ApiError,
also catches extractor rejections and upstream statuses relayed verbatim
by the proxy fallback.

Also bound the proxy path label. It was a naive '/'→'_' replace, so
/eth/v2/beacon/blocks/0x<root> minted a new histogram series per block
root; port Charon's proxyPathLabel to collapse hex, numeric and peer-id
segments.

vise::Family exposes no removal API, so Charon's ResetGaugeVec semantics
for vc_user_agent are approximated by zeroing the previous label.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Charon v1.7 removed the VC-push path for builder registrations: the
validator API ignores them, and the scheduler submits the cluster's
pre-aggregated registrations straight to the beacon node once per epoch.
Pluto still implemented the pre-v1.7 architecture — verify the VC's
partial signature, fan it out as a builder_registration duty, aggregate to
threshold, broadcast — which cannot work in a mixed cluster: with a 3-of-4
threshold and only the Pluto nodes contributing partial signatures, the
duty never reaches quorum. It also stalls silently, because registration
duties are deliberately given no deadline.

Port Charon's architecture:

- `builderregistration`: serves the lock's group-signed registrations,
  optionally overridden by an operator-managed JSON file (watched via
  `notify`, on the parent directory so atomic renames are caught) and by
  the Obol API, which aggregates operators' partial signatures. Overrides
  apply only when strictly newer per pubkey; the file wins ties. The file
  is validated strictly (any bad signature rejects it — these decide where
  rewards go) while API entries are dropped individually.
- `obolapi::feerecipient`: the two `/fee_recipient` endpoints, including
  Charon's two 404 cases, where "nothing submitted yet" is not an error.
- Two slot subscribers registered in `wire.rs` rather than inside the
  scheduler, since `pluto-core` cannot depend on `pluto-app` and
  `subscribe_slot` already gives per-event spawning and cancellation:
  the per-epoch registration submission (75% into the first slot, epoch
  recorded only on success) and `prepare_beacon_proposer`, which Pluto
  never sent at all — the beacon node was building local blocks against
  its own default fee recipient.

The service's `run` waits for cancellation even with nothing configured:
the node treats any long-lived task returning as a shutdown signal.

Removes the now-unreachable duty path, including the recaster, which had
already been dead code. `DutyType::BuilderRegistration` stays — it is a
wire-format value, and Charon keeps it too. The health check for
registration failures now reads
`core_scheduler_submit_registration_errors_total`, as Charon's does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two problems found running the mixed cluster:

The startup submission was spawned at wiring time, before the scheduler
waits for chain start and beacon-node sync, so it fired ~3s into boot and
failed against a beacon node that was not ready. Move it after
`sched_builder.build()`, which is where Charon submits its startup
registrations.

It also used the scheduling client, whose `--beacon-node-timeout` defaults
to 2s. Beacon nodes proxy `register_validator` to the builder relay and
routinely take longer: the local Lighthouse takes a flat 3s before
answering. The 2s deadline aborted first, so the operator saw a bare
transport error instead of the beacon node's actual response. Using the
submission client puts it under `--beacon-node-submit-timeout` like every
other submission, and the real error now surfaces
("500 INTERNAL_SERVER_ERROR: no successful relay response").

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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