🎯 Problem to be solved
From the Gloas fork onwards, prepare_beacon_proposer and register_validator are superseded by validator-signed SignedProposerPreferences, gossiped on the proposer_preferences topic, as the source of proposer fee_recipient and target_gas_limit. For a distributed validator this becomes a new ahead-of-time signed duty: aggregate the VCs' partial signatures and gossip the reconstructed signature before the proposal slot.
Sub-task of the gloas epic #4324 (Block production / ePBS). Gated upstream on attestantio/go-eth2-client#316 (submit proposer preferences).
🛠️ Proposed solution
Treat proposer preferences like sync committee messages: ungated (no consensus round), accept the VC's partial signature, aggregate on threshold, broadcast — returning 2XX to the VC on submission without blocking for threshold.
Key design facts (spec + Lighthouse impl)
- Signed message:
ProposerPreferences{dependent_root, proposal_slot, validator_index, fee_recipient, target_gas_limit}, domain DOMAIN_PROPOSER_PREFERENCES at proposal_epoch. There is no wall-clock timestamp — unlike ValidatorRegistrationV1.timestamp (the field that forces us to pre-sign registrations and ignore VC ones), so the VCs' partial signatures are aggregatable.
- Only path is aggregating VC partials at runtime. Charon can't self-sign (no key material in the running process) and can't pre-sign offline (
proposal_slot + dependent_root are live consensus values).
dependent_root = shuffling anchor, compute_shuffling_dependent_slot(E) = start_slot(E-1)-1 = last block of epoch E-2 (MIN_SEED_LOOKAHEAD=1). It points ~1–2 epochs into the past, so it's stable and agreed cluster-wide except on a pathological deep reorg. Charon serves it via the v2 proposer-duties response, so the cluster converges on identical messages by construction.
- Ahead-of-time, not at-trigger. Broadcast window is the current epoch up to
MIN_SEED_LOOKAHEAD epochs ahead; gossip validation [IGNORE] The proposal slot has not started yet. Missing it means the validator "will not accept any trustless bids for that slot" → self-build → lost MEV.
- VC data source = v2 proposer duties (
apis/validator/duties/proposer.v2.yaml; v1 proposer.yaml is deprecated). v2 dependent_root = get_block_root_at_slot(state, start_slot(epoch-1)-1) = last of E-2 = exactly the anchor the preference needs (v1 used start_slot(epoch)-1 = E-1, wrong here). Confirmed in Lighthouse validator_services/src/proposer_preferences_service.rs (v8.2.0): reads (dependent_root, duties) from the proposer-duties cache, copies dependent_root verbatim, signs per-duty, POSTs SSZ to one BN; dedup republishes only when dependent_root changes.
- Aggregation identity includes
dependent_root. On reorg it changes and VCs resubmit, so ParSigDB keying must treat a changed dependent_root for a proposal_slot as a fresh aggregation, not a collision with the stale one.
Content consistency (fee_recipient / gas_limit)
The VC signs fee_recipient and target_gas_limit from its own local config, not from the lock (Charon has no BN→VC channel for these). For the partials to aggregate they must match cluster-wide. Rather than a hard reject (which fragments intake during a staggered gas_limit bump — each node validates against its own lock), warn + emit a metric on mismatch vs the lock value and aggregate whatever reaches threshold (majority wins, self-heals). Getting the correct values onto the VCs in the first place is #4692.
Tasks
🔗 References
🎯 Problem to be solved
From the Gloas fork onwards,
prepare_beacon_proposerandregister_validatorare superseded by validator-signedSignedProposerPreferences, gossiped on theproposer_preferencestopic, as the source of proposerfee_recipientandtarget_gas_limit. For a distributed validator this becomes a new ahead-of-time signed duty: aggregate the VCs' partial signatures and gossip the reconstructed signature before the proposal slot.Sub-task of the gloas epic #4324 (Block production / ePBS). Gated upstream on attestantio/go-eth2-client#316 (submit proposer preferences).
🛠️ Proposed solution
Treat proposer preferences like sync committee messages: ungated (no consensus round), accept the VC's partial signature, aggregate on threshold, broadcast — returning
2XXto the VC on submission without blocking for threshold.Key design facts (spec + Lighthouse impl)
ProposerPreferences{dependent_root, proposal_slot, validator_index, fee_recipient, target_gas_limit}, domainDOMAIN_PROPOSER_PREFERENCESatproposal_epoch. There is no wall-clock timestamp — unlikeValidatorRegistrationV1.timestamp(the field that forces us to pre-sign registrations and ignore VC ones), so the VCs' partial signatures are aggregatable.proposal_slot+dependent_rootare live consensus values).dependent_root= shuffling anchor,compute_shuffling_dependent_slot(E) = start_slot(E-1)-1= last block of epochE-2(MIN_SEED_LOOKAHEAD=1). It points ~1–2 epochs into the past, so it's stable and agreed cluster-wide except on a pathological deep reorg. Charon serves it via the v2 proposer-duties response, so the cluster converges on identical messages by construction.MIN_SEED_LOOKAHEADepochs ahead; gossip validation[IGNORE] The proposal slot has not started yet. Missing it means the validator "will not accept any trustless bids for that slot" → self-build → lost MEV.apis/validator/duties/proposer.v2.yaml; v1proposer.yamlis deprecated). v2dependent_root = get_block_root_at_slot(state, start_slot(epoch-1)-1)= last ofE-2= exactly the anchor the preference needs (v1 usedstart_slot(epoch)-1=E-1, wrong here). Confirmed in Lighthousevalidator_services/src/proposer_preferences_service.rs(v8.2.0): reads(dependent_root, duties)from the proposer-duties cache, copiesdependent_rootverbatim, signs per-duty,POSTs SSZ to one BN; dedup republishes only whendependent_rootchanges.dependent_root. On reorg it changes and VCs resubmit, so ParSigDB keying must treat a changeddependent_rootfor aproposal_slotas a fresh aggregation, not a collision with the stale one.Content consistency (
fee_recipient/gas_limit)The VC signs
fee_recipientandtarget_gas_limitfrom its own local config, not from the lock (Charon has no BN→VC channel for these). For the partials to aggregate they must match cluster-wide. Rather than a hard reject (which fragments intake during a staggeredgas_limitbump — each node validates against its own lock), warn + emit a metric on mismatch vs the lock value and aggregate whatever reaches threshold (majority wins, self-heals). Getting the correct values onto the VCs in the first place is #4692.Tasks
go-eth2-clientproposer-preferences interfaces (gated on feat: submit Gloas proposer preferences attestantio/go-eth2-client#316)DutyProposerPreferencesduty type +DOMAIN_PROPOSER_PREFERENCESsigning domain(Versioned)ProposerPreferencestype +NewPartialSignedProposerPreferences(SSZ/JSON/proto encode-decode, parsig wire carries version)GET /eth/v2/validator/duties/proposer/{epoch}) with theE-2shufflingdependent_rootso cluster VCs sign identical messagesvalidatorapi.SubmitProposerPreferencesintake — sync-message shaped: verify partial sig, group, fan out to subscribers, return2XXnon-blocking(validator, proposal_slot, dependent_root); treat a changeddependent_root(reorg) as a fresh aggregationSignedProposerPreferencesto the BN (→ gossip) ahead of the slotfee_recipient/target_gas_limitdiffer from the lock value (do not reject)🔗 References
get_signed_proposer_preferences,get_upcoming_proposal_slots)get_shuffling_dependent_root/compute_shuffling_dependent_slot