feat(rpc): serve states/{id}/validators/{validator_id} and v2 proposer duties, for Lighthouse's validator client - #653
Conversation
🤖 Kimi Code ReviewI'll review this PR which adds Beacon API endpoints for validator lookup and v2 proposer duties, plus Lighthouse VC integration for testing.
|
🤖 Claude Code ReviewReview of PR 653I couldn't review the actual change. The checkout is a single squashed commit ( What I did check is the one change the commit message describes. The
Automated review by Claude (Anthropic) · sonnet · custom prompt |
🤖 Codex Code ReviewFound two review items; the new Beacon API route itself looks solid otherwise.
No fork-choice / justification / state-transition / SSZ / signature-path concerns are introduced by this PR; the consensus-critical surface here appears unchanged. Automated review by OpenAI Codex · gpt-5.4 · custom prompt |
…and GET /eth/v2/validator/duties/proposer/{epoch}, the two endpoints on Lighthouse's validator client's default path that ethlambda beacon lacked. A devnet with Lighthouse's validator client against ethlambda beacon never left slot 0: Lighthouse resolves each key's index through the single-validator route, and on a 404 holds no index for any of its validators, so every proposal was refused as a reorg and no attester, sync or liveness duty was ever requested; and it asks for proposer duties on v2 with no fallback to v1. The single-validator route answers the same entry the list endpoint gives, as an object, with 404 for a validator the state does not hold and 400 for a malformed id. v2 proposer duties are v1's with v2's dependent_root, the block root before the previous epoch's first slot (fulu fixes proposers an epoch ahead) or the genesis block's where that underflows, and a 503 while syncing; the two share one handler that takes the dependent-root rule.
… ethlambda's, so the Beacon API ethlambda beacon serves is checked against a client other than its own. ethlambda_validator.client: lighthouse starts lighthouse vc on the same derived keys against the same beacon node list, from a writable copy of the keys since Lighthouse writes its definitions file into the validators directory; doppelganger: true adds --enable-doppelganger-protection, and lighthouse_extra_params passes anything else. network_params_lighthouse_vc.yaml runs it against ethlambda beacon alone with doppelganger protection on, which exercises liveness and, with 128 validators filling a 512-seat sync committee, sync duties from the first epoch.
…ranch, beacon-api-lighthouse-defaults. The beacon-api-liveness image it named lacks the validator-by-id and v2 proposer duties endpoints, so Lighthouse's validator client on it never resolves its indices.
ea90354 to
cf4d158
Compare
MegaRedHand
left a comment
There was a problem hiding this comment.
Comments below. The v2 dependent_root rule and the head-state bound on v2 are the two that affect Lighthouse's validator client; the kurtosis config one decides whether the liveness check actually runs.
| dependent: DependentRoot, | ||
| ) -> Result<serde_json::Value, ApiError> { | ||
| let epoch = parse_epoch(epoch)?; | ||
| let (head_root, state) = head(store)?; |
There was a problem hiding this comment.
Medium: the requested epoch is bounded by the head state's epoch (the offset <= MIN_SEED_LOOKAHEAD check below), not the clock. Lighthouse's VC asks v2 for current_epoch and current_epoch + 1 on every poll (poll_beacon_proposers in duties_service.rs). From the start of epoch E until a block lands in E, the head state is still in E-1, so the E+1 request is a 400 and Lighthouse logs an error each poll; with several missed slots at the start of E, next-epoch duties stay unavailable that long.
Advancing the head state to the clock's epoch (as attestation_data does through fork_choice::checkpoint_state) would fix it. v1 has the same bound, so it isn't new, but v2 is the path Lighthouse uses.
| fn slot(self, epoch: Epoch) -> Slot { | ||
| let epoch = match self { | ||
| Self::V1 => epoch, | ||
| Self::V2 => epoch.saturating_sub(1), |
There was a problem hiding this comment.
Minor (one epoch per network): v2 always uses the post-Fulu rule. Lighthouse's proposer_shuffling_decision_slot (consensus/types/src/core/chain_spec.rs) is fork-aware: it uses start(epoch - 1) - 1 only when Fulu is active at epoch - 1, and the v1 rule otherwise. At the Fulu fork epoch F itself, the proposers were fixed by the upgrade from the end of epoch F-1, so v2 there equals v1.
With fulu_fork_epoch: 0 (the kurtosis configs) the two agree. With a later fork epoch, at epoch F this answers a different root than Lighthouse's BN, so a VC failing over between the two sees a spurious reorg, and a reorg of F-1's tail goes unnoticed. Checking the fork at epoch - 1, and using MIN_SEED_LOOKAHEAD instead of the literal 1, would match.
(This rule is fork-dependent and attester_duties' identical-looking formula is not, so they shouldn't be merged into one helper.)
| State(store): State<Store>, | ||
| Extension(sync_status): Extension<SyncStatusController>, | ||
| ) -> Response { | ||
| if sync_status.get() == SyncStatus::Syncing { |
There was a problem hiding this comment.
Minor: v2 is gated on sync status but v1 isn't, although both answer from the same head-state lookahead. If the gate matters, v1 (what ethlambda validator uses) needs it too; if it doesn't, v2 can drop it. Also, this if Syncing { 503 } block is now in three handlers (sync duties, liveness, v2 proposer duties); a reject_if_syncing helper or a route_layer on the gated routes would keep them identical.
| # | ||
| # This is how the Beacon API ethlambda beacon serves is checked against a | ||
| # validator client other than its own. In particular: | ||
| # - `/eth/v1/validator/liveness`: with doppelganger protection on, Lighthouse |
There was a problem hiding this comment.
Medium (tooling): as committed, this config can't exercise liveness. Lighthouse turns doppelganger protection off for validators registered in the genesis epoch (doppelganger_service/src/lib.rs: remaining_epochs = 0 when current_epoch <= genesis_epoch), and the VC starts with the network. Restarting the VC later to trigger it mutes all 128 validators, because the participants run validator_count: 0, so the chain stops. A split where the CL participants keep most of the keys and the Lighthouse VC holds a subset that it starts (or restarts) after epoch 0 would test it.
| # - `/eth/v1/validator/liveness`: with doppelganger protection on, Lighthouse | ||
| # keeps its validators muted until liveness has answered `false` for them | ||
| # for a couple of epochs, and forever if the endpoint is missing. | ||
| # - `/eth/v1/validator/duties/sync`: with 128 validators and a 512-seat sync |
There was a problem hiding this comment.
Nit: seats are drawn with replacement, so each of 128 validators has about a 1.8% chance ((127/128)^512) of holding none; typically one or two validators get no sync duty. Someone checking "every key gets a sync duty" against this would suspect the endpoint.
| plan.add_service( | ||
| name="vc-lighthouse", | ||
| config=ServiceConfig( | ||
| image=vc.get("lighthouse_image", "sigp/lighthouse:latest"), |
There was a problem hiding this comment.
Nit: sigp/lighthouse:latest floats. Pinning the version this was checked with (v8.2.3) keeps a future Lighthouse release from changing what this config tests without a change here.
| @@ -520,6 +579,49 @@ mod tests { | |||
| } | |||
|
|
|||
| /// What `ethlambda validator` sends: its keys, to learn their indices. | |||
There was a problem hiding this comment.
Nit: get_one was inserted under the existing doc comment, so "What ethlambda validator sends: its keys, to learn their indices." now documents the GET helper (which is what Lighthouse uses), and a_pubkey_resolves_to_its_index lost its comment.
| let (root, state) = load(store, state_id)?; | ||
| let index = match id { | ||
| ValidatorId::Index(index) => index, | ||
| ValidatorId::Pubkey(pubkey) => state |
There was a problem hiding this comment.
Nit (perf): each lookup by pubkey is a linear scan of the registry, run on a tokio worker. Lighthouse's VC calls this once per key at startup and again for keys it has no index for, so on a mainnet-sized registry that's one full scan per key. A pubkey-to-index map (or spawn_blocking) would help if this runs on a large network; fine for kurtosis.
| } | ||
| } | ||
|
|
||
| fn validator_response( |
There was a problem hiding this comment.
Nit: the entry building here (id match, ValidatorStatus::of, balance lookup, ValidatorEntry) repeats validators_response. Running the list selection with one id and returning its single match or a 404 would keep one copy.
| get(get_proposer_duties), | ||
| ) | ||
| .route( | ||
| "/eth/v2/validator/duties/proposer/{epoch}", |
There was a problem hiding this comment.
Nit: the module docs still say this file serves the endpoints under /eth/v1/validator/; it now serves /eth/v2/validator/duties/proposer too. Same for the header list in states.rs, which omits /fork (from #642) and /validators/{validator_id}.
🗒️ Description / Motivation
Lighthouse's validator client (v8.2.3) can't run on ethlambda beacon, because it needs two endpoints this node doesn't serve:
GET /eth/v1/beacon/states/{state_id}/validators/{validator_id}?id=andPOSTserved)GET /eth/v2/validator/duties/proposer/{epoch}The first one blocks the end-to-end check #642 and #643 list as still to do: Lighthouse's validator client with
--enable-doppelganger-protection. Its doppelganger service only callslivenessfor validators it has indices for.Found on a kurtosis devnet with Lighthouse's validator client talking only to ethlambda beacon. The VC logged
Validator without indexfor each of its 64 keys andAll validators inactiveevery slot.What Changed
crates/net/rpc/src/beacon/states.rs:GET /eth/v1/beacon/states/{state_id}/validators/{validator_id}. It returns one registry entry as an object, by index or by pubkey: the same entry the list endpoint returns, without the array.crates/net/rpc/src/beacon/validator.rs:GET /eth/v2/validator/duties/proposer/{epoch}. These are v1's duties with v2'sdependent_root. v1 and v2 share one handler, parameterized by the dependent-root rule.docs/rpc.md: both routes, and the v2dependent_rootrule.tooling/kurtosis-validator/:ethlambda_validator.client: lighthouserunslighthouse vcin place of ethlambda's validator client, on the same derived keys and against the same beacon node list.doppelganger: trueadds--enable-doppelganger-protection.lighthouse_extra_paramspasses any other flags through.network_params_lighthouse_vc.yamlruns it against ethlambda beacon alone, with doppelganger protection on.bin/ethlambda/src/main.rs:cargo fmt. A merge ofbeacon-chain-integrationleft the module list out of order, which is what fails Lint on feat(rpc): serve states/{id}/fork, config/deposit_contract and validator/duties/sync #642 and feat(rpc): serve validator/liveness #643.Correctness / Behavior Guarantees
validators/{validator_id}:404for a validator the state doesn't hold,400for a malformed id.state_idresolves the same way as on every other state endpoint.dependent_rootis the block root at the slot before the previous epoch's first slot, since fulu fixes proposers an epoch ahead. Where that would underflow, it's the genesis block root.503while syncing.Tests Added / Run
validators/{validator_id}:one_validator_by_pubkey_or_index_is_the_list_entryone_unknown_validator_is_a_404one_malformed_validator_id_is_a_400v2 proposer duties:
v2_proposer_duties_are_v1_sthe_v2_dependent_root_is_the_block_before_the_previous_epochthe_v2_dependent_root_is_genesis_where_it_would_underflowv2_proposer_duties_answer_503_while_syncingcargo test -p ethlambda-rpc --profile release-fast: 136 passed, 0 failed.Kurtosis devnet, Lighthouse v8.2.3's validator client talking only to ethlambda beacon (built from this branch). The setup:
--enable-doppelganger-protection. Withnetwork_params_lighthouse_vc.yaml's split, where the VC holds every key, the chain stops during the doppelganger wait (see below).validators/{validator_id})Validator without indexfor every keycurrent_epoch_proposers: 7; it proposed slots 3, 4, 29 and 30.dependent_rootmatches Lighthouse's BN for epochs 0 and 1duties/sync(feat(rpc): serve states/{id}/fork, config/deposit_contract and validator/duties/sync #642)Validator in sync committeefor 112 and 114liveness(feat(rpc): serve validator/liveness #643)Listening for doppelgangers(16), thenFound no doppelganger … further_checks_remaining: 0andDoppelganger detection complete: starting validator. The validators unmuted at slot 128 (epoch 4) and attested at once.livenessansweredfalsefor the muted keys andtruefor a participant'sFound on the devnet, not fixed here:
sync_committee_subscriptions,pool/sync_committeesor the contribution endpoints. Lighthouse logsUnable to publish sync committee messagesandNo aggregate contribution found(CRIT) every slot. This is the known gap feat(rpc): serve states/{id}/fork, config/deposit_contract and validator/duties/sync #642 describes.duties/attesterandduties/proposerbound the requested epoch by the head state's epoch, not the clock's (validator.rs, from the validator API work). At an epoch's first slot, before its block arrives, the next epoch's duties are a 400 (epoch is not within one epoch of the head state's). Lighthouse retries next slot and gets them, but it logs an ERROR every epoch. Lighthouse's BN advances the state instead; its own VCs logged no such failure.network_params_lighthouse_vc.yaml's split, where the VC holds all 128 keys, risks a stall: while doppelganger mutes the VC's validators, a run of more than 8 missed slots puts Lighthouse past its sync tolerance and every VC stops. An earlier run with a 64/64 split stopped at slot 55.400 slot is not after the head block. Harmless: slashing protection would refuse the second signature too.Related Issues / PRs
beacon-chain-integrationonce those merge.✅ Verification Checklist
make fmt— cleanmake lint(clippy with-D warnings) — cleanmake test(test-consensusplustest-node, atrelease-fast) — onlyethlambda-rpclocally (136 passed), the only crate with code changes; the full suite runs in CI