From f9c1ca7a4ee9d22c0d12c529606bf9aac8d7ddbe Mon Sep 17 00:00:00 2001 From: markolazic01 Date: Tue, 4 Aug 2026 13:39:42 +0200 Subject: [PATCH 01/33] refactor: extract builder identity resolution to identity.ts --- packages/builder/src/builder.ts | 50 ++++++++++---------------------- packages/builder/src/identity.ts | 44 ++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 35 deletions(-) create mode 100644 packages/builder/src/identity.ts diff --git a/packages/builder/src/builder.ts b/packages/builder/src/builder.ts index 1d2cf352b01c..0230f462cbe9 100644 --- a/packages/builder/src/builder.ts +++ b/packages/builder/src/builder.ts @@ -1,15 +1,17 @@ -import {ApiClient, routes} from "@lodestar/api"; +import {ApiClient} from "@lodestar/api"; import {ChainForkConfig, assertEqualParams, createBeaconConfig} from "@lodestar/config"; -import {PAYLOAD_BUILDER_VERSION} from "@lodestar/params"; import {Clock, ClockOptions, IClock} from "@lodestar/state-transition"; +import {BuilderIndex} from "@lodestar/types"; import {Logger} from "@lodestar/utils"; import {waitForGenesis} from "./genesis.js"; +import {resolveBuilderIdentity} from "./identity.js"; import {BuilderSigner, Keypair} from "./services/builderSigner.js"; export type BuilderModules = { opts: BuilderOptions; builderSigner: BuilderSigner; clock: IClock; + index: BuilderIndex; }; export type BuilderOptions = { @@ -28,56 +30,34 @@ export class Builder { readonly builderSigner: BuilderSigner; private readonly controller: AbortController; private readonly clock: IClock; + private readonly index: BuilderIndex; - constructor({opts, builderSigner, clock}: BuilderModules) { + constructor({opts, builderSigner, clock, index}: BuilderModules) { this.builderSigner = builderSigner; this.clock = clock; this.controller = opts.abortController; + this.index = index; this.clock.start(this.controller.signal); } static async init(opts: BuilderOptions): Promise { - const genesis = await waitForGenesis(opts.api, opts.logger, opts.abortController.signal); - opts.logger.info("Genesis fetched from the beacon node"); + const {api, logger} = opts; + const genesis = await waitForGenesis(api, logger, opts.abortController.signal); + logger.info("Genesis fetched from the beacon node"); - const specRes = await opts.api.config.getSpec(); + const specRes = await api.config.getSpec(); assertEqualParams(opts.config, specRes.value()); - opts.logger.info("Verified connected beacon node and builder have the same config"); + logger.info("Verified connected beacon node and builder have the same config"); const config = createBeaconConfig(opts.config, genesis.genesisValidatorsRoot); const builderSigner = new BuilderSigner(config, opts.keypair); - const builderRes = await opts.api.beacon.getStateBuilders({ - stateId: "head", - builderIds: [builderSigner.getPubkeyHex()], - }); + const index = await resolveBuilderIdentity(api, logger, builderSigner.getPubkeyHex()); - if (!builderRes.ok) { - throw Error(`Getting state builders from BN failed: ${builderRes.status}`); - } + const clock = new Clock(config, logger, {genesisTime: Number(genesis.genesisTime), ...opts.clock}); - const builders = builderRes.value(); - - if (builders.length === 0) { - throw Error(`Builder not registered: ${builderSigner.getPubkeyHex()}`); - } - - const builderStatus: routes.beacon.BuilderResponse = builders[0]; - - if (builderStatus.status !== "active") { - throw Error(`Builder not active: ${builderStatus.status}`); - } - - if (builderStatus.builder.version !== PAYLOAD_BUILDER_VERSION) { - throw Error( - `Builder version mismatch: got ${builderStatus.builder.version}, expected ${PAYLOAD_BUILDER_VERSION}` - ); - } - - const clock = new Clock(config, opts.logger, {genesisTime: Number(genesis.genesisTime), ...opts.clock}); - - return new Builder({opts, builderSigner, clock}); + return new Builder({opts, builderSigner, clock, index}); } async close(): Promise { diff --git a/packages/builder/src/identity.ts b/packages/builder/src/identity.ts new file mode 100644 index 000000000000..1d827f588ecc --- /dev/null +++ b/packages/builder/src/identity.ts @@ -0,0 +1,44 @@ +import {ApiClient} from "@lodestar/api"; +import {PAYLOAD_BUILDER_VERSION} from "@lodestar/params"; +import {BuilderIndex} from "@lodestar/types"; +import {Logger, toHex} from "@lodestar/utils"; + +export async function resolveBuilderIdentity( + api: ApiClient, + logger: Logger, + publicKeyHex: string +): Promise { + const builderRes = await api.beacon.getStateBuilders({ + stateId: "head", + builderIds: [publicKeyHex], + }); + + if (!builderRes.ok) { + throw Error(`Getting state builders from BN failed: ${builderRes.status}`); + } + + const builders = builderRes.value(); + + if (builders.length === 0) { + throw Error(`Builder not registered: ${publicKeyHex}`); + } + + const builderEntry = builders[0]; + + if (builderEntry.status !== "active") { + throw Error(`Builder not active: ${builderEntry.status}`); + } + + if (builderEntry.builder.version !== PAYLOAD_BUILDER_VERSION) { + throw Error(`Builder version mismatch: got ${builderEntry.builder.version}, expected ${PAYLOAD_BUILDER_VERSION}`); + } + + logger.info("Builder identity resolved", { + index: builderEntry.index, + status: builderEntry.status, + balanceGwei: builderEntry.builder.balance, + executionAddress: toHex(builderEntry.builder.executionAddress), + }); + + return builderEntry.index; +} From 6dd7f0cbd2eb61b8aff0e7d30444cc817792a984 Mon Sep 17 00:00:00 2001 From: markolazic01 Date: Tue, 4 Aug 2026 16:02:14 +0200 Subject: [PATCH 02/33] feat: add builder status query and share builder fetch helper --- packages/builder/src/identity.ts | 67 ++++++++++++++++++++++---------- 1 file changed, 46 insertions(+), 21 deletions(-) diff --git a/packages/builder/src/identity.ts b/packages/builder/src/identity.ts index 1d827f588ecc..9d9302c04626 100644 --- a/packages/builder/src/identity.ts +++ b/packages/builder/src/identity.ts @@ -1,30 +1,15 @@ -import {ApiClient} from "@lodestar/api"; +import {ApiClient, routes} from "@lodestar/api"; import {PAYLOAD_BUILDER_VERSION} from "@lodestar/params"; -import {BuilderIndex} from "@lodestar/types"; +import {BuilderIndex, BuilderStatus} from "@lodestar/types"; import {Logger, toHex} from "@lodestar/utils"; -export async function resolveBuilderIdentity( - api: ApiClient, - logger: Logger, - publicKeyHex: string -): Promise { - const builderRes = await api.beacon.getStateBuilders({ - stateId: "head", - builderIds: [publicKeyHex], - }); - - if (!builderRes.ok) { - throw Error(`Getting state builders from BN failed: ${builderRes.status}`); - } - - const builders = builderRes.value(); +export async function resolveBuilderIdentity(api: ApiClient, logger: Logger, id: string): Promise { + const builderEntry = await fetchBuilder(api, logger, id); - if (builders.length === 0) { - throw Error(`Builder not registered: ${publicKeyHex}`); + if (builderEntry === null) { + throw Error("Unable to retrieve the builder."); } - const builderEntry = builders[0]; - if (builderEntry.status !== "active") { throw Error(`Builder not active: ${builderEntry.status}`); } @@ -42,3 +27,43 @@ export async function resolveBuilderIdentity( return builderEntry.index; } + +export async function getBuilderStatus( + api: ApiClient, + logger: Logger, + id: routes.beacon.BuilderId +): Promise<{status: BuilderStatus; balance: number} | null> { + const builderEntry = await fetchBuilder(api, logger, id); + + if (builderEntry === null) return null; + + return { + status: builderEntry.status, + balance: builderEntry.builder.balance, + }; +} + +async function fetchBuilder( + api: ApiClient, + logger: Logger, + id: routes.beacon.BuilderId +): Promise { + const builderRes = await api.beacon.getStateBuilders({ + stateId: "head", + builderIds: [id], + }); + + if (!builderRes.ok) { + logger.warn("Getting builder state from BN failed", {status: builderRes.status}); + return null; + } + + const builders = builderRes.value(); + + if (builders.length === 0) { + logger.warn(`Builder not known to the BN: ${id}`); + return null; + } + + return builders[0]; +} From dd2bc258b6789c994e3f6c1e92407fc4d9c8c577 Mon Sep 17 00:00:00 2001 From: markolazic01 Date: Wed, 5 Aug 2026 12:16:10 +0200 Subject: [PATCH 03/33] feat: check beacon node readiness on startup --- packages/builder/src/builder.ts | 3 +++ packages/builder/src/readiness.ts | 39 +++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+) create mode 100644 packages/builder/src/readiness.ts diff --git a/packages/builder/src/builder.ts b/packages/builder/src/builder.ts index 0230f462cbe9..31f1dbc1724e 100644 --- a/packages/builder/src/builder.ts +++ b/packages/builder/src/builder.ts @@ -5,6 +5,7 @@ import {BuilderIndex} from "@lodestar/types"; import {Logger} from "@lodestar/utils"; import {waitForGenesis} from "./genesis.js"; import {resolveBuilderIdentity} from "./identity.js"; +import {assertNodeReady} from "./readiness.js"; import {BuilderSigner, Keypair} from "./services/builderSigner.js"; export type BuilderModules = { @@ -53,6 +54,8 @@ export class Builder { const config = createBeaconConfig(opts.config, genesis.genesisValidatorsRoot); const builderSigner = new BuilderSigner(config, opts.keypair); + await assertNodeReady(api, logger); + const index = await resolveBuilderIdentity(api, logger, builderSigner.getPubkeyHex()); const clock = new Clock(config, logger, {genesisTime: Number(genesis.genesisTime), ...opts.clock}); diff --git a/packages/builder/src/readiness.ts b/packages/builder/src/readiness.ts new file mode 100644 index 000000000000..df95d8ed94b9 --- /dev/null +++ b/packages/builder/src/readiness.ts @@ -0,0 +1,39 @@ +import {ApiClient} from "@lodestar/api"; +import {Logger} from "@lodestar/utils"; + +export async function assertNodeReady(api: ApiClient, logger: Logger): Promise { + try { + const versionRes = await api.node.getNodeVersionV2(); + const version = versionRes.value(); + logger.info("Node version", { + beaconNode: `${version.beaconNode.name}/${version.beaconNode.version}`, + executionClient: version.executionClient + ? `${version.executionClient.name}/${version.executionClient.version}` + : "unknown", + }); + } catch { + logger.warn("Failed to get node version"); + } + + const syncingStatusRes = await api.node.getSyncingStatus(); + + if (!syncingStatusRes.ok) { + throw Error("Cannot get node sync status"); + } + + const syncingStatus = syncingStatusRes.value(); + + if (syncingStatus.isSyncing) { + throw Error( + `Beacon node is syncing: headSlot=${syncingStatus.headSlot} syncDistance=${syncingStatus.syncDistance}` + ); + } + + if (syncingStatus.elOffline) { + throw Error("Execution client is offline"); + } + + if (syncingStatus.isOptimistic) { + logger.warn("Beacon node head is optimistic, execution payloads are not yet verified"); + } +} From 4cbb72bdf0f284f1f28dc375ea286203caaeb24a Mon Sep 17 00:00:00 2001 From: markolazic01 Date: Wed, 5 Aug 2026 14:59:18 +0200 Subject: [PATCH 04/33] chore: log builder index --- packages/builder/src/builder.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/builder/src/builder.ts b/packages/builder/src/builder.ts index 31f1dbc1724e..587141c43ca0 100644 --- a/packages/builder/src/builder.ts +++ b/packages/builder/src/builder.ts @@ -32,14 +32,18 @@ export class Builder { private readonly controller: AbortController; private readonly clock: IClock; private readonly index: BuilderIndex; + private readonly logger: Logger; constructor({opts, builderSigner, clock, index}: BuilderModules) { this.builderSigner = builderSigner; this.clock = clock; this.controller = opts.abortController; + this.logger = opts.logger; this.index = index; this.clock.start(this.controller.signal); + + this.logger.info("Builder client initialized", {index: this.index}); } static async init(opts: BuilderOptions): Promise { From 0d22e6db43e52f614e151ec371db07dd384c19b0 Mon Sep 17 00:00:00 2001 From: markolazic01 Date: Wed, 5 Aug 2026 17:36:38 +0200 Subject: [PATCH 05/33] feat: wait for beacon node readiness instead of failing at startup --- packages/builder/src/builder.ts | 5 ++- packages/builder/src/genesis.ts | 4 +- packages/builder/src/readiness.ts | 72 ++++++++++++++++++++----------- 3 files changed, 51 insertions(+), 30 deletions(-) diff --git a/packages/builder/src/builder.ts b/packages/builder/src/builder.ts index 587141c43ca0..14c62994b97b 100644 --- a/packages/builder/src/builder.ts +++ b/packages/builder/src/builder.ts @@ -5,7 +5,7 @@ import {BuilderIndex} from "@lodestar/types"; import {Logger} from "@lodestar/utils"; import {waitForGenesis} from "./genesis.js"; import {resolveBuilderIdentity} from "./identity.js"; -import {assertNodeReady} from "./readiness.js"; +import {logNodeVersion, waitForNodeReady} from "./readiness.js"; import {BuilderSigner, Keypair} from "./services/builderSigner.js"; export type BuilderModules = { @@ -58,7 +58,8 @@ export class Builder { const config = createBeaconConfig(opts.config, genesis.genesisValidatorsRoot); const builderSigner = new BuilderSigner(config, opts.keypair); - await assertNodeReady(api, logger); + await waitForNodeReady(api, logger, opts.abortController.signal); + await logNodeVersion(api, logger); const index = await resolveBuilderIdentity(api, logger, builderSigner.getPubkeyHex()); diff --git a/packages/builder/src/genesis.ts b/packages/builder/src/genesis.ts index 978f46ce7c6b..4e4351aa4950 100644 --- a/packages/builder/src/genesis.ts +++ b/packages/builder/src/genesis.ts @@ -5,7 +5,7 @@ import {Logger, sleep} from "@lodestar/utils"; /** The time between polls when waiting for genesis */ const WAITING_FOR_GENESIS_POLL_MS = 12 * 1000; -export async function waitForGenesis(api: ApiClient, logger: Logger, signal?: AbortSignal): Promise { +export async function waitForGenesis(api: ApiClient, logger: Logger, signal: AbortSignal): Promise { while (true) { try { return (await api.beacon.getGenesis()).value(); @@ -13,7 +13,7 @@ export async function waitForGenesis(api: ApiClient, logger: Logger, signal?: Ab if (e instanceof ApiError && e.status === HttpStatusCode.NOT_FOUND) { logger.info("Waiting for genesis", {message: e.message}); } else { - logger.warn("Failed to fetch genesis", {message: (e as Error).message}); + logger.warn("Failed to fetch genesis", {}, e as Error); } await sleep(WAITING_FOR_GENESIS_POLL_MS, signal); } diff --git a/packages/builder/src/readiness.ts b/packages/builder/src/readiness.ts index df95d8ed94b9..bf5bf83d222b 100644 --- a/packages/builder/src/readiness.ts +++ b/packages/builder/src/readiness.ts @@ -1,7 +1,49 @@ import {ApiClient} from "@lodestar/api"; -import {Logger} from "@lodestar/utils"; +import {Logger, sleep} from "@lodestar/utils"; -export async function assertNodeReady(api: ApiClient, logger: Logger): Promise { +/** The time between polls when waiting for BN to be ready */ +const WAITING_FOR_NODE_READY_POLL_MS = 5 * 1000; + +export async function waitForNodeReady(api: ApiClient, logger: Logger, signal: AbortSignal): Promise { + while (!(await isNodeReady(api, logger))) { + await sleep(WAITING_FOR_NODE_READY_POLL_MS, signal); + } +} + +async function isNodeReady(api: ApiClient, logger: Logger): Promise { + try { + const syncingStatusRes = await api.node.getSyncingStatus(); + + if (!syncingStatusRes.ok) { + logger.warn("Cannot get node sync status", {status: syncingStatusRes.status}); + return false; + } + + const syncingStatus = syncingStatusRes.value(); + + if (syncingStatus.isSyncing || syncingStatus.elOffline) { + logger.info("Beacon node is not ready yet", { + headSlot: syncingStatus.headSlot, + syncDistance: syncingStatus.syncDistance, + elOffline: syncingStatus.elOffline, + }); + return false; + } + + logger.info("Beacon node is ready", {headSlot: syncingStatus.headSlot}); + + if (syncingStatus.isOptimistic) { + logger.warn("Beacon node head is optimistic, execution payloads are not yet verified"); + } + + return true; + } catch (e) { + logger.warn("Cannot reach the beacon node", {}, e as Error); + return false; + } +} + +export async function logNodeVersion(api: ApiClient, logger: Logger): Promise { try { const versionRes = await api.node.getNodeVersionV2(); const version = versionRes.value(); @@ -11,29 +53,7 @@ export async function assertNodeReady(api: ApiClient, logger: Logger): Promise Date: Thu, 6 Aug 2026 00:02:54 +0200 Subject: [PATCH 06/33] feat: add executionFeeRecipient cli option --- packages/builder/src/builder.ts | 13 ++++++++++--- packages/cli/src/cmds/builder/handler.ts | 10 +++++++++- packages/cli/src/cmds/builder/options.ts | 7 +++++++ 3 files changed, 26 insertions(+), 4 deletions(-) diff --git a/packages/builder/src/builder.ts b/packages/builder/src/builder.ts index 14c62994b97b..44870cbd52a4 100644 --- a/packages/builder/src/builder.ts +++ b/packages/builder/src/builder.ts @@ -1,8 +1,8 @@ import {ApiClient} from "@lodestar/api"; import {ChainForkConfig, assertEqualParams, createBeaconConfig} from "@lodestar/config"; import {Clock, ClockOptions, IClock} from "@lodestar/state-transition"; -import {BuilderIndex} from "@lodestar/types"; -import {Logger} from "@lodestar/utils"; +import {BuilderIndex, ExecutionAddress} from "@lodestar/types"; +import {Logger, toHex} from "@lodestar/utils"; import {waitForGenesis} from "./genesis.js"; import {resolveBuilderIdentity} from "./identity.js"; import {logNodeVersion, waitForNodeReady} from "./readiness.js"; @@ -22,6 +22,7 @@ export type BuilderOptions = { abortController: AbortController; api: ApiClient; clock?: ClockOptions; + executionFeeRecipient: ExecutionAddress; }; /** @@ -33,6 +34,7 @@ export class Builder { private readonly clock: IClock; private readonly index: BuilderIndex; private readonly logger: Logger; + private readonly executionFeeRecipient: ExecutionAddress; constructor({opts, builderSigner, clock, index}: BuilderModules) { this.builderSigner = builderSigner; @@ -41,9 +43,14 @@ export class Builder { this.logger = opts.logger; this.index = index; + this.executionFeeRecipient = opts.executionFeeRecipient; + this.clock.start(this.controller.signal); - this.logger.info("Builder client initialized", {index: this.index}); + this.logger.info("Builder client initialized", { + index: this.index, + executionFeeRecipient: toHex(this.executionFeeRecipient), + }); } static async init(opts: BuilderOptions): Promise { diff --git a/packages/cli/src/cmds/builder/handler.ts b/packages/cli/src/cmds/builder/handler.ts index 0f89ac31d9fc..b90e51dc3dd9 100644 --- a/packages/cli/src/cmds/builder/handler.ts +++ b/packages/cli/src/cmds/builder/handler.ts @@ -2,10 +2,11 @@ import path from "node:path"; import {getClient} from "@lodestar/api"; import {Builder} from "@lodestar/builder"; import {getNodeLogger} from "@lodestar/logger/node"; +import {fromHex} from "@lodestar/utils"; import {getBeaconConfigFromArgs} from "../../config/beaconParams.js"; import {GlobalArgs} from "../../options/index.js"; import {getGlobalPaths} from "../../paths/global.js"; -import {cleanOldLogFiles, onGracefulShutdown, parseLoggerArgs} from "../../util/index.js"; +import {cleanOldLogFiles, onGracefulShutdown, parseFeeRecipient, parseLoggerArgs} from "../../util/index.js"; import {loadBuilderKeypair} from "./loadKeypair.js"; import {IBuilderCliArgs} from "./options.js"; @@ -22,6 +23,12 @@ export async function builderHandler(args: IBuilderCliArgs & GlobalArgs): Promis logger.debug("Not able to delete log files", {}, e as Error); } + const executionFeeRecipient = parseFeeRecipient(args.executionFeeRecipient); + + if (executionFeeRecipient === "0x" + "0".repeat(40)) { + throw Error("Cannot put zero address as an executionFeeRecipient"); + } + const keypair = await loadBuilderKeypair(args.keystore, args.keystorePassword, args.builderPubkey); const onGracefulShutdownCbs: (() => Promise | void)[] = []; @@ -40,6 +47,7 @@ export async function builderHandler(args: IBuilderCliArgs & GlobalArgs): Promis config, abortController, api, + executionFeeRecipient: fromHex(executionFeeRecipient), }); onGracefulShutdownCbs.push(() => builder.close()); diff --git a/packages/cli/src/cmds/builder/options.ts b/packages/cli/src/cmds/builder/options.ts index db7224dd35ca..a1a0963a735d 100644 --- a/packages/cli/src/cmds/builder/options.ts +++ b/packages/cli/src/cmds/builder/options.ts @@ -7,6 +7,7 @@ export type IBuilderCliArgs = LogArgs & { keystore: string; keystorePassword: string; builderPubkey?: string; + executionFeeRecipient: string; }; export const builderOptions: CliCommandOptions = { @@ -34,4 +35,10 @@ export const builderOptions: CliCommandOptions = { description: "Builder's expected public key based on the keystore from 'keystore' option", type: "string", }, + + executionFeeRecipient: { + description: "Execution address for receiving the payload rewards", + type: "string", + demandOption: true, + }, }; From 42c70859e722c704efc5f970f24181862ecb9e69 Mon Sep 17 00:00:00 2001 From: markolazic01 Date: Thu, 6 Aug 2026 00:20:15 +0200 Subject: [PATCH 07/33] feat: gloas fork schedule check --- packages/cli/src/cmds/builder/handler.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/cli/src/cmds/builder/handler.ts b/packages/cli/src/cmds/builder/handler.ts index b90e51dc3dd9..3917744c3ea9 100644 --- a/packages/cli/src/cmds/builder/handler.ts +++ b/packages/cli/src/cmds/builder/handler.ts @@ -13,6 +13,10 @@ import {IBuilderCliArgs} from "./options.js"; export async function builderHandler(args: IBuilderCliArgs & GlobalArgs): Promise { const {config, network} = getBeaconConfigFromArgs(args); + if (config.forks.gloas.epoch === Infinity) { + throw Error(`Gloas must be scheduled via GLOAS_FORK_EPOCH (network: ${network})`); + } + const globalPaths = getGlobalPaths(args, network); const defaultLogFilepath = path.join(globalPaths.dataDir, "builder.log"); const logger = getNodeLogger(parseLoggerArgs(args, {defaultLogFilepath}, config)); From 8424ecf3a393104ca5ef33293438486df678948f Mon Sep 17 00:00:00 2001 From: markolazic01 Date: Thu, 6 Aug 2026 00:38:09 +0200 Subject: [PATCH 08/33] chore: log gvr --- packages/builder/src/builder.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/builder/src/builder.ts b/packages/builder/src/builder.ts index 44870cbd52a4..222f3658286e 100644 --- a/packages/builder/src/builder.ts +++ b/packages/builder/src/builder.ts @@ -56,7 +56,7 @@ export class Builder { static async init(opts: BuilderOptions): Promise { const {api, logger} = opts; const genesis = await waitForGenesis(api, logger, opts.abortController.signal); - logger.info("Genesis fetched from the beacon node"); + logger.info("Genesis fetched from the beacon node", {genesisValidatorsRoot: toHex(genesis.genesisValidatorsRoot)}); const specRes = await api.config.getSpec(); assertEqualParams(opts.config, specRes.value()); From 14dbd09c88cca071ba7d666999837f0b696289a1 Mon Sep 17 00:00:00 2001 From: markolazic01 Date: Thu, 6 Aug 2026 01:20:45 +0200 Subject: [PATCH 09/33] refactor: throw typed errors from fetchBuilder --- packages/builder/src/identity.ts | 42 +++++++++++++++----------------- 1 file changed, 19 insertions(+), 23 deletions(-) diff --git a/packages/builder/src/identity.ts b/packages/builder/src/identity.ts index 9d9302c04626..275783f1b050 100644 --- a/packages/builder/src/identity.ts +++ b/packages/builder/src/identity.ts @@ -3,12 +3,12 @@ import {PAYLOAD_BUILDER_VERSION} from "@lodestar/params"; import {BuilderIndex, BuilderStatus} from "@lodestar/types"; import {Logger, toHex} from "@lodestar/utils"; -export async function resolveBuilderIdentity(api: ApiClient, logger: Logger, id: string): Promise { - const builderEntry = await fetchBuilder(api, logger, id); - - if (builderEntry === null) { - throw Error("Unable to retrieve the builder."); - } +export async function resolveBuilderIdentity( + api: ApiClient, + logger: Logger, + id: routes.beacon.BuilderId +): Promise { + const builderEntry = await fetchBuilder(api, id); if (builderEntry.status !== "active") { throw Error(`Builder not active: ${builderEntry.status}`); @@ -33,36 +33,32 @@ export async function getBuilderStatus( logger: Logger, id: routes.beacon.BuilderId ): Promise<{status: BuilderStatus; balance: number} | null> { - const builderEntry = await fetchBuilder(api, logger, id); - - if (builderEntry === null) return null; - - return { - status: builderEntry.status, - balance: builderEntry.builder.balance, - }; + try { + const builderEntry = await fetchBuilder(api, id); + return { + status: builderEntry.status, + balance: builderEntry.builder.balance, + }; + } catch (e) { + logger.warn("Couldn't fetch the builder", {}, e as Error); + return null; + } } -async function fetchBuilder( - api: ApiClient, - logger: Logger, - id: routes.beacon.BuilderId -): Promise { +async function fetchBuilder(api: ApiClient, id: routes.beacon.BuilderId): Promise { const builderRes = await api.beacon.getStateBuilders({ stateId: "head", builderIds: [id], }); if (!builderRes.ok) { - logger.warn("Getting builder state from BN failed", {status: builderRes.status}); - return null; + throw Error(`Failed to get builder state from beacon node: ${builderRes.status}`); } const builders = builderRes.value(); if (builders.length === 0) { - logger.warn(`Builder not known to the BN: ${id}`); - return null; + throw Error(`Builder not known to the beacon node: ${id}`); } return builders[0]; From f5f50d0564a31354937b6c3f28041790c6e1e86f Mon Sep 17 00:00:00 2001 From: markolazic01 Date: Thu, 6 Aug 2026 01:58:14 +0200 Subject: [PATCH 10/33] feat: add builder status tracker --- .../src/services/builderStatusTracker.ts | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 packages/builder/src/services/builderStatusTracker.ts diff --git a/packages/builder/src/services/builderStatusTracker.ts b/packages/builder/src/services/builderStatusTracker.ts new file mode 100644 index 000000000000..e7bc1eda42a8 --- /dev/null +++ b/packages/builder/src/services/builderStatusTracker.ts @@ -0,0 +1,38 @@ +import {ApiClient} from "@lodestar/api"; +import {BuilderIndex, BuilderStatus} from "@lodestar/types"; +import {Logger} from "@lodestar/utils"; +import {getBuilderStatus} from "../identity.js"; + +export class BuilderStatusTracker { + private readonly api: ApiClient; + private readonly logger: Logger; + private readonly index: BuilderIndex; + + private status?: BuilderStatus; + private balanceGwei?: number; + + constructor(api: ApiClient, logger: Logger, index: BuilderIndex) { + this.api = api; + this.logger = logger; + this.index = index; + } + + async poll() { + const builderStatus = await getBuilderStatus(this.api, this.logger, this.index); + if (builderStatus !== null) { + if (this.status !== undefined && this.status !== builderStatus.status) { + this.logger.warn("Builder status changed", {from: this.status, to: builderStatus.status}); + } + this.status = builderStatus.status; + this.balanceGwei = builderStatus.balance; + this.logger.info("Builder status", {status: builderStatus.status, balance: builderStatus.balance}); + } + } + + getStatus(): {status: BuilderStatus | undefined; balance: number | undefined} { + return { + status: this.status, + balance: this.balanceGwei, + }; + } +} From 96dafe880be7c71fe062fa69e1bfedf094d70fd6 Mon Sep 17 00:00:00 2001 From: markolazic01 Date: Thu, 6 Aug 2026 14:15:50 +0200 Subject: [PATCH 11/33] feat: poll builder status and balance every epoch --- packages/builder/src/builder.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/builder/src/builder.ts b/packages/builder/src/builder.ts index 222f3658286e..8a4180fb0371 100644 --- a/packages/builder/src/builder.ts +++ b/packages/builder/src/builder.ts @@ -7,10 +7,12 @@ import {waitForGenesis} from "./genesis.js"; import {resolveBuilderIdentity} from "./identity.js"; import {logNodeVersion, waitForNodeReady} from "./readiness.js"; import {BuilderSigner, Keypair} from "./services/builderSigner.js"; +import {BuilderStatusTracker} from "./services/builderStatusTracker.js"; export type BuilderModules = { opts: BuilderOptions; builderSigner: BuilderSigner; + builderStatusTracker: BuilderStatusTracker; clock: IClock; index: BuilderIndex; }; @@ -30,14 +32,16 @@ export type BuilderOptions = { */ export class Builder { readonly builderSigner: BuilderSigner; + private readonly builderStatusTracker: BuilderStatusTracker; private readonly controller: AbortController; private readonly clock: IClock; private readonly index: BuilderIndex; private readonly logger: Logger; private readonly executionFeeRecipient: ExecutionAddress; - constructor({opts, builderSigner, clock, index}: BuilderModules) { + constructor({opts, builderSigner, builderStatusTracker, clock, index}: BuilderModules) { this.builderSigner = builderSigner; + this.builderStatusTracker = builderStatusTracker; this.clock = clock; this.controller = opts.abortController; this.logger = opts.logger; @@ -45,6 +49,7 @@ export class Builder { this.executionFeeRecipient = opts.executionFeeRecipient; + this.clock.runEveryEpoch(() => this.builderStatusTracker.poll()); this.clock.start(this.controller.signal); this.logger.info("Builder client initialized", { @@ -72,7 +77,9 @@ export class Builder { const clock = new Clock(config, logger, {genesisTime: Number(genesis.genesisTime), ...opts.clock}); - return new Builder({opts, builderSigner, clock, index}); + const builderStatusTracker = new BuilderStatusTracker(api, logger, index); + + return new Builder({opts, builderSigner, builderStatusTracker, clock, index}); } async close(): Promise { From e90ebb2a9a84053e297e30fd074ee17c79f4fa25 Mon Sep 17 00:00:00 2001 From: markolazic01 Date: Thu, 6 Aug 2026 15:55:47 +0200 Subject: [PATCH 12/33] chore: BuilderStatusTracker description --- packages/builder/src/services/builderStatusTracker.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/builder/src/services/builderStatusTracker.ts b/packages/builder/src/services/builderStatusTracker.ts index e7bc1eda42a8..6748b6acd369 100644 --- a/packages/builder/src/services/builderStatusTracker.ts +++ b/packages/builder/src/services/builderStatusTracker.ts @@ -3,6 +3,10 @@ import {BuilderIndex, BuilderStatus} from "@lodestar/types"; import {Logger} from "@lodestar/utils"; import {getBuilderStatus} from "../identity.js"; +/** + * Service for tracking builder status. + * Provides regular builder status and balance updates for operator diagnostics. + */ export class BuilderStatusTracker { private readonly api: ApiClient; private readonly logger: Logger; From 354c6f478383b0ba863aad850f0c6bacc56b21f3 Mon Sep 17 00:00:00 2001 From: markolazic01 Date: Thu, 6 Aug 2026 15:58:21 +0200 Subject: [PATCH 13/33] chore: zero address as a constant --- packages/cli/src/cmds/builder/handler.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/cmds/builder/handler.ts b/packages/cli/src/cmds/builder/handler.ts index 3917744c3ea9..3f06ce163fe5 100644 --- a/packages/cli/src/cmds/builder/handler.ts +++ b/packages/cli/src/cmds/builder/handler.ts @@ -10,6 +10,8 @@ import {cleanOldLogFiles, onGracefulShutdown, parseFeeRecipient, parseLoggerArgs import {loadBuilderKeypair} from "./loadKeypair.js"; import {IBuilderCliArgs} from "./options.js"; +const ZERO_ADDRESS = "0x" + "0".repeat(40); + export async function builderHandler(args: IBuilderCliArgs & GlobalArgs): Promise { const {config, network} = getBeaconConfigFromArgs(args); @@ -29,7 +31,7 @@ export async function builderHandler(args: IBuilderCliArgs & GlobalArgs): Promis const executionFeeRecipient = parseFeeRecipient(args.executionFeeRecipient); - if (executionFeeRecipient === "0x" + "0".repeat(40)) { + if (executionFeeRecipient === ZERO_ADDRESS) { throw Error("Cannot put zero address as an executionFeeRecipient"); } From d68905f8ee3a9d12493e355ce835ac358c67fb16 Mon Sep 17 00:00:00 2001 From: markolazic01 Date: Fri, 7 Aug 2026 16:42:16 +0200 Subject: [PATCH 14/33] feat: requestTimeout cli flag --- packages/builder/src/defaults.ts | 1 + packages/cli/src/cmds/builder/handler.ts | 9 +++++++-- packages/cli/src/cmds/builder/options.ts | 7 +++++++ 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/packages/builder/src/defaults.ts b/packages/builder/src/defaults.ts index cf9e3623daa4..abab6737e795 100644 --- a/packages/builder/src/defaults.ts +++ b/packages/builder/src/defaults.ts @@ -1,4 +1,5 @@ export const defaultOptions = { // Source beacon node the builder connects to beaconNodeUrl: "http://127.0.0.1:9596", + requestTimeout: 10_000, }; diff --git a/packages/cli/src/cmds/builder/handler.ts b/packages/cli/src/cmds/builder/handler.ts index 3f06ce163fe5..4dc5bc4d5df9 100644 --- a/packages/cli/src/cmds/builder/handler.ts +++ b/packages/cli/src/cmds/builder/handler.ts @@ -2,7 +2,7 @@ import path from "node:path"; import {getClient} from "@lodestar/api"; import {Builder} from "@lodestar/builder"; import {getNodeLogger} from "@lodestar/logger/node"; -import {fromHex} from "@lodestar/utils"; +import {fromHex, toPrintableUrl} from "@lodestar/utils"; import {getBeaconConfigFromArgs} from "../../config/beaconParams.js"; import {GlobalArgs} from "../../options/index.js"; import {getGlobalPaths} from "../../paths/global.js"; @@ -45,7 +45,12 @@ export async function builderHandler(args: IBuilderCliArgs & GlobalArgs): Promis const abortController = new AbortController(); onGracefulShutdownCbs.push(async () => abortController.abort()); - const api = getClient({urls: [args.beaconNodeUrl], globalInit: {signal: abortController.signal}}, {config, logger}); + const api = getClient( + {urls: [args.beaconNodeUrl], globalInit: {signal: abortController.signal, timeoutMs: args.requestTimeout}}, + {config, logger} + ); + + logger.info("Beacon node", {beaconNode: toPrintableUrl(args.beaconNodeUrl), timeoutMs: args.requestTimeout}); const builder = await Builder.init({ keypair, diff --git a/packages/cli/src/cmds/builder/options.ts b/packages/cli/src/cmds/builder/options.ts index a1a0963a735d..9a91315d58af 100644 --- a/packages/cli/src/cmds/builder/options.ts +++ b/packages/cli/src/cmds/builder/options.ts @@ -8,6 +8,7 @@ export type IBuilderCliArgs = LogArgs & { keystorePassword: string; builderPubkey?: string; executionFeeRecipient: string; + requestTimeout: number; }; export const builderOptions: CliCommandOptions = { @@ -41,4 +42,10 @@ export const builderOptions: CliCommandOptions = { type: "string", demandOption: true, }, + + requestTimeout: { + description: "Timeout in milliseconds for HTTP requests to the beacon node", + type: "number", + default: defaultOptions.requestTimeout, + }, }; From d98ca85acf682dc833496911a0f1422d6a396b02 Mon Sep 17 00:00:00 2001 From: markolazic01 Date: Sun, 9 Aug 2026 00:13:05 +0200 Subject: [PATCH 15/33] test(builder): add BuilderStatusTracker unit tests --- .../services/builderStatusTracker.test.ts | 98 +++++++++++++++++++ packages/builder/test/unit/utils/apiStub.ts | 33 +++++++ packages/builder/test/unit/utils/logger.ts | 14 +++ 3 files changed, 145 insertions(+) create mode 100644 packages/builder/test/unit/services/builderStatusTracker.test.ts create mode 100644 packages/builder/test/unit/utils/apiStub.ts create mode 100644 packages/builder/test/unit/utils/logger.ts diff --git a/packages/builder/test/unit/services/builderStatusTracker.test.ts b/packages/builder/test/unit/services/builderStatusTracker.test.ts new file mode 100644 index 000000000000..9c35aa6ee8aa --- /dev/null +++ b/packages/builder/test/unit/services/builderStatusTracker.test.ts @@ -0,0 +1,98 @@ +import {afterEach, beforeEach, describe, expect, it, vi} from "vitest"; +import {routes} from "@lodestar/api"; +import {ssz} from "@lodestar/types"; +import {BuilderStatusTracker} from "../../../src/services/builderStatusTracker.js"; +import {getApiClientStub, mockApiErrorResponse, mockApiResponse} from "../utils/apiStub.js"; +import {getMockedLogger} from "../utils/logger.js"; + +describe("BuilderStatusTracker", () => { + const logger = getMockedLogger(); + const api = getApiClientStub(); + const builderIndex = 1; + + let builderStatusTracker: BuilderStatusTracker; + + function getMockedApiResponse( + status: routes.beacon.BuilderStatus = "active", + balance = 1 + ): Awaited> { + const builder = ssz.gloas.Builder.defaultValue(); + builder.balance = balance; + return mockApiResponse({ + data: [{index: builderIndex, status, builder}], + meta: {executionOptimistic: true, finalized: false}, + }); + } + + beforeEach(() => { + builderStatusTracker = new BuilderStatusTracker(api, logger, builderIndex); + api.beacon.getStateBuilders.mockResolvedValue(getMockedApiResponse()); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + it("status and balance should initially be undefined", async () => { + const {status, balance} = builderStatusTracker.getStatus(); + expect(status).toBeUndefined(); + expect(balance).toBeUndefined(); + }); + + it("status and balance should give real values after polling", async () => { + await builderStatusTracker.poll(); + const {status, balance} = builderStatusTracker.getStatus(); + expect(status).toEqual("active"); + expect(balance).toEqual(1); + expect(api.beacon.getStateBuilders).toHaveBeenCalledOnce(); + expect(logger.warn).not.toHaveBeenCalled(); + }); + + it("updates balance across polls", async () => { + await builderStatusTracker.poll(); + const {status, balance} = builderStatusTracker.getStatus(); + expect(status).toEqual("active"); + expect(balance).toEqual(1); + expect(api.beacon.getStateBuilders).toHaveBeenCalledOnce(); + + api.beacon.getStateBuilders.mockResolvedValue(getMockedApiResponse("active", 2)); + await builderStatusTracker.poll(); + const {status: newStatus, balance: newBalance} = builderStatusTracker.getStatus(); + expect(newStatus).toEqual("active"); + expect(newBalance).toEqual(2); + expect(api.beacon.getStateBuilders).toHaveBeenCalledTimes(2); + expect(logger.warn).not.toHaveBeenCalled(); + }); + + it("warns on status change", async () => { + await builderStatusTracker.poll(); + const {status, balance} = builderStatusTracker.getStatus(); + expect(status).toEqual("active"); + expect(balance).toEqual(1); + expect(api.beacon.getStateBuilders).toHaveBeenCalledOnce(); + + api.beacon.getStateBuilders.mockResolvedValue(getMockedApiResponse("exited", 1)); + await builderStatusTracker.poll(); + const {status: newStatus, balance: newBalance} = builderStatusTracker.getStatus(); + expect(newStatus).toEqual("exited"); + expect(newBalance).toEqual(1); + expect(api.beacon.getStateBuilders).toHaveBeenCalledTimes(2); + expect(logger.warn).toHaveBeenCalledWith("Builder status changed", {from: "active", to: "exited"}); + }); + + it("dismisses beacon api 500", async () => { + await builderStatusTracker.poll(); + const {status, balance} = builderStatusTracker.getStatus(); + expect(status).toEqual("active"); + expect(balance).toEqual(1); + expect(api.beacon.getStateBuilders).toHaveBeenCalledOnce(); + + api.beacon.getStateBuilders.mockResolvedValue(mockApiErrorResponse(500)); + // poll should resolve when beacon node throws 500 + await builderStatusTracker.poll(); + const {status: newStatus, balance: newBalance} = builderStatusTracker.getStatus(); + expect(newStatus).toEqual("active"); + expect(newBalance).toEqual(1); + expect(api.beacon.getStateBuilders).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/builder/test/unit/utils/apiStub.ts b/packages/builder/test/unit/utils/apiStub.ts new file mode 100644 index 000000000000..98a10ced4269 --- /dev/null +++ b/packages/builder/test/unit/utils/apiStub.ts @@ -0,0 +1,33 @@ +import {Mocked, vi} from "vitest"; +import {ApiClientMethods, ApiResponse, Endpoint, Endpoints, HttpStatusCode, IHttpClient} from "@lodestar/api"; + +type ApiClientStub = {[K in keyof Endpoints]: Mocked>} & { + httpClient: Mocked; +}; + +export function getApiClientStub(): ApiClientStub { + return { + beacon: { + getStateBuilders: vi.fn(), + }, + node: { + getSyncingStatus: vi.fn(), + }, + } as unknown as ApiClientStub; +} + +export function mockApiResponse>({ + data, + meta, +}: (E["return"] extends void ? {data?: never} : {data: E["return"]}) & + (E["meta"] extends void ? {meta?: never} : {meta: E["meta"]})): ApiResponse { + const response = new Response(null, {status: HttpStatusCode.OK}); + const apiResponse = new ApiResponse({} as any, null, response); + apiResponse.value = () => data as T; + apiResponse.meta = () => meta as M; + return apiResponse; +} + +export function mockApiErrorResponse(status: HttpStatusCode): ApiResponse { + return new ApiResponse({} as any, null, new Response(null, {status})); +} diff --git a/packages/builder/test/unit/utils/logger.ts b/packages/builder/test/unit/utils/logger.ts new file mode 100644 index 000000000000..d1708d53342b --- /dev/null +++ b/packages/builder/test/unit/utils/logger.ts @@ -0,0 +1,14 @@ +import {Mocked, vi} from "vitest"; +import {Logger} from "@lodestar/logger"; + +export type MockedLogger = Mocked; + +export function getMockedLogger(): MockedLogger { + return { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + verbose: vi.fn(), + }; +} From 0a13a306c92a46f61874ecf26a6ce2c1818f424e Mon Sep 17 00:00:00 2001 From: markolazic01 Date: Sun, 9 Aug 2026 02:59:54 +0200 Subject: [PATCH 16/33] test(builder): extract getStateBuilders response fixture --- .../services/builderStatusTracker.test.ts | 23 ++++--------------- packages/builder/test/unit/utils/apiStub.ts | 2 +- packages/builder/test/unit/utils/mocks.ts | 18 +++++++++++++++ 3 files changed, 24 insertions(+), 19 deletions(-) create mode 100644 packages/builder/test/unit/utils/mocks.ts diff --git a/packages/builder/test/unit/services/builderStatusTracker.test.ts b/packages/builder/test/unit/services/builderStatusTracker.test.ts index 9c35aa6ee8aa..82b9f4c74d9f 100644 --- a/packages/builder/test/unit/services/builderStatusTracker.test.ts +++ b/packages/builder/test/unit/services/builderStatusTracker.test.ts @@ -1,9 +1,8 @@ import {afterEach, beforeEach, describe, expect, it, vi} from "vitest"; -import {routes} from "@lodestar/api"; -import {ssz} from "@lodestar/types"; import {BuilderStatusTracker} from "../../../src/services/builderStatusTracker.js"; -import {getApiClientStub, mockApiErrorResponse, mockApiResponse} from "../utils/apiStub.js"; +import {getApiClientStub, mockApiErrorResponse} from "../utils/apiStub.js"; import {getMockedLogger} from "../utils/logger.js"; +import {mockGetStateBuildersResponse} from "../utils/mocks.js"; describe("BuilderStatusTracker", () => { const logger = getMockedLogger(); @@ -12,21 +11,9 @@ describe("BuilderStatusTracker", () => { let builderStatusTracker: BuilderStatusTracker; - function getMockedApiResponse( - status: routes.beacon.BuilderStatus = "active", - balance = 1 - ): Awaited> { - const builder = ssz.gloas.Builder.defaultValue(); - builder.balance = balance; - return mockApiResponse({ - data: [{index: builderIndex, status, builder}], - meta: {executionOptimistic: true, finalized: false}, - }); - } - beforeEach(() => { builderStatusTracker = new BuilderStatusTracker(api, logger, builderIndex); - api.beacon.getStateBuilders.mockResolvedValue(getMockedApiResponse()); + api.beacon.getStateBuilders.mockResolvedValue(mockGetStateBuildersResponse(builderIndex)); }); afterEach(() => { @@ -55,7 +42,7 @@ describe("BuilderStatusTracker", () => { expect(balance).toEqual(1); expect(api.beacon.getStateBuilders).toHaveBeenCalledOnce(); - api.beacon.getStateBuilders.mockResolvedValue(getMockedApiResponse("active", 2)); + api.beacon.getStateBuilders.mockResolvedValue(mockGetStateBuildersResponse(builderIndex, "active", 2)); await builderStatusTracker.poll(); const {status: newStatus, balance: newBalance} = builderStatusTracker.getStatus(); expect(newStatus).toEqual("active"); @@ -71,7 +58,7 @@ describe("BuilderStatusTracker", () => { expect(balance).toEqual(1); expect(api.beacon.getStateBuilders).toHaveBeenCalledOnce(); - api.beacon.getStateBuilders.mockResolvedValue(getMockedApiResponse("exited", 1)); + api.beacon.getStateBuilders.mockResolvedValue(mockGetStateBuildersResponse(builderIndex, "exited", 1)); await builderStatusTracker.poll(); const {status: newStatus, balance: newBalance} = builderStatusTracker.getStatus(); expect(newStatus).toEqual("exited"); diff --git a/packages/builder/test/unit/utils/apiStub.ts b/packages/builder/test/unit/utils/apiStub.ts index 98a10ced4269..902ff9350beb 100644 --- a/packages/builder/test/unit/utils/apiStub.ts +++ b/packages/builder/test/unit/utils/apiStub.ts @@ -1,7 +1,7 @@ import {Mocked, vi} from "vitest"; import {ApiClientMethods, ApiResponse, Endpoint, Endpoints, HttpStatusCode, IHttpClient} from "@lodestar/api"; -type ApiClientStub = {[K in keyof Endpoints]: Mocked>} & { +export type ApiClientStub = {[K in keyof Endpoints]: Mocked>} & { httpClient: Mocked; }; diff --git a/packages/builder/test/unit/utils/mocks.ts b/packages/builder/test/unit/utils/mocks.ts new file mode 100644 index 000000000000..cb3c01dc22b3 --- /dev/null +++ b/packages/builder/test/unit/utils/mocks.ts @@ -0,0 +1,18 @@ +import {routes} from "@lodestar/api"; +import {BuilderIndex, ssz} from "@lodestar/types"; +import {ApiClientStub, mockApiResponse} from "./apiStub.js"; + +export function mockGetStateBuildersResponse( + index: BuilderIndex, + status: routes.beacon.BuilderStatus = "active", + balance = 1, + version = 0 +): Awaited> { + const builder = ssz.gloas.Builder.defaultValue(); + builder.balance = balance; + builder.version = version; + return mockApiResponse({ + data: [{index, status, builder}], + meta: {executionOptimistic: true, finalized: false}, + }); +} From 4fa1405da5b849a732369475e92079de68784ea1 Mon Sep 17 00:00:00 2001 From: markolazic01 Date: Sun, 9 Aug 2026 03:51:47 +0200 Subject: [PATCH 17/33] wip: identity tests --- packages/builder/test/unit/identity.test.ts | 37 +++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 packages/builder/test/unit/identity.test.ts diff --git a/packages/builder/test/unit/identity.test.ts b/packages/builder/test/unit/identity.test.ts new file mode 100644 index 000000000000..7d1e17e6d27a --- /dev/null +++ b/packages/builder/test/unit/identity.test.ts @@ -0,0 +1,37 @@ +import {afterEach, beforeEach, describe, expect, it, vi} from "vitest"; +import {getBuilderStatus, resolveBuilderIdentity} from "../../src/identity.js"; +import {getApiClientStub, mockApiErrorResponse} from "./utils/apiStub.js"; +import {getMockedLogger} from "./utils/logger.js"; +import {mockGetStateBuildersResponse} from "./utils/mocks.js"; + +describe("Identity", () => { + const logger = getMockedLogger(); + const api = getApiClientStub(); + const builderIndex = 1; + const builderStatus = "active"; + const builderBalance = 1; + + beforeEach(() => { + api.beacon.getStateBuilders.mockResolvedValue( + mockGetStateBuildersResponse(builderIndex, builderStatus, builderBalance) + ); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + it("successfully gets the builder status", async () => { + const res = await getBuilderStatus(api, logger, builderIndex); + expect(res).not.toBeNull(); + expect(res?.status).toEqual(builderStatus); + expect(res?.balance).toEqual(builderBalance); + }); + + it("fails to fetch the builder status", async () => { + api.beacon.getStateBuilders.mockResolvedValue(mockApiErrorResponse(500)); + const res = await getBuilderStatus(api, logger, builderIndex); + expect(res).toBeNull(); + expect(logger.warn).toHaveBeenCalledOnce(); + }); +}); From 926adbc23b09d46db5d52dc6803e39036c6b2392 Mon Sep 17 00:00:00 2001 From: markolazic01 Date: Sun, 9 Aug 2026 14:15:27 +0200 Subject: [PATCH 18/33] test(builder): identity tests pt2 --- packages/builder/test/unit/identity.test.ts | 63 ++++++++++++++++++--- packages/builder/test/unit/utils/mocks.ts | 3 +- 2 files changed, 57 insertions(+), 9 deletions(-) diff --git a/packages/builder/test/unit/identity.test.ts b/packages/builder/test/unit/identity.test.ts index 7d1e17e6d27a..a3d931952bf8 100644 --- a/packages/builder/test/unit/identity.test.ts +++ b/packages/builder/test/unit/identity.test.ts @@ -1,6 +1,7 @@ -import {afterEach, beforeEach, describe, expect, it, vi} from "vitest"; +import {afterEach, describe, expect, it, vi} from "vitest"; +import {PAYLOAD_BUILDER_VERSION} from "@lodestar/params"; import {getBuilderStatus, resolveBuilderIdentity} from "../../src/identity.js"; -import {getApiClientStub, mockApiErrorResponse} from "./utils/apiStub.js"; +import {getApiClientStub, mockApiErrorResponse, mockApiResponse} from "./utils/apiStub.js"; import {getMockedLogger} from "./utils/logger.js"; import {mockGetStateBuildersResponse} from "./utils/mocks.js"; @@ -9,19 +10,19 @@ describe("Identity", () => { const api = getApiClientStub(); const builderIndex = 1; const builderStatus = "active"; + const builderPubkey = "0xaabb"; const builderBalance = 1; - - beforeEach(() => { - api.beacon.getStateBuilders.mockResolvedValue( - mockGetStateBuildersResponse(builderIndex, builderStatus, builderBalance) - ); - }); + const builderVersion = PAYLOAD_BUILDER_VERSION; afterEach(() => { vi.resetAllMocks(); }); it("successfully gets the builder status", async () => { + api.beacon.getStateBuilders.mockResolvedValue( + mockGetStateBuildersResponse(builderIndex, builderStatus, builderBalance, builderVersion) + ); + const res = await getBuilderStatus(api, logger, builderIndex); expect(res).not.toBeNull(); expect(res?.status).toEqual(builderStatus); @@ -34,4 +35,50 @@ describe("Identity", () => { expect(res).toBeNull(); expect(logger.warn).toHaveBeenCalledOnce(); }); + + it("successfully resolves builder identity", async () => { + api.beacon.getStateBuilders.mockResolvedValue( + mockGetStateBuildersResponse(builderIndex, builderStatus, builderBalance, builderVersion) + ); + + const _builderIndex = await resolveBuilderIdentity(api, logger, builderPubkey); + expect(_builderIndex).toEqual(builderIndex); + }); + + it("throws on version mismatch", async () => { + const version = builderVersion + 1; + api.beacon.getStateBuilders.mockResolvedValue( + mockGetStateBuildersResponse(builderIndex, builderStatus, builderBalance, version) + ); + await expect(resolveBuilderIdentity(api, logger, builderPubkey)).rejects.toThrow( + `Builder version mismatch: got ${version}, expected ${builderVersion}` + ); + expect(api.beacon.getStateBuilders).toHaveBeenCalledWith(expect.objectContaining({builderIds: [builderPubkey]})); + }); + + it("throws on builder not active", async () => { + const inactiveStatus = "exited"; + api.beacon.getStateBuilders.mockResolvedValue(mockGetStateBuildersResponse(builderIndex, inactiveStatus)); + await expect(resolveBuilderIdentity(api, logger, builderPubkey)).rejects.toThrow( + `Builder not active: ${inactiveStatus}` + ); + expect(api.beacon.getStateBuilders).toHaveBeenCalledWith(expect.objectContaining({builderIds: [builderPubkey]})); + }); + + it("throws on builder not known", async () => { + api.beacon.getStateBuilders.mockResolvedValue( + mockApiResponse({data: [], meta: {executionOptimistic: true, finalized: false}}) + ); + await expect(resolveBuilderIdentity(api, logger, builderPubkey)).rejects.toThrow( + `Builder not known to the beacon node: ${builderPubkey}` + ); + }); + + it("throws on beacon node 500", async () => { + const resStatus = 500; + api.beacon.getStateBuilders.mockResolvedValue(mockApiErrorResponse(resStatus)); + await expect(resolveBuilderIdentity(api, logger, builderPubkey)).rejects.toThrow( + `Failed to get builder state from beacon node: ${resStatus}` + ); + }); }); diff --git a/packages/builder/test/unit/utils/mocks.ts b/packages/builder/test/unit/utils/mocks.ts index cb3c01dc22b3..2850de889cfd 100644 --- a/packages/builder/test/unit/utils/mocks.ts +++ b/packages/builder/test/unit/utils/mocks.ts @@ -1,4 +1,5 @@ import {routes} from "@lodestar/api"; +import {PAYLOAD_BUILDER_VERSION} from "@lodestar/params"; import {BuilderIndex, ssz} from "@lodestar/types"; import {ApiClientStub, mockApiResponse} from "./apiStub.js"; @@ -6,7 +7,7 @@ export function mockGetStateBuildersResponse( index: BuilderIndex, status: routes.beacon.BuilderStatus = "active", balance = 1, - version = 0 + version = PAYLOAD_BUILDER_VERSION ): Awaited> { const builder = ssz.gloas.Builder.defaultValue(); builder.balance = balance; From ded9e6278eb2207f1431df4ff33918ac03187c58 Mon Sep 17 00:00:00 2001 From: markolazic01 Date: Sun, 9 Aug 2026 23:57:54 +0200 Subject: [PATCH 19/33] chore: minor improvements --- packages/builder/src/readiness.ts | 2 +- packages/cli/src/cmds/builder/handler.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/builder/src/readiness.ts b/packages/builder/src/readiness.ts index bf5bf83d222b..1a45bb490ef3 100644 --- a/packages/builder/src/readiness.ts +++ b/packages/builder/src/readiness.ts @@ -47,7 +47,7 @@ export async function logNodeVersion(api: ApiClient, logger: Logger): Promise { const {config, network} = getBeaconConfigFromArgs(args); - if (config.forks.gloas.epoch === Infinity) { + if (config.GLOAS_FORK_EPOCH === Infinity) { throw Error(`Gloas must be scheduled via GLOAS_FORK_EPOCH (network: ${network})`); } From 1b056f91f2155f5c2f928c7f256e3150c09a5081 Mon Sep 17 00:00:00 2001 From: markolazic01 Date: Mon, 10 Aug 2026 00:06:17 +0200 Subject: [PATCH 20/33] fix: status change log warn -> info --- packages/builder/src/services/builderStatusTracker.ts | 2 +- .../builder/test/unit/services/builderStatusTracker.test.ts | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/builder/src/services/builderStatusTracker.ts b/packages/builder/src/services/builderStatusTracker.ts index 6748b6acd369..6c4255e1f3b1 100644 --- a/packages/builder/src/services/builderStatusTracker.ts +++ b/packages/builder/src/services/builderStatusTracker.ts @@ -25,7 +25,7 @@ export class BuilderStatusTracker { const builderStatus = await getBuilderStatus(this.api, this.logger, this.index); if (builderStatus !== null) { if (this.status !== undefined && this.status !== builderStatus.status) { - this.logger.warn("Builder status changed", {from: this.status, to: builderStatus.status}); + this.logger.info("Builder status changed", {from: this.status, to: builderStatus.status}); } this.status = builderStatus.status; this.balanceGwei = builderStatus.balance; diff --git a/packages/builder/test/unit/services/builderStatusTracker.test.ts b/packages/builder/test/unit/services/builderStatusTracker.test.ts index 82b9f4c74d9f..8c844317b338 100644 --- a/packages/builder/test/unit/services/builderStatusTracker.test.ts +++ b/packages/builder/test/unit/services/builderStatusTracker.test.ts @@ -48,10 +48,10 @@ describe("BuilderStatusTracker", () => { expect(newStatus).toEqual("active"); expect(newBalance).toEqual(2); expect(api.beacon.getStateBuilders).toHaveBeenCalledTimes(2); - expect(logger.warn).not.toHaveBeenCalled(); + expect(logger.info).not.toHaveBeenCalledWith("Builder status changed", expect.anything()); }); - it("warns on status change", async () => { + it("logs on status change", async () => { await builderStatusTracker.poll(); const {status, balance} = builderStatusTracker.getStatus(); expect(status).toEqual("active"); @@ -64,7 +64,7 @@ describe("BuilderStatusTracker", () => { expect(newStatus).toEqual("exited"); expect(newBalance).toEqual(1); expect(api.beacon.getStateBuilders).toHaveBeenCalledTimes(2); - expect(logger.warn).toHaveBeenCalledWith("Builder status changed", {from: "active", to: "exited"}); + expect(logger.info).toHaveBeenCalledWith("Builder status changed", {from: "active", to: "exited"}); }); it("dismisses beacon api 500", async () => { From 8c83008da9adbb8e0fdf81b77b8a77e80c9d4b2c Mon Sep 17 00:00:00 2001 From: markolazic01 Date: Mon, 10 Aug 2026 00:24:57 +0200 Subject: [PATCH 21/33] chore: log error messages --- packages/builder/src/identity.ts | 3 ++- packages/builder/src/readiness.ts | 6 +++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/builder/src/identity.ts b/packages/builder/src/identity.ts index 275783f1b050..dbdac10d09d1 100644 --- a/packages/builder/src/identity.ts +++ b/packages/builder/src/identity.ts @@ -52,7 +52,8 @@ async function fetchBuilder(api: ApiClient, id: routes.beacon.BuilderId): Promis }); if (!builderRes.ok) { - throw Error(`Failed to get builder state from beacon node: ${builderRes.status}`); + await builderRes.errorBody(); + throw Error(`Failed to get builder state from beacon node: ${builderRes.status} - ${builderRes.error()?.message}`); } const builders = builderRes.value(); diff --git a/packages/builder/src/readiness.ts b/packages/builder/src/readiness.ts index 1a45bb490ef3..4ef350bc7bb5 100644 --- a/packages/builder/src/readiness.ts +++ b/packages/builder/src/readiness.ts @@ -15,7 +15,11 @@ async function isNodeReady(api: ApiClient, logger: Logger): Promise { const syncingStatusRes = await api.node.getSyncingStatus(); if (!syncingStatusRes.ok) { - logger.warn("Cannot get node sync status", {status: syncingStatusRes.status}); + await syncingStatusRes.errorBody(); + logger.warn("Cannot get node sync status", { + status: syncingStatusRes.status, + message: syncingStatusRes.error()?.message, + }); return false; } From 51c3493b2b7cfd788d665b99611de9856d6307b1 Mon Sep 17 00:00:00 2001 From: markolazic01 Date: Mon, 10 Aug 2026 00:31:13 +0200 Subject: [PATCH 22/33] refactor: genesis warn --- packages/builder/src/genesis.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/builder/src/genesis.ts b/packages/builder/src/genesis.ts index 4e4351aa4950..fe2521a143d1 100644 --- a/packages/builder/src/genesis.ts +++ b/packages/builder/src/genesis.ts @@ -13,7 +13,7 @@ export async function waitForGenesis(api: ApiClient, logger: Logger, signal: Abo if (e instanceof ApiError && e.status === HttpStatusCode.NOT_FOUND) { logger.info("Waiting for genesis", {message: e.message}); } else { - logger.warn("Failed to fetch genesis", {}, e as Error); + logger.warn("Failed to fetch genesis", {message: (e as Error).message}); } await sleep(WAITING_FOR_GENESIS_POLL_MS, signal); } From 300e29c839d1873ed3eb8d8878c2b8e6e26c0971 Mon Sep 17 00:00:00 2001 From: Marko Lazic Date: Mon, 10 Aug 2026 16:01:41 +0200 Subject: [PATCH 23/33] Update packages/cli/src/cmds/builder/handler.ts Co-authored-by: Nico Flaig --- packages/cli/src/cmds/builder/handler.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/cmds/builder/handler.ts b/packages/cli/src/cmds/builder/handler.ts index 87090cfc73d6..3364926c7200 100644 --- a/packages/cli/src/cmds/builder/handler.ts +++ b/packages/cli/src/cmds/builder/handler.ts @@ -16,7 +16,7 @@ export async function builderHandler(args: IBuilderCliArgs & GlobalArgs): Promis const {config, network} = getBeaconConfigFromArgs(args); if (config.GLOAS_FORK_EPOCH === Infinity) { - throw Error(`Gloas must be scheduled via GLOAS_FORK_EPOCH (network: ${network})`); + throw Error(`Gloas must be scheduled via GLOAS_FORK_EPOCH for network=${network}`); } const globalPaths = getGlobalPaths(args, network); From ce6e28d47d7d1ac81b64be8e2709118e73d6593c Mon Sep 17 00:00:00 2001 From: Marko Lazic Date: Mon, 10 Aug 2026 16:02:08 +0200 Subject: [PATCH 24/33] Update packages/builder/src/builder.ts Co-authored-by: Nico Flaig --- packages/builder/src/builder.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/builder/src/builder.ts b/packages/builder/src/builder.ts index 8a4180fb0371..0e4a3bd48347 100644 --- a/packages/builder/src/builder.ts +++ b/packages/builder/src/builder.ts @@ -61,7 +61,7 @@ export class Builder { static async init(opts: BuilderOptions): Promise { const {api, logger} = opts; const genesis = await waitForGenesis(api, logger, opts.abortController.signal); - logger.info("Genesis fetched from the beacon node", {genesisValidatorsRoot: toHex(genesis.genesisValidatorsRoot)}); + logger.info("Genesis fetched from the beacon node", {genesisValidatorsRoot: toRootHex(genesis.genesisValidatorsRoot)}); const specRes = await api.config.getSpec(); assertEqualParams(opts.config, specRes.value()); From 0e25f090cfcddc89859c727a0ab428093a93e7a0 Mon Sep 17 00:00:00 2001 From: markolazic01 Date: Mon, 10 Aug 2026 17:41:46 +0200 Subject: [PATCH 25/33] refactor: remove builder response check --- packages/builder/src/identity.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/packages/builder/src/identity.ts b/packages/builder/src/identity.ts index dbdac10d09d1..fb043d6c744c 100644 --- a/packages/builder/src/identity.ts +++ b/packages/builder/src/identity.ts @@ -51,11 +51,6 @@ async function fetchBuilder(api: ApiClient, id: routes.beacon.BuilderId): Promis builderIds: [id], }); - if (!builderRes.ok) { - await builderRes.errorBody(); - throw Error(`Failed to get builder state from beacon node: ${builderRes.status} - ${builderRes.error()?.message}`); - } - const builders = builderRes.value(); if (builders.length === 0) { From 7386db08081ce635662d6898c925036cd2015b71 Mon Sep 17 00:00:00 2001 From: markolazic01 Date: Mon, 10 Aug 2026 17:42:09 +0200 Subject: [PATCH 26/33] fix: mockApiErrorResponse --- packages/builder/test/unit/identity.test.ts | 8 +++----- .../test/unit/services/builderStatusTracker.test.ts | 2 +- packages/builder/test/unit/utils/apiStub.ts | 6 ++++-- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/builder/test/unit/identity.test.ts b/packages/builder/test/unit/identity.test.ts index a3d931952bf8..2bd83b9358f3 100644 --- a/packages/builder/test/unit/identity.test.ts +++ b/packages/builder/test/unit/identity.test.ts @@ -30,7 +30,7 @@ describe("Identity", () => { }); it("fails to fetch the builder status", async () => { - api.beacon.getStateBuilders.mockResolvedValue(mockApiErrorResponse(500)); + api.beacon.getStateBuilders.mockResolvedValue(await mockApiErrorResponse(500)); const res = await getBuilderStatus(api, logger, builderIndex); expect(res).toBeNull(); expect(logger.warn).toHaveBeenCalledOnce(); @@ -76,9 +76,7 @@ describe("Identity", () => { it("throws on beacon node 500", async () => { const resStatus = 500; - api.beacon.getStateBuilders.mockResolvedValue(mockApiErrorResponse(resStatus)); - await expect(resolveBuilderIdentity(api, logger, builderPubkey)).rejects.toThrow( - `Failed to get builder state from beacon node: ${resStatus}` - ); + api.beacon.getStateBuilders.mockResolvedValue(await mockApiErrorResponse(resStatus)); + await expect(resolveBuilderIdentity(api, logger, builderPubkey)).rejects.toThrow(/status 500/); }); }); diff --git a/packages/builder/test/unit/services/builderStatusTracker.test.ts b/packages/builder/test/unit/services/builderStatusTracker.test.ts index 8c844317b338..844e48c0ed80 100644 --- a/packages/builder/test/unit/services/builderStatusTracker.test.ts +++ b/packages/builder/test/unit/services/builderStatusTracker.test.ts @@ -74,7 +74,7 @@ describe("BuilderStatusTracker", () => { expect(balance).toEqual(1); expect(api.beacon.getStateBuilders).toHaveBeenCalledOnce(); - api.beacon.getStateBuilders.mockResolvedValue(mockApiErrorResponse(500)); + api.beacon.getStateBuilders.mockResolvedValue(await mockApiErrorResponse(500)); // poll should resolve when beacon node throws 500 await builderStatusTracker.poll(); const {status: newStatus, balance: newBalance} = builderStatusTracker.getStatus(); diff --git a/packages/builder/test/unit/utils/apiStub.ts b/packages/builder/test/unit/utils/apiStub.ts index 902ff9350beb..402cc9e8e21c 100644 --- a/packages/builder/test/unit/utils/apiStub.ts +++ b/packages/builder/test/unit/utils/apiStub.ts @@ -28,6 +28,8 @@ export function mockApiResponse>({ return apiResponse; } -export function mockApiErrorResponse(status: HttpStatusCode): ApiResponse { - return new ApiResponse({} as any, null, new Response(null, {status})); +export async function mockApiErrorResponse(status: HttpStatusCode): Promise> { + const res = new ApiResponse({} as any, null, new Response(null, {status})); + await res.errorBody(); + return res; } From 460616e293c8a5c1e5733e82146208669c0190ca Mon Sep 17 00:00:00 2001 From: markolazic01 Date: Mon, 10 Aug 2026 18:20:10 +0200 Subject: [PATCH 27/33] fix: drop await errorBody() --- packages/builder/src/readiness.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/builder/src/readiness.ts b/packages/builder/src/readiness.ts index 4ef350bc7bb5..020a37f2ccc0 100644 --- a/packages/builder/src/readiness.ts +++ b/packages/builder/src/readiness.ts @@ -15,7 +15,6 @@ async function isNodeReady(api: ApiClient, logger: Logger): Promise { const syncingStatusRes = await api.node.getSyncingStatus(); if (!syncingStatusRes.ok) { - await syncingStatusRes.errorBody(); logger.warn("Cannot get node sync status", { status: syncingStatusRes.status, message: syncingStatusRes.error()?.message, From 251347b7b5c5723a84ca6851e603604cdc75cd80 Mon Sep 17 00:00:00 2001 From: markolazic01 Date: Mon, 10 Aug 2026 18:21:13 +0200 Subject: [PATCH 28/33] fix: add missing import --- packages/builder/src/builder.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/builder/src/builder.ts b/packages/builder/src/builder.ts index 0e4a3bd48347..eefa783fd09b 100644 --- a/packages/builder/src/builder.ts +++ b/packages/builder/src/builder.ts @@ -2,7 +2,7 @@ import {ApiClient} from "@lodestar/api"; import {ChainForkConfig, assertEqualParams, createBeaconConfig} from "@lodestar/config"; import {Clock, ClockOptions, IClock} from "@lodestar/state-transition"; import {BuilderIndex, ExecutionAddress} from "@lodestar/types"; -import {Logger, toHex} from "@lodestar/utils"; +import {Logger, toHex, toRootHex} from "@lodestar/utils"; import {waitForGenesis} from "./genesis.js"; import {resolveBuilderIdentity} from "./identity.js"; import {logNodeVersion, waitForNodeReady} from "./readiness.js"; @@ -61,7 +61,9 @@ export class Builder { static async init(opts: BuilderOptions): Promise { const {api, logger} = opts; const genesis = await waitForGenesis(api, logger, opts.abortController.signal); - logger.info("Genesis fetched from the beacon node", {genesisValidatorsRoot: toRootHex(genesis.genesisValidatorsRoot)}); + logger.info("Genesis fetched from the beacon node", { + genesisValidatorsRoot: toRootHex(genesis.genesisValidatorsRoot), + }); const specRes = await api.config.getSpec(); assertEqualParams(opts.config, specRes.value()); From c04b3c36e5c65b343a72cc772c80f3ed0de765e3 Mon Sep 17 00:00:00 2001 From: markolazic01 Date: Thu, 13 Aug 2026 18:42:45 +0200 Subject: [PATCH 29/33] feat(builder): wait for builder to become known to the beacon node --- packages/builder/src/builder.ts | 2 +- packages/builder/src/identity.ts | 44 ++++++++++++++++----- packages/builder/test/unit/identity.test.ts | 34 +++++++++++----- 3 files changed, 60 insertions(+), 20 deletions(-) diff --git a/packages/builder/src/builder.ts b/packages/builder/src/builder.ts index eefa783fd09b..bccb53976f34 100644 --- a/packages/builder/src/builder.ts +++ b/packages/builder/src/builder.ts @@ -75,7 +75,7 @@ export class Builder { await waitForNodeReady(api, logger, opts.abortController.signal); await logNodeVersion(api, logger); - const index = await resolveBuilderIdentity(api, logger, builderSigner.getPubkeyHex()); + const index = await resolveBuilderIdentity(api, logger, builderSigner.getPubkeyHex(), opts.abortController.signal); const clock = new Clock(config, logger, {genesisTime: Number(genesis.genesisTime), ...opts.clock}); diff --git a/packages/builder/src/identity.ts b/packages/builder/src/identity.ts index fb043d6c744c..4d16a393f2e4 100644 --- a/packages/builder/src/identity.ts +++ b/packages/builder/src/identity.ts @@ -1,14 +1,17 @@ import {ApiClient, routes} from "@lodestar/api"; import {PAYLOAD_BUILDER_VERSION} from "@lodestar/params"; import {BuilderIndex, BuilderStatus} from "@lodestar/types"; -import {Logger, toHex} from "@lodestar/utils"; +import {Logger, sleep, toHex} from "@lodestar/utils"; + +export const WAITING_FOR_BUILDER_POLL_MS = 10 * 1000; export async function resolveBuilderIdentity( api: ApiClient, logger: Logger, - id: routes.beacon.BuilderId + id: routes.beacon.BuilderId, + signal: AbortSignal ): Promise { - const builderEntry = await fetchBuilder(api, id); + const builderEntry = await waitForBuilder(api, logger, id, signal); if (builderEntry.status !== "active") { throw Error(`Builder not active: ${builderEntry.status}`); @@ -35,17 +38,40 @@ export async function getBuilderStatus( ): Promise<{status: BuilderStatus; balance: number} | null> { try { const builderEntry = await fetchBuilder(api, id); - return { - status: builderEntry.status, - balance: builderEntry.builder.balance, - }; + if (builderEntry) { + return { + status: builderEntry.status, + balance: builderEntry.builder.balance, + }; + } + logger.warn("Builder status not available in beacon node"); + return null; } catch (e) { logger.warn("Couldn't fetch the builder", {}, e as Error); return null; } } -async function fetchBuilder(api: ApiClient, id: routes.beacon.BuilderId): Promise { +async function waitForBuilder( + api: ApiClient, + logger: Logger, + id: routes.beacon.BuilderId, + signal: AbortSignal +): Promise { + while (true) { + const builder = await fetchBuilder(api, id); + if (builder !== null) { + return builder; + } + logger.info("Waiting for builder to be known to the beacon node", {id}); + await sleep(WAITING_FOR_BUILDER_POLL_MS, signal); + } +} + +async function fetchBuilder( + api: ApiClient, + id: routes.beacon.BuilderId +): Promise { const builderRes = await api.beacon.getStateBuilders({ stateId: "head", builderIds: [id], @@ -54,7 +80,7 @@ async function fetchBuilder(api: ApiClient, id: routes.beacon.BuilderId): Promis const builders = builderRes.value(); if (builders.length === 0) { - throw Error(`Builder not known to the beacon node: ${id}`); + return null; } return builders[0]; diff --git a/packages/builder/test/unit/identity.test.ts b/packages/builder/test/unit/identity.test.ts index 2bd83b9358f3..b37b6574db41 100644 --- a/packages/builder/test/unit/identity.test.ts +++ b/packages/builder/test/unit/identity.test.ts @@ -1,6 +1,6 @@ -import {afterEach, describe, expect, it, vi} from "vitest"; +import {afterEach, beforeEach, describe, expect, it, vi} from "vitest"; import {PAYLOAD_BUILDER_VERSION} from "@lodestar/params"; -import {getBuilderStatus, resolveBuilderIdentity} from "../../src/identity.js"; +import {WAITING_FOR_BUILDER_POLL_MS, getBuilderStatus, resolveBuilderIdentity} from "../../src/identity.js"; import {getApiClientStub, mockApiErrorResponse, mockApiResponse} from "./utils/apiStub.js"; import {getMockedLogger} from "./utils/logger.js"; import {mockGetStateBuildersResponse} from "./utils/mocks.js"; @@ -14,8 +14,15 @@ describe("Identity", () => { const builderBalance = 1; const builderVersion = PAYLOAD_BUILDER_VERSION; + let abortController: AbortController; + + beforeEach(() => { + abortController = new AbortController(); + }); + afterEach(() => { vi.resetAllMocks(); + vi.useRealTimers(); }); it("successfully gets the builder status", async () => { @@ -41,7 +48,7 @@ describe("Identity", () => { mockGetStateBuildersResponse(builderIndex, builderStatus, builderBalance, builderVersion) ); - const _builderIndex = await resolveBuilderIdentity(api, logger, builderPubkey); + const _builderIndex = await resolveBuilderIdentity(api, logger, builderPubkey, abortController.signal); expect(_builderIndex).toEqual(builderIndex); }); @@ -50,7 +57,7 @@ describe("Identity", () => { api.beacon.getStateBuilders.mockResolvedValue( mockGetStateBuildersResponse(builderIndex, builderStatus, builderBalance, version) ); - await expect(resolveBuilderIdentity(api, logger, builderPubkey)).rejects.toThrow( + await expect(resolveBuilderIdentity(api, logger, builderPubkey, abortController.signal)).rejects.toThrow( `Builder version mismatch: got ${version}, expected ${builderVersion}` ); expect(api.beacon.getStateBuilders).toHaveBeenCalledWith(expect.objectContaining({builderIds: [builderPubkey]})); @@ -59,24 +66,31 @@ describe("Identity", () => { it("throws on builder not active", async () => { const inactiveStatus = "exited"; api.beacon.getStateBuilders.mockResolvedValue(mockGetStateBuildersResponse(builderIndex, inactiveStatus)); - await expect(resolveBuilderIdentity(api, logger, builderPubkey)).rejects.toThrow( + await expect(resolveBuilderIdentity(api, logger, builderPubkey, abortController.signal)).rejects.toThrow( `Builder not active: ${inactiveStatus}` ); expect(api.beacon.getStateBuilders).toHaveBeenCalledWith(expect.objectContaining({builderIds: [builderPubkey]})); }); - it("throws on builder not known", async () => { - api.beacon.getStateBuilders.mockResolvedValue( + it("waits for beacon node to return the builder", async () => { + vi.useFakeTimers(); + api.beacon.getStateBuilders.mockResolvedValueOnce( mockApiResponse({data: [], meta: {executionOptimistic: true, finalized: false}}) ); - await expect(resolveBuilderIdentity(api, logger, builderPubkey)).rejects.toThrow( - `Builder not known to the beacon node: ${builderPubkey}` + api.beacon.getStateBuilders.mockResolvedValue( + mockGetStateBuildersResponse(builderIndex, builderStatus, builderBalance, builderVersion) ); + const promise = resolveBuilderIdentity(api, logger, builderPubkey, abortController.signal); + await vi.advanceTimersByTimeAsync(WAITING_FOR_BUILDER_POLL_MS); + expect(await promise).toEqual(builderIndex); + expect(api.beacon.getStateBuilders).toHaveBeenCalledTimes(2); }); it("throws on beacon node 500", async () => { const resStatus = 500; api.beacon.getStateBuilders.mockResolvedValue(await mockApiErrorResponse(resStatus)); - await expect(resolveBuilderIdentity(api, logger, builderPubkey)).rejects.toThrow(/status 500/); + await expect(resolveBuilderIdentity(api, logger, builderPubkey, abortController.signal)).rejects.toThrow( + /status 500/ + ); }); }); From 9916c3438e3c3e0cf55f8a5f3dc3c9222c936945 Mon Sep 17 00:00:00 2001 From: markolazic01 Date: Thu, 13 Aug 2026 19:17:44 +0200 Subject: [PATCH 30/33] feat(builder): wait for pending builder to become active --- packages/builder/src/identity.ts | 15 +++++++++------ packages/builder/test/unit/identity.test.ts | 21 +++++++++++++++++---- 2 files changed, 26 insertions(+), 10 deletions(-) diff --git a/packages/builder/src/identity.ts b/packages/builder/src/identity.ts index 4d16a393f2e4..41d3ea663848 100644 --- a/packages/builder/src/identity.ts +++ b/packages/builder/src/identity.ts @@ -13,10 +13,6 @@ export async function resolveBuilderIdentity( ): Promise { const builderEntry = await waitForBuilder(api, logger, id, signal); - if (builderEntry.status !== "active") { - throw Error(`Builder not active: ${builderEntry.status}`); - } - if (builderEntry.builder.version !== PAYLOAD_BUILDER_VERSION) { throw Error(`Builder version mismatch: got ${builderEntry.builder.version}, expected ${PAYLOAD_BUILDER_VERSION}`); } @@ -60,10 +56,17 @@ async function waitForBuilder( ): Promise { while (true) { const builder = await fetchBuilder(api, id); - if (builder !== null) { + if (builder?.status === "active") { return builder; } - logger.info("Waiting for builder to be known to the beacon node", {id}); + if (builder?.status === "exited") { + throw Error(`Builder exited: id=${id}`); + } + if (builder?.status === "pending") { + logger.info("Waiting for builder deposit to be finalized", {id}); + } else { + logger.info("Waiting for builder to be known to the beacon node", {id}); + } await sleep(WAITING_FOR_BUILDER_POLL_MS, signal); } } diff --git a/packages/builder/test/unit/identity.test.ts b/packages/builder/test/unit/identity.test.ts index b37b6574db41..28971b102d22 100644 --- a/packages/builder/test/unit/identity.test.ts +++ b/packages/builder/test/unit/identity.test.ts @@ -63,11 +63,10 @@ describe("Identity", () => { expect(api.beacon.getStateBuilders).toHaveBeenCalledWith(expect.objectContaining({builderIds: [builderPubkey]})); }); - it("throws on builder not active", async () => { - const inactiveStatus = "exited"; - api.beacon.getStateBuilders.mockResolvedValue(mockGetStateBuildersResponse(builderIndex, inactiveStatus)); + it("throws on builder status exited", async () => { + api.beacon.getStateBuilders.mockResolvedValue(mockGetStateBuildersResponse(builderIndex, "exited")); await expect(resolveBuilderIdentity(api, logger, builderPubkey, abortController.signal)).rejects.toThrow( - `Builder not active: ${inactiveStatus}` + `Builder exited: id=${builderPubkey}` ); expect(api.beacon.getStateBuilders).toHaveBeenCalledWith(expect.objectContaining({builderIds: [builderPubkey]})); }); @@ -86,6 +85,20 @@ describe("Identity", () => { expect(api.beacon.getStateBuilders).toHaveBeenCalledTimes(2); }); + it("waits for a pending builder to become active", async () => { + vi.useFakeTimers(); + api.beacon.getStateBuilders.mockResolvedValueOnce( + mockGetStateBuildersResponse(builderIndex, "pending", builderBalance, builderVersion) + ); + api.beacon.getStateBuilders.mockResolvedValue( + mockGetStateBuildersResponse(builderIndex, builderStatus, builderBalance, builderVersion) + ); + const promise = resolveBuilderIdentity(api, logger, builderPubkey, abortController.signal); + await vi.advanceTimersByTimeAsync(WAITING_FOR_BUILDER_POLL_MS); + expect(await promise).toEqual(builderIndex); + expect(api.beacon.getStateBuilders).toHaveBeenCalledTimes(2); + }); + it("throws on beacon node 500", async () => { const resStatus = 500; api.beacon.getStateBuilders.mockResolvedValue(await mockApiErrorResponse(resStatus)); From aad1b36d155fd95860661ba280c73e32429240fd Mon Sep 17 00:00:00 2001 From: markolazic01 Date: Thu, 13 Aug 2026 23:21:25 +0200 Subject: [PATCH 31/33] fix(builder): verify beacon node resolved the requested builder --- packages/builder/src/identity.ts | 12 +++- packages/builder/test/unit/identity.test.ts | 71 +++++++++++-------- .../services/builderStatusTracker.test.ts | 4 +- packages/builder/test/unit/utils/mocks.ts | 10 ++- 4 files changed, 61 insertions(+), 36 deletions(-) diff --git a/packages/builder/src/identity.ts b/packages/builder/src/identity.ts index 41d3ea663848..394226f59130 100644 --- a/packages/builder/src/identity.ts +++ b/packages/builder/src/identity.ts @@ -86,5 +86,15 @@ async function fetchBuilder( return null; } - return builders[0]; + const builder = builders[0]; + + if (typeof id === "number") { + if (id !== builder.index) { + throw Error(`Index mismatch: got=${builder.index} expected=${id}`); + } + } else if (id !== toHex(builder.builder.pubkey)) { + throw Error(`Pubkey mismatch: got=${toHex(builder.builder.pubkey)} expected=${id}`); + } + + return builder; } diff --git a/packages/builder/test/unit/identity.test.ts b/packages/builder/test/unit/identity.test.ts index 28971b102d22..9cc694011d29 100644 --- a/packages/builder/test/unit/identity.test.ts +++ b/packages/builder/test/unit/identity.test.ts @@ -1,5 +1,6 @@ import {afterEach, beforeEach, describe, expect, it, vi} from "vitest"; import {PAYLOAD_BUILDER_VERSION} from "@lodestar/params"; +import {toHex} from "@lodestar/utils"; import {WAITING_FOR_BUILDER_POLL_MS, getBuilderStatus, resolveBuilderIdentity} from "../../src/identity.js"; import {getApiClientStub, mockApiErrorResponse, mockApiResponse} from "./utils/apiStub.js"; import {getMockedLogger} from "./utils/logger.js"; @@ -8,11 +9,12 @@ import {mockGetStateBuildersResponse} from "./utils/mocks.js"; describe("Identity", () => { const logger = getMockedLogger(); const api = getApiClientStub(); - const builderIndex = 1; - const builderStatus = "active"; - const builderPubkey = "0xaabb"; - const builderBalance = 1; - const builderVersion = PAYLOAD_BUILDER_VERSION; + const index = 1; + const status = "active"; + const pubkey = Buffer.alloc(48, 1); + const pubkeyString = toHex(pubkey); + const balance = 1; + const version = PAYLOAD_BUILDER_VERSION; let abortController: AbortController; @@ -27,48 +29,57 @@ describe("Identity", () => { it("successfully gets the builder status", async () => { api.beacon.getStateBuilders.mockResolvedValue( - mockGetStateBuildersResponse(builderIndex, builderStatus, builderBalance, builderVersion) + mockGetStateBuildersResponse(index, {status, pubkey, balance, version}) ); - const res = await getBuilderStatus(api, logger, builderIndex); + const res = await getBuilderStatus(api, logger, index); expect(res).not.toBeNull(); - expect(res?.status).toEqual(builderStatus); - expect(res?.balance).toEqual(builderBalance); + expect(res?.status).toEqual(status); + expect(res?.balance).toEqual(balance); }); it("fails to fetch the builder status", async () => { api.beacon.getStateBuilders.mockResolvedValue(await mockApiErrorResponse(500)); - const res = await getBuilderStatus(api, logger, builderIndex); + const res = await getBuilderStatus(api, logger, index); expect(res).toBeNull(); expect(logger.warn).toHaveBeenCalledOnce(); }); it("successfully resolves builder identity", async () => { api.beacon.getStateBuilders.mockResolvedValue( - mockGetStateBuildersResponse(builderIndex, builderStatus, builderBalance, builderVersion) + mockGetStateBuildersResponse(index, {status, pubkey, balance, version}) ); - const _builderIndex = await resolveBuilderIdentity(api, logger, builderPubkey, abortController.signal); - expect(_builderIndex).toEqual(builderIndex); + const builderIndex = await resolveBuilderIdentity(api, logger, pubkeyString, abortController.signal); + expect(builderIndex).toEqual(index); }); it("throws on version mismatch", async () => { - const version = builderVersion + 1; + const newVersion = version + 1; api.beacon.getStateBuilders.mockResolvedValue( - mockGetStateBuildersResponse(builderIndex, builderStatus, builderBalance, version) + mockGetStateBuildersResponse(index, {status, pubkey, balance, version: newVersion}) ); - await expect(resolveBuilderIdentity(api, logger, builderPubkey, abortController.signal)).rejects.toThrow( - `Builder version mismatch: got ${version}, expected ${builderVersion}` + await expect(resolveBuilderIdentity(api, logger, pubkeyString, abortController.signal)).rejects.toThrow( + `Builder version mismatch: got ${newVersion}, expected ${version}` ); - expect(api.beacon.getStateBuilders).toHaveBeenCalledWith(expect.objectContaining({builderIds: [builderPubkey]})); + expect(api.beacon.getStateBuilders).toHaveBeenCalledWith(expect.objectContaining({builderIds: [pubkeyString]})); + }); + + it("throws on pubkey mismatch", async () => { + const invalidPubkey = Buffer.alloc(48, 2); + api.beacon.getStateBuilders.mockResolvedValue(mockGetStateBuildersResponse(index, {pubkey: invalidPubkey})); + await expect(resolveBuilderIdentity(api, logger, pubkeyString, abortController.signal)).rejects.toThrow( + `Pubkey mismatch: got=${toHex(invalidPubkey)} expected=${pubkeyString}` + ); + expect(api.beacon.getStateBuilders).toHaveBeenCalledWith(expect.objectContaining({builderIds: [pubkeyString]})); }); it("throws on builder status exited", async () => { - api.beacon.getStateBuilders.mockResolvedValue(mockGetStateBuildersResponse(builderIndex, "exited")); - await expect(resolveBuilderIdentity(api, logger, builderPubkey, abortController.signal)).rejects.toThrow( - `Builder exited: id=${builderPubkey}` + api.beacon.getStateBuilders.mockResolvedValue(mockGetStateBuildersResponse(index, {status: "exited", pubkey})); + await expect(resolveBuilderIdentity(api, logger, pubkeyString, abortController.signal)).rejects.toThrow( + `Builder exited: id=${pubkeyString}` ); - expect(api.beacon.getStateBuilders).toHaveBeenCalledWith(expect.objectContaining({builderIds: [builderPubkey]})); + expect(api.beacon.getStateBuilders).toHaveBeenCalledWith(expect.objectContaining({builderIds: [pubkeyString]})); }); it("waits for beacon node to return the builder", async () => { @@ -77,32 +88,32 @@ describe("Identity", () => { mockApiResponse({data: [], meta: {executionOptimistic: true, finalized: false}}) ); api.beacon.getStateBuilders.mockResolvedValue( - mockGetStateBuildersResponse(builderIndex, builderStatus, builderBalance, builderVersion) + mockGetStateBuildersResponse(index, {status, pubkey, balance, version}) ); - const promise = resolveBuilderIdentity(api, logger, builderPubkey, abortController.signal); + const promise = resolveBuilderIdentity(api, logger, pubkeyString, abortController.signal); await vi.advanceTimersByTimeAsync(WAITING_FOR_BUILDER_POLL_MS); - expect(await promise).toEqual(builderIndex); + expect(await promise).toEqual(index); expect(api.beacon.getStateBuilders).toHaveBeenCalledTimes(2); }); it("waits for a pending builder to become active", async () => { vi.useFakeTimers(); api.beacon.getStateBuilders.mockResolvedValueOnce( - mockGetStateBuildersResponse(builderIndex, "pending", builderBalance, builderVersion) + mockGetStateBuildersResponse(index, {status: "pending", pubkey, balance, version}) ); api.beacon.getStateBuilders.mockResolvedValue( - mockGetStateBuildersResponse(builderIndex, builderStatus, builderBalance, builderVersion) + mockGetStateBuildersResponse(index, {status, pubkey, balance, version}) ); - const promise = resolveBuilderIdentity(api, logger, builderPubkey, abortController.signal); + const promise = resolveBuilderIdentity(api, logger, pubkeyString, abortController.signal); await vi.advanceTimersByTimeAsync(WAITING_FOR_BUILDER_POLL_MS); - expect(await promise).toEqual(builderIndex); + expect(await promise).toEqual(index); expect(api.beacon.getStateBuilders).toHaveBeenCalledTimes(2); }); it("throws on beacon node 500", async () => { const resStatus = 500; api.beacon.getStateBuilders.mockResolvedValue(await mockApiErrorResponse(resStatus)); - await expect(resolveBuilderIdentity(api, logger, builderPubkey, abortController.signal)).rejects.toThrow( + await expect(resolveBuilderIdentity(api, logger, pubkeyString, abortController.signal)).rejects.toThrow( /status 500/ ); }); diff --git a/packages/builder/test/unit/services/builderStatusTracker.test.ts b/packages/builder/test/unit/services/builderStatusTracker.test.ts index 844e48c0ed80..6d2cbf86f42d 100644 --- a/packages/builder/test/unit/services/builderStatusTracker.test.ts +++ b/packages/builder/test/unit/services/builderStatusTracker.test.ts @@ -42,7 +42,7 @@ describe("BuilderStatusTracker", () => { expect(balance).toEqual(1); expect(api.beacon.getStateBuilders).toHaveBeenCalledOnce(); - api.beacon.getStateBuilders.mockResolvedValue(mockGetStateBuildersResponse(builderIndex, "active", 2)); + api.beacon.getStateBuilders.mockResolvedValue(mockGetStateBuildersResponse(builderIndex, {status: "active", balance: 2})); await builderStatusTracker.poll(); const {status: newStatus, balance: newBalance} = builderStatusTracker.getStatus(); expect(newStatus).toEqual("active"); @@ -58,7 +58,7 @@ describe("BuilderStatusTracker", () => { expect(balance).toEqual(1); expect(api.beacon.getStateBuilders).toHaveBeenCalledOnce(); - api.beacon.getStateBuilders.mockResolvedValue(mockGetStateBuildersResponse(builderIndex, "exited", 1)); + api.beacon.getStateBuilders.mockResolvedValue(mockGetStateBuildersResponse(builderIndex, {status: "exited", balance: 1})); await builderStatusTracker.poll(); const {status: newStatus, balance: newBalance} = builderStatusTracker.getStatus(); expect(newStatus).toEqual("exited"); diff --git a/packages/builder/test/unit/utils/mocks.ts b/packages/builder/test/unit/utils/mocks.ts index 2850de889cfd..0a3955f73cd1 100644 --- a/packages/builder/test/unit/utils/mocks.ts +++ b/packages/builder/test/unit/utils/mocks.ts @@ -5,13 +5,17 @@ import {ApiClientStub, mockApiResponse} from "./apiStub.js"; export function mockGetStateBuildersResponse( index: BuilderIndex, - status: routes.beacon.BuilderStatus = "active", - balance = 1, - version = PAYLOAD_BUILDER_VERSION + { + status = "active", + pubkey = Buffer.alloc(48), + balance = 1, + version = PAYLOAD_BUILDER_VERSION, + }: {status?: routes.beacon.BuilderStatus; pubkey?: Uint8Array; balance?: number; version?: number} = {} ): Awaited> { const builder = ssz.gloas.Builder.defaultValue(); builder.balance = balance; builder.version = version; + builder.pubkey = pubkey; return mockApiResponse({ data: [{index, status, builder}], meta: {executionOptimistic: true, finalized: false}, From 7a469bbafa92d66c73d84ea6e2fd5bb8f200c4fb Mon Sep 17 00:00:00 2001 From: markolazic01 Date: Thu, 13 Aug 2026 23:40:13 +0200 Subject: [PATCH 32/33] refactor(builder): operator message split --- packages/builder/src/readiness.ts | 13 ++++++++----- .../test/unit/services/builderStatusTracker.test.ts | 8 ++++++-- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/packages/builder/src/readiness.ts b/packages/builder/src/readiness.ts index 020a37f2ccc0..c9360e73fd0c 100644 --- a/packages/builder/src/readiness.ts +++ b/packages/builder/src/readiness.ts @@ -25,11 +25,14 @@ async function isNodeReady(api: ApiClient, logger: Logger): Promise { const syncingStatus = syncingStatusRes.value(); if (syncingStatus.isSyncing || syncingStatus.elOffline) { - logger.info("Beacon node is not ready yet", { - headSlot: syncingStatus.headSlot, - syncDistance: syncingStatus.syncDistance, - elOffline: syncingStatus.elOffline, - }); + logger.info( + syncingStatus.elOffline ? "Beacon node EL is offline, unable to submit bids" : "Beacon node is not ready yet", + { + headSlot: syncingStatus.headSlot, + syncDistance: syncingStatus.syncDistance, + elOffline: syncingStatus.elOffline, + } + ); return false; } diff --git a/packages/builder/test/unit/services/builderStatusTracker.test.ts b/packages/builder/test/unit/services/builderStatusTracker.test.ts index 6d2cbf86f42d..80b10c1811ae 100644 --- a/packages/builder/test/unit/services/builderStatusTracker.test.ts +++ b/packages/builder/test/unit/services/builderStatusTracker.test.ts @@ -42,7 +42,9 @@ describe("BuilderStatusTracker", () => { expect(balance).toEqual(1); expect(api.beacon.getStateBuilders).toHaveBeenCalledOnce(); - api.beacon.getStateBuilders.mockResolvedValue(mockGetStateBuildersResponse(builderIndex, {status: "active", balance: 2})); + api.beacon.getStateBuilders.mockResolvedValue( + mockGetStateBuildersResponse(builderIndex, {status: "active", balance: 2}) + ); await builderStatusTracker.poll(); const {status: newStatus, balance: newBalance} = builderStatusTracker.getStatus(); expect(newStatus).toEqual("active"); @@ -58,7 +60,9 @@ describe("BuilderStatusTracker", () => { expect(balance).toEqual(1); expect(api.beacon.getStateBuilders).toHaveBeenCalledOnce(); - api.beacon.getStateBuilders.mockResolvedValue(mockGetStateBuildersResponse(builderIndex, {status: "exited", balance: 1})); + api.beacon.getStateBuilders.mockResolvedValue( + mockGetStateBuildersResponse(builderIndex, {status: "exited", balance: 1}) + ); await builderStatusTracker.poll(); const {status: newStatus, balance: newBalance} = builderStatusTracker.getStatus(); expect(newStatus).toEqual("exited"); From a71c4f04bf8f37bdedf421fc46bf73c1b55d75be Mon Sep 17 00:00:00 2001 From: markolazic01 Date: Thu, 13 Aug 2026 23:56:26 +0200 Subject: [PATCH 33/33] refactor(builder): operator message isOptimistic --- packages/builder/src/readiness.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/builder/src/readiness.ts b/packages/builder/src/readiness.ts index c9360e73fd0c..0b56834bc773 100644 --- a/packages/builder/src/readiness.ts +++ b/packages/builder/src/readiness.ts @@ -36,12 +36,13 @@ async function isNodeReady(api: ApiClient, logger: Logger): Promise { return false; } - logger.info("Beacon node is ready", {headSlot: syncingStatus.headSlot}); - if (syncingStatus.isOptimistic) { - logger.warn("Beacon node head is optimistic, execution payloads are not yet verified"); + logger.warn("Beacon node head is optimistic, execution payloads are not yet verified - unable to submit bids"); + return false; } + logger.info("Beacon node is ready", {headSlot: syncingStatus.headSlot}); + return true; } catch (e) { logger.warn("Cannot reach the beacon node", {}, e as Error);