fix(app): make builder registrations work against real beacon nodes - #592
Draft
varex83agent wants to merge 4 commits into
Draft
fix(app): make builder registrations work against real beacon nodes#592varex83agent wants to merge 4 commits into
varex83agent wants to merge 4 commits into
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
🧩 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:DOMAIN_APPLICATION_BUILDERcomes 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 servesDOMAIN_APPLICATION_MASKbut 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 —
SubmitValidatorRegistrationsis 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 abuilder_registrationduty 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_totalandvc_user_agentwere defined invalidatorapi::metricsbut incremented nowhere, and the router had no endpoint-name concept, sorequest_latency_secondsonly ever emittedendpoint="proxy". A Pluto node exported zerocore_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) insidefetch_spec_data— the single cached point where spec JSON enters the process, so it fixesresolve_domain_type,fetch_domain_typeandsigning::get_domainat once. Keys the node does serve always win.2. Validator API metrics at Charon parity (
crates/core/src/validatorapi/)Every route carries Charon's
endpointlabel. One middleware records latency,request_total,vc_user_agentand any non-2xx, following the ordering in Charon'swrap: the content type is normalised first, and an unrecognised type is rejected 415 and deliberately not counted inrequest_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_recipientendpoints, and two slot subscribers: the per-epoch registration submission, andprepare_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
futuresfrompluto-core, whichrecast.rswas 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::BuilderRegistrationis kept even though it is now unreachable — it is a wire-format enum value, and Charon keepscore.DutyBuilderRegistrationtoo.Deliberate deviations from Charon, all commented in code:
vise::Familyexposes no removal API, so Charon'sResetGaugeVecsingle-series semantics forvc_user_agentcan't be reproduced. The previous label is set to0instead; the stale series remains at zero.proxyfor the counter and keeps the collapsed path only on the latency histogram.runwaits 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_BUILDEReven 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_validatorto 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-timeoutlike 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
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):
register_validatorcore_validatorapi_request_*series🤖 Generated with Claude Code