From e6e45b93e328c1a65b7d66c794e5aab76e9b8c48 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Sat, 26 Sep 2026 15:37:54 +0100 Subject: [PATCH 1/4] feat: add ptc state endpoint --- .../api/src/beacon/routes/beacon/state.ts | 30 ++++++++ .../api/test/unit/beacon/testData/beacon.ts | 7 ++ .../src/api/impl/beacon/state/index.ts | 29 ++++++- .../api/impl/beacon/state/getStatePtc.test.ts | 77 +++++++++++++++++++ 4 files changed, 142 insertions(+), 1 deletion(-) create mode 100644 packages/beacon-node/test/unit/api/impl/beacon/state/getStatePtc.test.ts diff --git a/packages/api/src/beacon/routes/beacon/state.ts b/packages/api/src/beacon/routes/beacon/state.ts index 6217606928d6..841e03b13e63 100644 --- a/packages/api/src/beacon/routes/beacon/state.ts +++ b/packages/api/src/beacon/routes/beacon/state.ts @@ -90,6 +90,10 @@ export const EpochSyncCommitteeResponseType = new ContainerType( }, {jsonCase: "eth2"} ); +export const PtcResponseType = new ContainerType({ + slot: ssz.Slot, + validators: ssz.gloas.PayloadTimelinessCommittee, +}); export const ValidatorResponseListType = ArrayOf(ValidatorResponseType); export const BuilderResponseListType = ArrayOf(BuilderResponseType); export const ValidatorIdentitiesType = ArrayOf(ValidatorIdentityType); @@ -103,6 +107,7 @@ export type BuilderResponse = ValueOf; export type EpochCommitteeResponse = ValueOf; export type ValidatorBalance = ValueOf; export type EpochSyncCommitteeResponse = ValueOf; +export type PtcResponse = ValueOf; export type ValidatorResponseList = ValueOf; export type BuilderResponseList = ValueOf; @@ -315,6 +320,14 @@ export type Endpoints = { ExecutionOptimisticAndFinalizedMeta >; + getStatePtc: Endpoint< + "GET", + StateArgs & {slot?: Slot}, + {params: {state_id: string}; query: {slot?: number}}, + PtcResponse, + ExecutionOptimisticAndFinalizedMeta + >; + /** * Get State Pending Deposits * @@ -417,6 +430,23 @@ export function getDefinitions(_config: ChainForkConfig): RouteDefinitions ({params: {state_id: stateId.toString()}, query: {slot}}), + parseReq: ({params, query}) => ({stateId: params.state_id, slot: query.slot}), + schema: { + params: {state_id: Schema.StringRequired}, + query: {slot: Schema.Uint}, + }, + }, + resp: { + onlySupport: WireFormat.json, + data: PtcResponseType, + meta: ExecutionOptimisticAndFinalizedCodec, + }, + }, getStateFinalityCheckpoints: { url: "/eth/v1/beacon/states/{state_id}/finality_checkpoints", method: "GET", diff --git a/packages/api/test/unit/beacon/testData/beacon.ts b/packages/api/test/unit/beacon/testData/beacon.ts index 94d92e7fd5f0..8af8b8a34347 100644 --- a/packages/api/test/unit/beacon/testData/beacon.ts +++ b/packages/api/test/unit/beacon/testData/beacon.ts @@ -241,6 +241,13 @@ export const testData: GenericServerTestCases = { meta: {executionOptimistic: true, finalized: false}, }, }, + getStatePtc: { + args: {stateId: "head", slot: 2}, + res: { + data: {slot: 2, validators: ssz.gloas.PayloadTimelinessCommittee.defaultValue()}, + meta: {executionOptimistic: true, finalized: false}, + }, + }, getPendingDeposits: { args: {stateId: "head"}, res: { diff --git a/packages/beacon-node/src/api/impl/beacon/state/index.ts b/packages/beacon-node/src/api/impl/beacon/state/index.ts index 2128c700a4cc..7c08b0d270af 100644 --- a/packages/beacon-node/src/api/impl/beacon/state/index.ts +++ b/packages/beacon-node/src/api/impl/beacon/state/index.ts @@ -1,6 +1,11 @@ import {routes} from "@lodestar/api"; import {ApplicationMethods} from "@lodestar/api/server"; -import {EPOCHS_PER_HISTORICAL_VECTOR, SLOTS_PER_EPOCH, SYNC_COMMITTEE_SUBNET_SIZE} from "@lodestar/params"; +import { + EPOCHS_PER_HISTORICAL_VECTOR, + MIN_SEED_LOOKAHEAD, + SLOTS_PER_EPOCH, + SYNC_COMMITTEE_SUBNET_SIZE, +} from "@lodestar/params"; import { IBeaconStateView, computeEpochAtSlot, @@ -374,6 +379,28 @@ export function getBeaconStateApi({ }; }, + async getStatePtc({stateId, slot}) { + const {state, executionOptimistic, finalized} = await getState(stateId); + if (!isStatePostGloas(state)) { + throw new ApiError(400, `Cannot retrieve PTC for pre-gloas state fork=${state.forkName}`); + } + + const usedSlot = slot ?? state.slot; + const epoch = computeEpochAtSlot(usedSlot); + const stateEpoch = computeEpochAtSlot(state.slot); + if (epoch < config.GLOAS_FORK_EPOCH) { + throw new ApiError(400, "Cannot retrieve PTC for pre-gloas slot"); + } + if (epoch < stateEpoch - 1 || epoch > stateEpoch + MIN_SEED_LOOKAHEAD) { + throw new ApiError(400, "Slot is outside the PTC window of the state"); + } + + return { + data: {slot: usedSlot, validators: Array.from(state.getPayloadTimelinessCommittee(usedSlot))}, + meta: {executionOptimistic, finalized}, + }; + }, + async getPendingDeposits({stateId}, context) { const {state, executionOptimistic, finalized} = await getState(stateId); const fork = state.forkName; diff --git a/packages/beacon-node/test/unit/api/impl/beacon/state/getStatePtc.test.ts b/packages/beacon-node/test/unit/api/impl/beacon/state/getStatePtc.test.ts new file mode 100644 index 000000000000..07f7e6f5e827 --- /dev/null +++ b/packages/beacon-node/test/unit/api/impl/beacon/state/getStatePtc.test.ts @@ -0,0 +1,77 @@ +import {afterEach, beforeEach, describe, expect, it, vi} from "vitest"; +import {pubkeyCache} from "@chainsafe/lodestar-z/pubkeys"; +import {createBeaconConfig} from "@lodestar/config"; +import {getConfig} from "@lodestar/config/test-utils"; +import {ForkName, MIN_SEED_LOOKAHEAD, SLOTS_PER_EPOCH} from "@lodestar/params"; +import {BeaconStateGloas, BeaconStateView, createCachedBeaconState} from "@lodestar/state-transition"; +import {ssz} from "@lodestar/types"; +import {getBeaconStateApi} from "../../../../../../src/api/impl/beacon/state/index.js"; +import * as stateUtils from "../../../../../../src/api/impl/beacon/state/utils.js"; +import {ApiTestModules, getApiTestModules} from "../../../../../utils/api.js"; +import {generateCachedElectraState, generateState} from "../../../../../utils/state.js"; + +describe("getStatePtc", () => { + const stateSlot = 3 * SLOTS_PER_EPOCH + 7; + const firstSlot = 2 * SLOTS_PER_EPOCH; + const lastSlot = (4 + MIN_SEED_LOOKAHEAD) * SLOTS_PER_EPOCH - 1; + const stateId = `0x${"12".repeat(32)}`; + const validators = ssz.gloas.PayloadTimelinessCommittee.defaultValue(); + validators.splice(0, 3, 7, 2, 7); + let modules: ApiTestModules; + let api: ReturnType; + + beforeEach(() => { + const config = getConfig(ForkName.gloas, 1); + const state = generateState({slot: stateSlot}, config, true) as BeaconStateGloas; + state.ptcWindow.set( + SLOTS_PER_EPOCH + (stateSlot % SLOTS_PER_EPOCH), + ssz.gloas.PayloadTimelinessCommittee.toViewDU(validators) + ); + const view = new BeaconStateView( + createCachedBeaconState(state, {config: createBeaconConfig(config, state.genesisValidatorsRoot), pubkeyCache}) + ); + modules = getApiTestModules({config}); + api = getBeaconStateApi(modules); + vi.spyOn(stateUtils, "getStateResponseWithRegen").mockResolvedValue({ + state: view, + executionOptimistic: true, + finalized: false, + }); + }); + + afterEach(() => vi.restoreAllMocks()); + + it("defaults to the selected state's slot and preserves committee order and duplicates", async () => { + expect(await api.getStatePtc({stateId})).toEqual({ + data: {slot: stateSlot, validators}, + meta: {executionOptimistic: true, finalized: false}, + }); + expect(stateUtils.getStateResponseWithRegen).toHaveBeenCalledWith(modules.chain, modules.sync, stateId); + }); + + it.each([firstSlot, stateSlot + 1, lastSlot])("serves slot %i within the state's PTC window", async (slot) => { + const {data} = await api.getStatePtc({stateId, slot}); + expect(data).toEqual({slot, validators: ssz.gloas.PayloadTimelinessCommittee.defaultValue()}); + }); + + it.each([firstSlot - 1, lastSlot + 1])("rejects slot %i outside the state's PTC window", async (slot) => { + await expect(api.getStatePtc({stateId, slot})).rejects.toMatchObject({ + statusCode: 400, + message: "Slot is outside the PTC window of the state", + }); + }); + + it("rejects pre-Gloas slots even within the state's PTC window", async () => { + modules.config.GLOAS_FORK_EPOCH = 3; + await expect(api.getStatePtc({stateId, slot: firstSlot})).rejects.toMatchObject({statusCode: 400}); + }); + + it("rejects pre-Gloas states", async () => { + vi.mocked(stateUtils.getStateResponseWithRegen).mockResolvedValue({ + state: new BeaconStateView(generateCachedElectraState()), + executionOptimistic: false, + finalized: true, + }); + await expect(api.getStatePtc({stateId})).rejects.toMatchObject({statusCode: 400}); + }); +}); From a784f55feade5639840839e391f44ce022204a0e Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Wed, 7 Oct 2026 18:04:36 +0100 Subject: [PATCH 2/4] fix: include slot and state epoch in ptc errors --- .../src/api/impl/beacon/state/index.ts | 4 +-- .../api/impl/beacon/state/getStatePtc.test.ts | 26 +++++++++++++++++-- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/packages/beacon-node/src/api/impl/beacon/state/index.ts b/packages/beacon-node/src/api/impl/beacon/state/index.ts index 1d7712df4137..5b6c693bb543 100644 --- a/packages/beacon-node/src/api/impl/beacon/state/index.ts +++ b/packages/beacon-node/src/api/impl/beacon/state/index.ts @@ -389,10 +389,10 @@ export function getBeaconStateApi({ const epoch = computeEpochAtSlot(usedSlot); const stateEpoch = computeEpochAtSlot(state.slot); if (epoch < config.GLOAS_FORK_EPOCH) { - throw new ApiError(400, "Cannot retrieve PTC for pre-gloas slot"); + throw new ApiError(400, `Cannot retrieve PTC for pre-gloas slot=${usedSlot}`); } if (epoch < stateEpoch - 1 || epoch > stateEpoch + MIN_SEED_LOOKAHEAD) { - throw new ApiError(400, "Slot is outside the PTC window of the state"); + throw new ApiError(400, `Slot ${usedSlot} is outside the PTC window of state epoch ${stateEpoch}`); } return { diff --git a/packages/beacon-node/test/unit/api/impl/beacon/state/getStatePtc.test.ts b/packages/beacon-node/test/unit/api/impl/beacon/state/getStatePtc.test.ts index 07f7e6f5e827..6feb3ebb2a82 100644 --- a/packages/beacon-node/test/unit/api/impl/beacon/state/getStatePtc.test.ts +++ b/packages/beacon-node/test/unit/api/impl/beacon/state/getStatePtc.test.ts @@ -57,13 +57,35 @@ describe("getStatePtc", () => { it.each([firstSlot - 1, lastSlot + 1])("rejects slot %i outside the state's PTC window", async (slot) => { await expect(api.getStatePtc({stateId, slot})).rejects.toMatchObject({ statusCode: 400, - message: "Slot is outside the PTC window of the state", + message: `Slot ${slot} is outside the PTC window of state epoch 3`, }); }); it("rejects pre-Gloas slots even within the state's PTC window", async () => { modules.config.GLOAS_FORK_EPOCH = 3; - await expect(api.getStatePtc({stateId, slot: firstSlot})).rejects.toMatchObject({statusCode: 400}); + await expect(api.getStatePtc({stateId, slot: firstSlot})).rejects.toMatchObject({ + statusCode: 400, + message: `Cannot retrieve PTC for pre-gloas slot=${firstSlot}`, + }); + }); + + it("serves the genesis slot when Gloas starts at genesis", async () => { + const config = getConfig(ForkName.gloas, 0); + const state = generateState({slot: 0}, config, true) as BeaconStateGloas; + state.ptcWindow.set(SLOTS_PER_EPOCH, ssz.gloas.PayloadTimelinessCommittee.toViewDU(validators)); + const view = new BeaconStateView( + createCachedBeaconState(state, {config: createBeaconConfig(config, state.genesisValidatorsRoot), pubkeyCache}) + ); + vi.mocked(stateUtils.getStateResponseWithRegen).mockResolvedValue({ + state: view, + executionOptimistic: false, + finalized: true, + }); + const genesisApi = getBeaconStateApi(getApiTestModules({config})); + expect(await genesisApi.getStatePtc({stateId, slot: 0})).toEqual({ + data: {slot: 0, validators}, + meta: {executionOptimistic: false, finalized: true}, + }); }); it("rejects pre-Gloas states", async () => { From 78f365a3ce6f0cbe78593c224b88e4560816fcc6 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Wed, 7 Oct 2026 18:10:58 +0100 Subject: [PATCH 3/4] docs: document ptc state endpoint --- packages/api/src/beacon/routes/beacon/state.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/api/src/beacon/routes/beacon/state.ts b/packages/api/src/beacon/routes/beacon/state.ts index c45ba310b012..aed91d2e1dc2 100644 --- a/packages/api/src/beacon/routes/beacon/state.ts +++ b/packages/api/src/beacon/routes/beacon/state.ts @@ -328,6 +328,11 @@ export type Endpoints = { ExecutionOptimisticAndFinalizedMeta >; + /** + * Get the payload timeliness committee for a slot from the given state. + * + * Defaults to the state's slot. Validator indices are returned in committee order and may contain duplicates. + */ getStatePtc: Endpoint< "GET", StateArgs & {slot?: Slot}, From bfc01ebd97f4e5231d3ab1e1d2d1bf1f8f4f9de3 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Wed, 7 Oct 2026 18:21:20 +0100 Subject: [PATCH 4/4] feat: support ssz responses for ptc state endpoint --- packages/api/src/beacon/routes/beacon/state.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/api/src/beacon/routes/beacon/state.ts b/packages/api/src/beacon/routes/beacon/state.ts index aed91d2e1dc2..0f57f49f25c5 100644 --- a/packages/api/src/beacon/routes/beacon/state.ts +++ b/packages/api/src/beacon/routes/beacon/state.ts @@ -481,7 +481,6 @@ export function getDefinitions(_config: ChainForkConfig): RouteDefinitions