Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions packages/api/src/beacon/routes/beacon/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,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);
Expand All @@ -111,6 +115,7 @@ export type BuilderResponse = ValueOf<typeof BuilderResponseType>;
export type EpochCommitteeResponse = ValueOf<typeof EpochCommitteeResponseType>;
export type ValidatorBalance = ValueOf<typeof ValidatorBalanceType>;
export type EpochSyncCommitteeResponse = ValueOf<typeof EpochSyncCommitteeResponseType>;
export type PtcResponse = ValueOf<typeof PtcResponseType>;

export type ValidatorResponseList = ValueOf<typeof ValidatorResponseListType>;
export type BuilderResponseList = ValueOf<typeof BuilderResponseListType>;
Expand Down Expand Up @@ -323,6 +328,19 @@ 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},
{params: {state_id: string}; query: {slot?: number}},
PtcResponse,
ExecutionOptimisticAndFinalizedMeta
>;

/**
* Get State Pending Deposits
*
Expand Down Expand Up @@ -451,6 +469,22 @@ export function getDefinitions(_config: ChainForkConfig): RouteDefinitions<Endpo
meta: ExecutionOptimisticAndFinalizedCodec,
},
},
getStatePtc: {
url: "/eth/v1/beacon/states/{state_id}/ptc",
method: "GET",
req: {
writeReq: ({stateId, slot}) => ({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: {
data: PtcResponseType,
meta: ExecutionOptimisticAndFinalizedCodec,
},
},
getStateFinalityCheckpoints: {
url: "/eth/v1/beacon/states/{state_id}/finality_checkpoints",
method: "GET",
Expand Down
7 changes: 7 additions & 0 deletions packages/api/test/unit/beacon/testData/beacon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,13 @@ export const testData: GenericServerTestCases<Endpoints> = {
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: {
Expand Down
29 changes: 28 additions & 1 deletion packages/beacon-node/src/api/impl/beacon/state/index.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -375,6 +380,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=${usedSlot}`);
}
if (epoch < stateEpoch - 1 || epoch > stateEpoch + MIN_SEED_LOOKAHEAD) {
throw new ApiError(400, `Slot ${usedSlot} is outside the PTC window of state epoch ${stateEpoch}`);
}

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;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
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<typeof getBeaconStateApi>;

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 ${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,
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 () => {
vi.mocked(stateUtils.getStateResponseWithRegen).mockResolvedValue({
state: new BeaconStateView(generateCachedElectraState()),
executionOptimistic: false,
finalized: true,
});
await expect(api.getStatePtc({stateId})).rejects.toMatchObject({statusCode: 400});
});
});
Loading