feat: add proposer duties v2 endpoint (#8597)

Adds proposer duties v2 endpoint which works the same as v1 but uses
previous epoch to determine dependent root to account for deterministic
proposer lookahead changes in fulu.

https://github.com/ethereum/beacon-APIs/pull/563
This commit is contained in:
Nico Flaig
2025-11-04 21:33:18 +00:00
committed by GitHub
parent 6d7c41db1a
commit 983ef10850
4 changed files with 65 additions and 6 deletions

View File

@@ -285,6 +285,33 @@ export type Endpoints = {
ExecutionOptimisticAndDependentRootMeta
>;
/**
* Get block proposers duties
* Request beacon node to provide all validators that are scheduled to propose a block in the given epoch.
* Duties should only need to be checked once per epoch, however a chain reorganization could occur that results in a change of duties.
* For full safety, you should monitor head events and confirm the dependent root in this response matches. After Fulu, different checks
* need to be performed as the dependent root changes due to deterministic proposer lookahead.
*
* Before Fulu:
* - event.current_duty_dependent_root when `compute_epoch_at_slot(event.slot) == epoch`
* - event.block otherwise
* - dependent_root value is `get_block_root_at_slot(state, compute_start_slot_at_epoch(epoch) - 1)`
*
* After Fulu:
* - event.previous_duty_dependent_root when `compute_epoch_at_slot(event.slot) == epoch`
* - event.block otherwise
* - dependent_root value is `get_block_root_at_slot(state, compute_start_slot_at_epoch(epoch - 1) - 1)`
*
* The dependent_root value is the genesis block root in the case of underflow."
*/
getProposerDutiesV2: Endpoint<
"GET",
{epoch: Epoch},
{params: {epoch: Epoch}},
ProposerDutyList,
ExecutionOptimisticAndDependentRootMeta
>;
getSyncCommitteeDuties: Endpoint<
"POST",
{
@@ -565,6 +592,21 @@ export function getDefinitions(config: ChainForkConfig): RouteDefinitions<Endpoi
meta: ExecutionOptimisticAndDependentRootCodec,
},
},
getProposerDutiesV2: {
url: "/eth/v2/validator/duties/proposer/{epoch}",
method: "GET",
req: {
writeReq: ({epoch}) => ({params: {epoch}}),
parseReq: ({params}) => ({epoch: params.epoch}),
schema: {
params: {epoch: Schema.UintRequired},
},
},
resp: {
data: ProposerDutyListType,
meta: ExecutionOptimisticAndDependentRootCodec,
},
},
getSyncCommitteeDuties: {
url: "/eth/v1/validator/duties/sync/{epoch}",
method: "POST",

View File

@@ -35,6 +35,13 @@ export const testData: GenericServerTestCases<Endpoints> = {
meta: {executionOptimistic: true, dependentRoot: ZERO_HASH_HEX},
},
},
getProposerDutiesV2: {
args: {epoch: 1000},
res: {
data: [{slot: 1, validatorIndex: 2, pubkey: new Uint8Array(48).fill(3)}],
meta: {executionOptimistic: true, dependentRoot: ZERO_HASH_HEX},
},
},
getSyncCommitteeDuties: {
args: {epoch: 1000, indices: [1, 2, 3]},
res: {

View File

@@ -1002,7 +1002,7 @@ export function getValidatorApi(
return {data: contribution};
},
async getProposerDuties({epoch}) {
async getProposerDuties({epoch}, _context, opts?: {v2?: boolean}) {
notWhileSyncing();
// Early check that epoch is no more than current_epoch + 1, or allow for pre-genesis
@@ -1106,7 +1106,10 @@ export function getValidatorApi(
// Returns `null` on the one-off scenario where the genesis block decides its own shuffling.
// It should be set to the latest block applied to `self` or the genesis block root.
const dependentRoot = proposerShufflingDecisionRoot(state) || (await getGenesisBlockRoot(state));
const dependentRoot =
// In v2 the dependent root is different after fulu due to deterministic proposer lookahead
proposerShufflingDecisionRoot(opts?.v2 ? config.getForkName(startSlot) : ForkName.phase0, state) ||
(await getGenesisBlockRoot(state));
return {
data: duties,
@@ -1117,6 +1120,10 @@ export function getValidatorApi(
};
},
async getProposerDutiesV2(args, context) {
return this.getProposerDuties(args, context, {v2: true});
},
async getAttesterDuties({epoch, indices}) {
notWhileSyncing();

View File

@@ -1,3 +1,4 @@
import {ForkName, isForkPostFulu} from "@lodestar/params";
import {Epoch, Root, Slot} from "@lodestar/types";
import {CachedBeaconStateAllForks} from "../types.js";
import {getBlockRootAtSlot} from "./blockRoot.js";
@@ -10,8 +11,8 @@ import {computeStartSlotAtEpoch} from "./epoch.js";
* Returns `null` on the one-off scenario where the genesis block decides its own shuffling.
* It should be set to the latest block applied to this `state` or the genesis block root.
*/
export function proposerShufflingDecisionRoot(state: CachedBeaconStateAllForks): Root | null {
const decisionSlot = proposerShufflingDecisionSlot(state);
export function proposerShufflingDecisionRoot(fork: ForkName, state: CachedBeaconStateAllForks): Root | null {
const decisionSlot = proposerShufflingDecisionSlot(fork, state);
if (state.slot === decisionSlot) {
return null;
}
@@ -22,8 +23,10 @@ export function proposerShufflingDecisionRoot(state: CachedBeaconStateAllForks):
* Returns the slot at which the proposer shuffling was decided. The block root at this slot
* can be used to key the proposer shuffling for the current epoch.
*/
function proposerShufflingDecisionSlot(state: CachedBeaconStateAllForks): Slot {
const startSlot = computeStartSlotAtEpoch(state.epochCtx.epoch);
function proposerShufflingDecisionSlot(fork: ForkName, state: CachedBeaconStateAllForks): Slot {
// After fulu, the decision slot is in previous epoch due to deterministic proposer lookahead
const epoch = isForkPostFulu(fork) ? state.epochCtx.epoch - 1 : state.epochCtx.epoch;
const startSlot = computeStartSlotAtEpoch(epoch);
return Math.max(startSlot - 1, 0);
}