Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
f9c1ca7
refactor: extract builder identity resolution to identity.ts
markolazic01 Aug 4, 2026
6dd7f0c
feat: add builder status query and share builder fetch helper
markolazic01 Aug 4, 2026
dd2bc25
feat: check beacon node readiness on startup
markolazic01 Aug 5, 2026
4cbb72b
chore: log builder index
markolazic01 Aug 5, 2026
0d22e6d
feat: wait for beacon node readiness instead of failing at startup
markolazic01 Aug 5, 2026
35aec8d
feat: add executionFeeRecipient cli option
markolazic01 Aug 5, 2026
42c7085
feat: gloas fork schedule check
markolazic01 Aug 5, 2026
8424ecf
chore: log gvr
markolazic01 Aug 5, 2026
14dbd09
refactor: throw typed errors from fetchBuilder
markolazic01 Aug 5, 2026
f5f50d0
feat: add builder status tracker
markolazic01 Aug 5, 2026
96dafe8
feat: poll builder status and balance every epoch
markolazic01 Aug 6, 2026
6764758
Merge branch 'unstable' into feat/builder-api-01
markolazic01 Aug 6, 2026
e90ebb2
chore: BuilderStatusTracker description
markolazic01 Aug 6, 2026
354c6f4
chore: zero address as a constant
markolazic01 Aug 6, 2026
87bade7
Merge branch 'unstable' into feat/builder-api-01
markolazic01 Aug 6, 2026
d68905f
feat: requestTimeout cli flag
markolazic01 Aug 7, 2026
5492130
Merge branch 'unstable' into feat/builder-api-01
markolazic01 Aug 7, 2026
d98ca85
test(builder): add BuilderStatusTracker unit tests
markolazic01 Aug 8, 2026
0a13a30
test(builder): extract getStateBuilders response fixture
markolazic01 Aug 9, 2026
4fa1405
wip: identity tests
markolazic01 Aug 9, 2026
926adbc
test(builder): identity tests pt2
markolazic01 Aug 9, 2026
250ae7b
Merge branch 'unstable' into feat/builder-api-01
markolazic01 Aug 9, 2026
ded9e62
chore: minor improvements
markolazic01 Aug 9, 2026
1b056f9
fix: status change log warn -> info
markolazic01 Aug 9, 2026
8c83008
chore: log error messages
markolazic01 Aug 9, 2026
51c3493
refactor: genesis warn
markolazic01 Aug 9, 2026
300e29c
Update packages/cli/src/cmds/builder/handler.ts
markolazic01 Aug 10, 2026
ce6e28d
Update packages/builder/src/builder.ts
markolazic01 Aug 10, 2026
0e25f09
refactor: remove builder response check
markolazic01 Aug 10, 2026
7386db0
fix: mockApiErrorResponse
markolazic01 Aug 10, 2026
81ed67f
Merge branch 'unstable' into feat/builder-api-01
markolazic01 Aug 10, 2026
460616e
fix: drop await errorBody()
markolazic01 Aug 10, 2026
251347b
fix: add missing import
markolazic01 Aug 10, 2026
c04b3c3
feat(builder): wait for builder to become known to the beacon node
markolazic01 Aug 13, 2026
9916c34
feat(builder): wait for pending builder to become active
markolazic01 Aug 13, 2026
aad1b36
fix(builder): verify beacon node resolved the requested builder
markolazic01 Aug 13, 2026
7a469bb
refactor(builder): operator message split
markolazic01 Aug 13, 2026
a71c4f0
refactor(builder): operator message isOptimistic
markolazic01 Aug 13, 2026
3fe4ef4
Merge branch 'unstable' into feat/builder-api-01
markolazic01 Aug 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 38 additions & 34 deletions packages/builder/src/builder.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,20 @@
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 {Logger} from "@lodestar/utils";
import {BuilderIndex, ExecutionAddress} from "@lodestar/types";
import {Logger, toHex, toRootHex} from "@lodestar/utils";
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;
};

export type BuilderOptions = {
Expand All @@ -19,65 +24,64 @@ export type BuilderOptions = {
abortController: AbortController;
api: ApiClient;
clock?: ClockOptions;
executionFeeRecipient: ExecutionAddress;
};

/**
* Main class for the Builder client.
*/
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}: 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;
this.index = index;

this.executionFeeRecipient = opts.executionFeeRecipient;

this.clock.runEveryEpoch(() => this.builderStatusTracker.poll());
this.clock.start(this.controller.signal);

this.logger.info("Builder client initialized", {
index: this.index,
executionFeeRecipient: toHex(this.executionFeeRecipient),
});
}

static async init(opts: BuilderOptions): Promise<Builder> {
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", {
genesisValidatorsRoot: toRootHex(genesis.genesisValidatorsRoot),
});

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()],
});

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: ${builderSigner.getPubkeyHex()}`);
}

const builderStatus: routes.beacon.BuilderResponse = builders[0];
await waitForNodeReady(api, logger, opts.abortController.signal);
await logNodeVersion(api, logger);

if (builderStatus.status !== "active") {
throw Error(`Builder not active: ${builderStatus.status}`);
}
const index = await resolveBuilderIdentity(api, logger, builderSigner.getPubkeyHex(), opts.abortController.signal);

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, logger, {genesisTime: Number(genesis.genesisTime), ...opts.clock});

const clock = new Clock(config, opts.logger, {genesisTime: Number(genesis.genesisTime), ...opts.clock});
const builderStatusTracker = new BuilderStatusTracker(api, logger, index);

return new Builder({opts, builderSigner, clock});
return new Builder({opts, builderSigner, builderStatusTracker, clock, index});
}

async close(): Promise<void> {
Expand Down
1 change: 1 addition & 0 deletions packages/builder/src/defaults.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export const defaultOptions = {
// Source beacon node the builder connects to
beaconNodeUrl: "http://127.0.0.1:9596",
requestTimeout: 10_000,
};
2 changes: 1 addition & 1 deletion packages/builder/src/genesis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Genesis> {
export async function waitForGenesis(api: ApiClient, logger: Logger, signal: AbortSignal): Promise<Genesis> {
while (true) {
try {
return (await api.beacon.getGenesis()).value();
Expand Down
100 changes: 100 additions & 0 deletions packages/builder/src/identity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import {ApiClient, routes} from "@lodestar/api";
import {PAYLOAD_BUILDER_VERSION} from "@lodestar/params";
import {BuilderIndex, BuilderStatus} from "@lodestar/types";
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,
signal: AbortSignal
): Promise<BuilderIndex> {
const builderEntry = await waitForBuilder(api, logger, id, signal);

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;
}

export async function getBuilderStatus(
api: ApiClient,
logger: Logger,
id: routes.beacon.BuilderId
): Promise<{status: BuilderStatus; balance: number} | null> {
try {
const builderEntry = await fetchBuilder(api, id);
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 waitForBuilder(
api: ApiClient,
logger: Logger,
id: routes.beacon.BuilderId,
signal: AbortSignal
): Promise<routes.beacon.BuilderResponse> {
while (true) {
const builder = await fetchBuilder(api, id);
if (builder?.status === "active") {
return builder;
}
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);
}
}

async function fetchBuilder(
api: ApiClient,
id: routes.beacon.BuilderId
): Promise<routes.beacon.BuilderResponse | null> {
const builderRes = await api.beacon.getStateBuilders({
stateId: "head",
builderIds: [id],
});

const builders = builderRes.value();

if (builders.length === 0) {
return null;
}

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;
}
66 changes: 66 additions & 0 deletions packages/builder/src/readiness.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import {ApiClient} from "@lodestar/api";
import {Logger, sleep} from "@lodestar/utils";

/** 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<void> {
while (!(await isNodeReady(api, logger))) {
await sleep(WAITING_FOR_NODE_READY_POLL_MS, signal);
}
}

async function isNodeReady(api: ApiClient, logger: Logger): Promise<boolean> {
try {
const syncingStatusRes = await api.node.getSyncingStatus();

if (!syncingStatusRes.ok) {
logger.warn("Cannot get node sync status", {
status: syncingStatusRes.status,
message: syncingStatusRes.error()?.message,
});
return false;
}
Comment thread
nflaig marked this conversation as resolved.

const syncingStatus = syncingStatusRes.value();

if (syncingStatus.isSyncing || 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;
}

if (syncingStatus.isOptimistic) {
logger.warn("Beacon node head is optimistic, execution payloads are not yet verified - unable to submit bids");
return false;
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

so for the operator it should be clear that in case the EL is syncing we won't be able to submit any bids

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added proper messaging on both isOptimistic and elOffline


logger.info("Beacon node is ready", {headSlot: syncingStatus.headSlot});

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<void> {
Comment thread
nflaig marked this conversation as resolved.
try {
const versionRes = await api.node.getNodeVersionV2();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

some clients may not have this api yet, but that's fine for now, we might need to bully them a bit to implement it, but since it's gloas related, it's fine to assume it's implemented by clients

const version = versionRes.value();
logger.info("Connected node version", {
beaconNode: `${version.beaconNode.name}/${version.beaconNode.version}`,
executionClient: version.executionClient
? `${version.executionClient.name}/${version.executionClient.version}`
: "unknown",
});
} catch (e) {
logger.warn("Failed to get node version", {}, e as Error);
}
}
42 changes: 42 additions & 0 deletions packages/builder/src/services/builderStatusTracker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import {ApiClient} from "@lodestar/api";
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 {
Comment thread
nflaig marked this conversation as resolved.
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.info("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,
};
}
}
Loading
Loading