From e923627cb8b6a95444ce15225d08a1f6aa760f4b Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Sun, 5 Jul 2026 10:33:11 +0100 Subject: [PATCH 1/2] feat: add getStateBuilders endpoint --- .../api/src/beacon/routes/beacon/index.ts | 2 + .../api/src/beacon/routes/beacon/state.ts | 62 ++++++++++++++++++- .../api/test/unit/beacon/testData/beacon.ts | 7 +++ .../src/api/impl/beacon/state/index.ts | 53 +++++++++++++++- .../src/api/impl/beacon/state/utils.ts | 59 +++++++++++++++++- .../src/stateView/beaconStateView.ts | 8 +++ .../src/stateView/interface.ts | 1 + .../src/stateView/nativeBeaconStateView.ts | 8 +++ packages/types/src/utils/validatorStatus.ts | 21 ++++++- 9 files changed, 215 insertions(+), 6 deletions(-) diff --git a/packages/api/src/beacon/routes/beacon/index.ts b/packages/api/src/beacon/routes/beacon/index.ts index 2e0326b3e13a..9c064ddde333 100644 --- a/packages/api/src/beacon/routes/beacon/index.ts +++ b/packages/api/src/beacon/routes/beacon/index.ts @@ -15,6 +15,8 @@ export type {BlockHeaderResponse, BlockId} from "./block.js"; export {BroadcastValidation} from "./block.js"; // TODO: Review if re-exporting all these types is necessary export type { + BuilderResponse, + BuilderStatus, EpochCommitteeResponse, EpochSyncCommitteeResponse, FinalityCheckpoints, diff --git a/packages/api/src/beacon/routes/beacon/state.ts b/packages/api/src/beacon/routes/beacon/state.ts index 79c82b7935c8..ac067c4ed11e 100644 --- a/packages/api/src/beacon/routes/beacon/state.ts +++ b/packages/api/src/beacon/routes/beacon/state.ts @@ -3,6 +3,7 @@ import {ChainForkConfig} from "@lodestar/config"; import {MAX_VALIDATORS_PER_COMMITTEE} from "@lodestar/params"; import { ArrayOf, + BuilderStatus, CommitteeIndex, Epoch, RootHex, @@ -38,7 +39,7 @@ export type StateArgs = { export type ValidatorId = string | number; -export type {ValidatorStatus}; +export type {BuilderStatus, ValidatorStatus}; export const RandaoResponseType = new ContainerType({ randao: ssz.Root, @@ -57,6 +58,11 @@ export const ValidatorResponseType = new ContainerType({ status: new StringType(), validator: ssz.phase0.Validator, }); +export const BuilderResponseType = new ContainerType({ + index: ssz.BuilderIndex, + status: new StringType(), + builder: ssz.gloas.Builder, +}); export const ValidatorIdentityType = new ContainerType( { index: ssz.ValidatorIndex, @@ -84,6 +90,7 @@ export const EpochSyncCommitteeResponseType = new ContainerType( {jsonCase: "eth2"} ); export const ValidatorResponseListType = ArrayOf(ValidatorResponseType); +export const BuilderResponseListType = ArrayOf(BuilderResponseType); export const ValidatorIdentitiesType = ArrayOf(ValidatorIdentityType); export const EpochCommitteeResponseListType = ArrayOf(EpochCommitteeResponseType); export const ValidatorBalanceListType = ArrayOf(ValidatorBalanceType); @@ -91,11 +98,13 @@ export const ValidatorBalanceListType = ArrayOf(ValidatorBalanceType); export type RandaoResponse = ValueOf; export type FinalityCheckpoints = ValueOf; export type ValidatorResponse = ValueOf; +export type BuilderResponse = ValueOf; export type EpochCommitteeResponse = ValueOf; export type ValidatorBalance = ValueOf; export type EpochSyncCommitteeResponse = ValueOf; export type ValidatorResponseList = ValueOf; +export type BuilderResponseList = ValueOf; export type ValidatorIdentities = ValueOf; export type EpochCommitteeResponseList = ValueOf; export type ValidatorBalanceList = ValueOf; @@ -204,6 +213,30 @@ export type Endpoints = { ExecutionOptimisticAndFinalizedMeta >; + /** + * Get builders from state + * + * Returns filterable list of builders with their status and index. + * + * Information will be returned for all indices or public keys that match known builders. If an index or public key does not + * match any known builder, no information will be returned but this will not cause an error. There are no guarantees for the + * returned data in terms of ordering; both the index and public key are returned for each builder, and can be used to confirm + * for which inputs a response has been returned. + * + * Returns 400 if the requested state is prior to Gloas. + */ + getStateBuilders: Endpoint< + "POST", + StateArgs & { + /** Either hex encoded public key (any bytes48 with 0x prefix) or builder index */ + builderIds?: ValidatorId[]; + statuses?: BuilderStatus[]; + }, + {params: {state_id: string}; body: {ids?: string[]; statuses?: BuilderStatus[]}}, + BuilderResponseList, + ExecutionOptimisticAndFinalizedMeta + >; + /** * Get validator identities from state * @@ -489,6 +522,33 @@ export function getDefinitions(_config: ChainForkConfig): RouteDefinitions ({ + params: {state_id: stateId.toString()}, + body: { + ids: toValidatorIdsStr(builderIds), + statuses, + }, + }), + parseReqJson: ({params, body = {}}) => ({ + stateId: params.state_id, + builderIds: fromValidatorIdsStr(body.ids), + statuses: body.statuses ?? undefined, + }), + schema: { + params: {state_id: Schema.StringRequired}, + body: Schema.Object, + }, + }), + resp: { + onlySupport: WireFormat.json, + data: BuilderResponseListType, + meta: ExecutionOptimisticAndFinalizedCodec, + }, + }, postStateValidatorIdentities: { url: "/eth/v1/beacon/states/{state_id}/validator_identities", method: "POST", diff --git a/packages/api/test/unit/beacon/testData/beacon.ts b/packages/api/test/unit/beacon/testData/beacon.ts index 0d8bcf5613c1..17bc1eeb20a4 100644 --- a/packages/api/test/unit/beacon/testData/beacon.ts +++ b/packages/api/test/unit/beacon/testData/beacon.ts @@ -205,6 +205,13 @@ export const testData: GenericServerTestCases = { args: {stateId: "head", validatorIds: [pubkeyHex, 1300], statuses: ["active_ongoing"]}, res: {data: [validatorResponse], meta: {executionOptimistic: true, finalized: false}}, }, + getStateBuilders: { + args: {stateId: "head", builderIds: [pubkeyHex, 32], statuses: ["active"]}, + res: { + data: [{index: 32, status: "active", builder: ssz.gloas.Builder.defaultValue()}], + meta: {executionOptimistic: true, finalized: false}, + }, + }, postStateValidatorIdentities: { args: {stateId: "head", validatorIds: [1300]}, 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 85bb748e417b..4b801d3532ad 100644 --- a/packages/beacon-node/src/api/impl/beacon/state/index.ts +++ b/packages/beacon-node/src/api/impl/beacon/state/index.ts @@ -9,13 +9,15 @@ import { isStatePostAltair, isStatePostElectra, isStatePostFulu, + isStatePostGloas, } from "@lodestar/state-transition"; -import {ValidatorIndex, getValidatorStatus, ssz} from "@lodestar/types"; +import {ValidatorIndex, getBuilderStatus, getValidatorStatus, ssz} from "@lodestar/types"; import {ApiError} from "../../errors.js"; import {ApiModules} from "../../types.js"; import {assertUniqueItems} from "../../utils.js"; import { filterStateValidatorsByStatus, + getStateBuilderIndex, getStateResponseWithRegen, getStateValidatorIndex, toValidatorResponse, @@ -143,6 +145,55 @@ export function getBeaconStateApi({ return this.getStateValidators(args, context); }, + async getStateBuilders({stateId, builderIds = [], statuses = []}) { + const {state, executionOptimistic, finalized} = await getState(stateId); + if (!isStatePostGloas(state)) { + throw new ApiError(400, `Builders are not supported for pre-gloas state fork=${state.forkName}`); + } + const finalizedEpoch = state.finalizedCheckpoint.epoch; + + const builderResponses: routes.beacon.BuilderResponse[] = []; + if (builderIds.length) { + assertUniqueItems(builderIds, "Duplicate builder IDs provided"); + + for (const id of builderIds) { + const resp = getStateBuilderIndex(id, state); + if (resp.valid) { + const builderIndex = resp.builderIndex; + const builder = state.getBuilder(builderIndex); + const status = getBuilderStatus(builder, finalizedEpoch); + if (statuses.length && !statuses.includes(status)) { + continue; + } + builderResponses.push({index: builderIndex, status, builder}); + } + } + return { + data: builderResponses, + meta: {executionOptimistic, finalized}, + }; + } + + if (statuses.length) { + assertUniqueItems(statuses, "Duplicate statuses provided"); + } + + const buildersLength = state.getBuildersLength(); + for (let builderIndex = 0; builderIndex < buildersLength; builderIndex++) { + const builder = state.getBuilder(builderIndex); + const status = getBuilderStatus(builder, finalizedEpoch); + if (statuses.length && !statuses.includes(status)) { + continue; + } + builderResponses.push({index: builderIndex, status, builder}); + } + + return { + data: builderResponses, + meta: {executionOptimistic, finalized}, + }; + }, + async postStateValidatorIdentities({stateId, validatorIds = []}) { const {state, executionOptimistic, finalized} = await getState(stateId); const {pubkeyCache} = chain; diff --git a/packages/beacon-node/src/api/impl/beacon/state/utils.ts b/packages/beacon-node/src/api/impl/beacon/state/utils.ts index 44a69c87d107..048260332de4 100644 --- a/packages/beacon-node/src/api/impl/beacon/state/utils.ts +++ b/packages/beacon-node/src/api/impl/beacon/state/utils.ts @@ -1,9 +1,18 @@ import {routes} from "@lodestar/api"; import {CheckpointWithHex, IForkChoice} from "@lodestar/fork-choice"; import {GENESIS_SLOT} from "@lodestar/params"; -import {IBeaconStateView, PubkeyCache} from "@lodestar/state-transition"; -import {BLSPubkey, Epoch, RootHex, Slot, ValidatorIndex, getValidatorStatus, phase0} from "@lodestar/types"; -import {fromHex} from "@lodestar/utils"; +import {IBeaconStateView, IBeaconStateViewGloas, PubkeyCache} from "@lodestar/state-transition"; +import { + BLSPubkey, + BuilderIndex, + Epoch, + RootHex, + Slot, + ValidatorIndex, + getValidatorStatus, + phase0, +} from "@lodestar/types"; +import {byteArrayEquals, fromHex} from "@lodestar/utils"; import {IBeaconChain} from "../../../../chain/index.js"; import {ApiError, ValidationError} from "../../errors.js"; @@ -78,6 +87,50 @@ export function toValidatorResponse( }; } +type StateBuilderIndexResponse = + | {valid: true; builderIndex: BuilderIndex} + | {valid: false; code: number; reason: string}; + +export function getStateBuilderIndex( + id: routes.beacon.ValidatorId | BLSPubkey, + state: IBeaconStateViewGloas +): StateBuilderIndexResponse { + if (typeof id === "string") { + // mutate `id` and fallthrough to below + if (id.startsWith("0x")) { + try { + id = fromHex(id); + } catch (_e) { + return {valid: false, code: 400, reason: "Invalid pubkey hex encoding"}; + } + } else { + id = Number(id); + } + } + + if (typeof id === "number") { + const builderIndex = id; + // builder is invalid or added later than given stateId + if (!Number.isSafeInteger(builderIndex)) { + return {valid: false, code: 400, reason: "Invalid builder index"}; + } + if (builderIndex >= state.getBuildersLength()) { + return {valid: false, code: 404, reason: "Builder index from future state"}; + } + return {valid: true, builderIndex}; + } + + // typeof id === Uint8Array + // There is no builder pubkey cache, linear scan over the registry + const buildersLength = state.getBuildersLength(); + for (let builderIndex = 0; builderIndex < buildersLength; builderIndex++) { + if (byteArrayEquals(state.getBuilder(builderIndex).pubkey, id)) { + return {valid: true, builderIndex}; + } + } + return {valid: false, code: 404, reason: "Builder pubkey not found in state"}; +} + export function filterStateValidatorsByStatus( statuses: string[], state: IBeaconStateView, diff --git a/packages/state-transition/src/stateView/beaconStateView.ts b/packages/state-transition/src/stateView/beaconStateView.ts index 05285b4ea753..3c27e85f2ed9 100644 --- a/packages/state-transition/src/stateView/beaconStateView.ts +++ b/packages/state-transition/src/stateView/beaconStateView.ts @@ -398,6 +398,14 @@ export class BeaconStateView implements IBeaconStateViewLatestFork { return (this.cachedState as CachedBeaconStateGloas).builders.getReadonly(index); } + getBuildersLength(): number { + if (this.config.getForkSeq(this.cachedState.slot) < ForkSeq.gloas) { + throw new Error("Builders are not supported before Gloas"); + } + + return (this.cachedState as CachedBeaconStateGloas).builders.length; + } + canBuilderCoverBid(builderIndex: BuilderIndex, bidAmount: number): boolean { if (this.config.getForkSeq(this.cachedState.slot) < ForkSeq.gloas) { throw new Error("Builders are not supported before Gloas"); diff --git a/packages/state-transition/src/stateView/interface.ts b/packages/state-transition/src/stateView/interface.ts index fe133c599239..b0425279d05c 100644 --- a/packages/state-transition/src/stateView/interface.ts +++ b/packages/state-transition/src/stateView/interface.ts @@ -255,6 +255,7 @@ export interface IBeaconStateViewGloas extends IBeaconStateViewFulu { latestExecutionPayloadBid: ExecutionPayloadBid; payloadExpectedWithdrawals: capella.Withdrawal[]; getBuilder(index: BuilderIndex): gloas.Builder; + getBuildersLength(): number; canBuilderCoverBid(builderIndex: BuilderIndex, bidAmount: number): boolean; getEpochPTCs(epoch: Epoch): Uint32Array[]; getIndicesInPayloadTimelinessCommittee(validatorIndex: ValidatorIndex, slot: Slot): number[]; diff --git a/packages/state-transition/src/stateView/nativeBeaconStateView.ts b/packages/state-transition/src/stateView/nativeBeaconStateView.ts index e6722dc71eee..60b8019776c3 100644 --- a/packages/state-transition/src/stateView/nativeBeaconStateView.ts +++ b/packages/state-transition/src/stateView/nativeBeaconStateView.ts @@ -144,6 +144,7 @@ export class NativeBeaconStateView implements IBeaconStateViewLatestFork { private _getNextShuffling: EpochShuffling | null = null; private _getEffectiveBalanceIncrementsZeroInactive: EffectiveBalanceIncrements | null = null; private _getAllValidators: phase0.Validator[] | null = null; + private _getBuildersLength: number | null = null; private _getAllBalances: number[] | null = null; private _getLatestWeakSubjectivityCheckpointEpoch: Epoch | null = null; private _getFinalizedRootProof: Uint8Array[] | null = null; @@ -888,6 +889,13 @@ export class NativeBeaconStateView implements IBeaconStateViewLatestFork { return cached; } + getBuildersLength(): number { + if (this._getBuildersLength === null) { + this._getBuildersLength = this.binding.getBuildersLength(); + } + return this._getBuildersLength; + } + canBuilderCoverBid(builderIndex: BuilderIndex, bidAmount: number): boolean { return this.binding.canBuilderCoverBid(builderIndex, bidAmount); } diff --git a/packages/types/src/utils/validatorStatus.ts b/packages/types/src/utils/validatorStatus.ts index e5c4692e2f3f..d82dc6ed438c 100644 --- a/packages/types/src/utils/validatorStatus.ts +++ b/packages/types/src/utils/validatorStatus.ts @@ -1,5 +1,5 @@ import {FAR_FUTURE_EPOCH} from "@lodestar/params"; -import {Epoch, phase0} from "../types.js"; +import {Epoch, gloas, phase0} from "../types.js"; /** * [Validator status specification](https://hackmd.io/ofFJ5gOmQpu1jjHilHbdQQ) @@ -53,6 +53,25 @@ export function getValidatorStatus(validator: phase0.Validator, currentEpoch: Ep throw new Error("ValidatorStatus unknown"); } +/** + * Statuses as defined in https://github.com/ethereum/beacon-APIs/pull/614 + */ +export type BuilderStatus = "pending" | "active" | "exited"; + +/** + * Get the status of the builder, `finalizedEpoch` refers to `state.finalized_checkpoint.epoch` + */ +export function getBuilderStatus(builder: gloas.Builder, finalizedEpoch: Epoch): BuilderStatus { + if (builder.withdrawableEpoch !== FAR_FUTURE_EPOCH) { + return "exited"; + } + // Same as is_active_builder check with withdrawable epoch already confirmed to be FAR_FUTURE_EPOCH + if (builder.depositEpoch < finalizedEpoch) { + return "active"; + } + return "pending"; +} + export function mapToGeneralStatus(subStatus: ValidatorStatus): GeneralValidatorStatus { switch (subStatus) { case "active_ongoing": From d7c793a5d7c9402de91653b8cce213341c732aef Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Sun, 5 Jul 2026 11:05:44 +0100 Subject: [PATCH 2/2] reject negative builder and validator indices --- packages/beacon-node/src/api/impl/beacon/state/utils.ts | 4 ++-- .../beacon-node/test/unit/api/impl/beacon/state/utils.test.ts | 3 +++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/beacon-node/src/api/impl/beacon/state/utils.ts b/packages/beacon-node/src/api/impl/beacon/state/utils.ts index 048260332de4..5af545d3ab86 100644 --- a/packages/beacon-node/src/api/impl/beacon/state/utils.ts +++ b/packages/beacon-node/src/api/impl/beacon/state/utils.ts @@ -111,7 +111,7 @@ export function getStateBuilderIndex( if (typeof id === "number") { const builderIndex = id; // builder is invalid or added later than given stateId - if (!Number.isSafeInteger(builderIndex)) { + if (!Number.isSafeInteger(builderIndex) || builderIndex < 0) { return {valid: false, code: 400, reason: "Invalid builder index"}; } if (builderIndex >= state.getBuildersLength()) { @@ -175,7 +175,7 @@ export function getStateValidatorIndex( if (typeof id === "number") { const validatorIndex = id; // validator is invalid or added later than given stateId - if (!Number.isSafeInteger(validatorIndex)) { + if (!Number.isSafeInteger(validatorIndex) || validatorIndex < 0) { return {valid: false, code: 400, reason: "Invalid validator index"}; } if (validatorIndex >= state.validatorCount) { diff --git a/packages/beacon-node/test/unit/api/impl/beacon/state/utils.test.ts b/packages/beacon-node/test/unit/api/impl/beacon/state/utils.test.ts index 0eb499465142..6d1aee4581a3 100644 --- a/packages/beacon-node/test/unit/api/impl/beacon/state/utils.test.ts +++ b/packages/beacon-node/test/unit/api/impl/beacon/state/utils.test.ts @@ -14,6 +14,9 @@ describe("beacon state api utils", () => { expect(getStateValidatorIndex("foo", state, pubkeyCache).valid).toBe(false); // "invalid hex" expect(getStateValidatorIndex("0xfoo", state, pubkeyCache).valid).toBe(false); + // "negative validator index" + expect(getStateValidatorIndex("-1", state, pubkeyCache).valid).toBe(false); + expect(getStateValidatorIndex(-1, state, pubkeyCache).valid).toBe(false); }); it("should return valid: false on validator indices / pubkeys not in the state", () => {