diff --git a/packages/builder/src/builder.ts b/packages/builder/src/builder.ts index 1d2cf352b01c..bccb53976f34 100644 --- a/packages/builder/src/builder.ts +++ b/packages/builder/src/builder.ts @@ -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 = { @@ -19,6 +24,7 @@ export type BuilderOptions = { abortController: AbortController; api: ApiClient; clock?: ClockOptions; + executionFeeRecipient: ExecutionAddress; }; /** @@ -26,58 +32,56 @@ 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}: 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 { - 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 { 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/builder/src/genesis.ts b/packages/builder/src/genesis.ts index 978f46ce7c6b..fe2521a143d1 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(); diff --git a/packages/builder/src/identity.ts b/packages/builder/src/identity.ts new file mode 100644 index 000000000000..394226f59130 --- /dev/null +++ b/packages/builder/src/identity.ts @@ -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 { + 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 { + 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 { + 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; +} diff --git a/packages/builder/src/readiness.ts b/packages/builder/src/readiness.ts new file mode 100644 index 000000000000..0b56834bc773 --- /dev/null +++ b/packages/builder/src/readiness.ts @@ -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 { + 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, + message: syncingStatusRes.error()?.message, + }); + return false; + } + + 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; + } + + 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 { + try { + const versionRes = await api.node.getNodeVersionV2(); + 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); + } +} diff --git a/packages/builder/src/services/builderStatusTracker.ts b/packages/builder/src/services/builderStatusTracker.ts new file mode 100644 index 000000000000..6c4255e1f3b1 --- /dev/null +++ b/packages/builder/src/services/builderStatusTracker.ts @@ -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 { + 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, + }; + } +} diff --git a/packages/builder/test/unit/identity.test.ts b/packages/builder/test/unit/identity.test.ts new file mode 100644 index 000000000000..9cc694011d29 --- /dev/null +++ b/packages/builder/test/unit/identity.test.ts @@ -0,0 +1,120 @@ +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"; +import {mockGetStateBuildersResponse} from "./utils/mocks.js"; + +describe("Identity", () => { + const logger = getMockedLogger(); + const api = getApiClientStub(); + 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; + + beforeEach(() => { + abortController = new AbortController(); + }); + + afterEach(() => { + vi.resetAllMocks(); + vi.useRealTimers(); + }); + + it("successfully gets the builder status", async () => { + api.beacon.getStateBuilders.mockResolvedValue( + mockGetStateBuildersResponse(index, {status, pubkey, balance, version}) + ); + + const res = await getBuilderStatus(api, logger, index); + expect(res).not.toBeNull(); + 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, index); + expect(res).toBeNull(); + expect(logger.warn).toHaveBeenCalledOnce(); + }); + + it("successfully resolves builder identity", async () => { + api.beacon.getStateBuilders.mockResolvedValue( + mockGetStateBuildersResponse(index, {status, pubkey, balance, version}) + ); + + const builderIndex = await resolveBuilderIdentity(api, logger, pubkeyString, abortController.signal); + expect(builderIndex).toEqual(index); + }); + + it("throws on version mismatch", async () => { + const newVersion = version + 1; + api.beacon.getStateBuilders.mockResolvedValue( + mockGetStateBuildersResponse(index, {status, pubkey, balance, version: newVersion}) + ); + 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: [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(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: [pubkeyString]})); + }); + + it("waits for beacon node to return the builder", async () => { + vi.useFakeTimers(); + api.beacon.getStateBuilders.mockResolvedValueOnce( + mockApiResponse({data: [], meta: {executionOptimistic: true, finalized: false}}) + ); + api.beacon.getStateBuilders.mockResolvedValue( + mockGetStateBuildersResponse(index, {status, pubkey, balance, version}) + ); + const promise = resolveBuilderIdentity(api, logger, pubkeyString, abortController.signal); + await vi.advanceTimersByTimeAsync(WAITING_FOR_BUILDER_POLL_MS); + 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(index, {status: "pending", pubkey, balance, version}) + ); + api.beacon.getStateBuilders.mockResolvedValue( + mockGetStateBuildersResponse(index, {status, pubkey, balance, version}) + ); + const promise = resolveBuilderIdentity(api, logger, pubkeyString, abortController.signal); + await vi.advanceTimersByTimeAsync(WAITING_FOR_BUILDER_POLL_MS); + 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, 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 new file mode 100644 index 000000000000..80b10c1811ae --- /dev/null +++ b/packages/builder/test/unit/services/builderStatusTracker.test.ts @@ -0,0 +1,89 @@ +import {afterEach, beforeEach, describe, expect, it, vi} from "vitest"; +import {BuilderStatusTracker} from "../../../src/services/builderStatusTracker.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(); + const api = getApiClientStub(); + const builderIndex = 1; + + let builderStatusTracker: BuilderStatusTracker; + + beforeEach(() => { + builderStatusTracker = new BuilderStatusTracker(api, logger, builderIndex); + api.beacon.getStateBuilders.mockResolvedValue(mockGetStateBuildersResponse(builderIndex)); + }); + + 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( + mockGetStateBuildersResponse(builderIndex, {status: "active", balance: 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.info).not.toHaveBeenCalledWith("Builder status changed", expect.anything()); + }); + + it("logs 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( + mockGetStateBuildersResponse(builderIndex, {status: "exited", balance: 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.info).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(await 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..402cc9e8e21c --- /dev/null +++ b/packages/builder/test/unit/utils/apiStub.ts @@ -0,0 +1,35 @@ +import {Mocked, vi} from "vitest"; +import {ApiClientMethods, ApiResponse, Endpoint, Endpoints, HttpStatusCode, IHttpClient} from "@lodestar/api"; + +export 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 async function mockApiErrorResponse(status: HttpStatusCode): Promise> { + const res = new ApiResponse({} as any, null, new Response(null, {status})); + await res.errorBody(); + return res; +} 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(), + }; +} diff --git a/packages/builder/test/unit/utils/mocks.ts b/packages/builder/test/unit/utils/mocks.ts new file mode 100644 index 000000000000..0a3955f73cd1 --- /dev/null +++ b/packages/builder/test/unit/utils/mocks.ts @@ -0,0 +1,23 @@ +import {routes} from "@lodestar/api"; +import {PAYLOAD_BUILDER_VERSION} from "@lodestar/params"; +import {BuilderIndex, ssz} from "@lodestar/types"; +import {ApiClientStub, mockApiResponse} from "./apiStub.js"; + +export function mockGetStateBuildersResponse( + index: BuilderIndex, + { + 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}, + }); +} diff --git a/packages/cli/src/cmds/builder/handler.ts b/packages/cli/src/cmds/builder/handler.ts index 0f89ac31d9fc..3364926c7200 100644 --- a/packages/cli/src/cmds/builder/handler.ts +++ b/packages/cli/src/cmds/builder/handler.ts @@ -2,16 +2,23 @@ import path from "node:path"; import {getClient} from "@lodestar/api"; import {Builder} from "@lodestar/builder"; import {getNodeLogger} from "@lodestar/logger/node"; +import {fromHex, toPrintableUrl} 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"; +const ZERO_ADDRESS = "0x" + "0".repeat(40); + export async function builderHandler(args: IBuilderCliArgs & GlobalArgs): Promise { const {config, network} = getBeaconConfigFromArgs(args); + if (config.GLOAS_FORK_EPOCH === Infinity) { + throw Error(`Gloas must be scheduled via GLOAS_FORK_EPOCH for network=${network}`); + } + const globalPaths = getGlobalPaths(args, network); const defaultLogFilepath = path.join(globalPaths.dataDir, "builder.log"); const logger = getNodeLogger(parseLoggerArgs(args, {defaultLogFilepath}, config)); @@ -22,6 +29,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 === ZERO_ADDRESS) { + throw Error("Cannot put zero address as an executionFeeRecipient"); + } + const keypair = await loadBuilderKeypair(args.keystore, args.keystorePassword, args.builderPubkey); const onGracefulShutdownCbs: (() => Promise | void)[] = []; @@ -32,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, @@ -40,6 +58,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..9a91315d58af 100644 --- a/packages/cli/src/cmds/builder/options.ts +++ b/packages/cli/src/cmds/builder/options.ts @@ -7,6 +7,8 @@ export type IBuilderCliArgs = LogArgs & { keystore: string; keystorePassword: string; builderPubkey?: string; + executionFeeRecipient: string; + requestTimeout: number; }; export const builderOptions: CliCommandOptions = { @@ -34,4 +36,16 @@ 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, + }, + + requestTimeout: { + description: "Timeout in milliseconds for HTTP requests to the beacon node", + type: "number", + default: defaultOptions.requestTimeout, + }, };