@lodestar/beacon-node
Version:
A Typescript implementation of the beacon chain
230 lines (205 loc) • 7.92 kB
text/typescript
import {WireFormat} from "@lodestar/api";
import {ApiClient as BuilderApi, getClient} from "@lodestar/api/builder";
import {ChainForkConfig} from "@lodestar/config";
import {Logger} from "@lodestar/logger";
import {ForkPostBellatrix, SLOTS_PER_EPOCH} from "@lodestar/params";
import {parseExecutionPayloadAndBlobsBundle, reconstructSignedBlockContents} from "@lodestar/state-transition";
import {
BLSPubkey,
Epoch,
ExecutionPayloadHeader,
Root,
SignedBlindedBeaconBlock,
SignedBlockContents,
Slot,
Wei,
WithOptionalBytes,
bellatrix,
deneb,
electra,
} from "@lodestar/types";
import {toPrintableUrl} from "@lodestar/utils";
import {Metrics} from "../../metrics/metrics.js";
import {ValidatorRegistration, ValidatorRegistrationCache} from "./cache.js";
import {IExecutionBuilder} from "./interface.js";
export type ExecutionBuilderHttpOpts = {
enabled: boolean;
url: string;
timeout?: number;
faultInspectionWindow?: number;
allowedFaults?: number;
// Only required for merge-mock runs, no need to expose it to cli
issueLocalFcUWithFeeRecipient?: string;
// Add User-Agent header to all requests
userAgent?: string;
};
export const defaultExecutionBuilderHttpOpts: ExecutionBuilderHttpOpts = {
enabled: false,
url: "http://localhost:8661",
timeout: 12000,
};
export enum BuilderStatus {
/**
* Builder is enabled and operational
*/
enabled = "enabled",
/**
* Builder is disabled due to failed status check
*/
disabled = "disabled",
/**
* Circuit breaker condition that is triggered when the node determines the chain is unhealthy.
* When the circuit breaker is fired, proposers **MUST** not utilize the external builder
* network and exclusively build locally.
*/
circuitBreaker = "circuit_breaker",
}
/**
* Expected error if builder does not provide a bid. Most of the time, this
* is due to `min-bid` setting on the mev-boost side but in rare cases could
* also happen if there are no bids from any of the connected relayers.
*/
export class NoBidReceived extends Error {
constructor() {
super("No bid received");
}
}
/**
* Additional duration to account for potential event loop lag which causes
* builder blocks to be rejected even though the response was sent in time.
*/
const EVENT_LOOP_LAG_BUFFER = 250;
/**
* Duration given to the builder to provide a `SignedBuilderBid` before the deadline
* is reached, aborting the external builder flow in favor of the local build process.
*/
const BUILDER_PROPOSAL_DELAY_TOLERANCE = 1000 + EVENT_LOOP_LAG_BUFFER;
export class ExecutionBuilderHttp implements IExecutionBuilder {
readonly api: BuilderApi;
readonly config: ChainForkConfig;
readonly registrations: ValidatorRegistrationCache;
readonly issueLocalFcUWithFeeRecipient?: string;
// Builder needs to be explicity enabled using updateStatus
status = BuilderStatus.disabled;
faultInspectionWindow: number;
allowedFaults: number;
/**
* Determine if SSZ is supported by requesting an SSZ encoded response in the `getHeader` request.
* The builder responding with a SSZ serialized `SignedBuilderBid` indicates support to handle the
* `SignedBlindedBeaconBlock` as SSZ serialized bytes instead of JSON when calling `submitBlindedBlock`.
*/
private sszSupported = false;
constructor(
opts: ExecutionBuilderHttpOpts,
config: ChainForkConfig,
metrics: Metrics | null = null,
logger?: Logger
) {
const baseUrl = opts.url;
if (!baseUrl) throw Error("No Url provided for executionBuilder");
this.api = getClient(
{
baseUrl,
globalInit: {
timeoutMs: opts.timeout,
headers: opts.userAgent ? {"User-Agent": opts.userAgent} : undefined,
},
},
{config, metrics: metrics?.builderHttpClient, logger}
);
logger?.info("External builder", {url: toPrintableUrl(baseUrl)});
this.config = config;
this.registrations = new ValidatorRegistrationCache();
this.issueLocalFcUWithFeeRecipient = opts.issueLocalFcUWithFeeRecipient;
/**
* Beacon clients select randomized values from the following ranges when initializing
* the circuit breaker (so at boot time and once for each unique boot).
*
* ALLOWED_FAULTS: between 1 and SLOTS_PER_EPOCH // 4
* FAULT_INSPECTION_WINDOW: between SLOTS_PER_EPOCH and 2 * SLOTS_PER_EPOCH
*
*/
this.faultInspectionWindow = Math.max(
opts.faultInspectionWindow ?? SLOTS_PER_EPOCH + Math.floor(Math.random() * SLOTS_PER_EPOCH),
SLOTS_PER_EPOCH
);
// allowedFaults should be < faultInspectionWindow, limiting them to faultInspectionWindow/4
this.allowedFaults = Math.min(
opts.allowedFaults ?? Math.floor(this.faultInspectionWindow / 4),
Math.floor(this.faultInspectionWindow / 4)
);
}
updateStatus(status: BuilderStatus): void {
this.status = status;
}
async checkStatus(): Promise<void> {
try {
(await this.api.status()).assertOk();
} catch (e) {
if (this.status === BuilderStatus.enabled) {
// Disable if the status was enabled
this.status = BuilderStatus.disabled;
}
throw e;
}
}
async registerValidator(epoch: Epoch, registrations: bellatrix.SignedValidatorRegistrationV1[]): Promise<void> {
(await this.api.registerValidator({registrations})).assertOk();
for (const registration of registrations) {
this.registrations.add(epoch, registration.message);
}
this.registrations.prune(epoch);
}
getValidatorRegistration(pubkey: BLSPubkey): ValidatorRegistration | undefined {
return this.registrations.get(pubkey);
}
async getHeader(
_fork: ForkPostBellatrix,
slot: Slot,
parentHash: Root,
proposerPubkey: BLSPubkey
): Promise<{
header: ExecutionPayloadHeader;
executionPayloadValue: Wei;
blobKzgCommitments?: deneb.BlobKzgCommitments;
executionRequests?: electra.ExecutionRequests;
}> {
const res = await this.api.getHeader(
{slot, parentHash, proposerPubkey},
{timeoutMs: BUILDER_PROPOSAL_DELAY_TOLERANCE}
);
const signedBuilderBid = res.value();
if (!signedBuilderBid) {
throw new NoBidReceived();
}
this.sszSupported = res.wireFormat() === WireFormat.ssz;
const {header, value: executionPayloadValue} = signedBuilderBid.message;
const {blobKzgCommitments} = signedBuilderBid.message as deneb.BuilderBid;
const {executionRequests} = signedBuilderBid.message as electra.BuilderBid;
return {header, executionPayloadValue, blobKzgCommitments, executionRequests};
}
async submitBlindedBlock(
signedBlindedBlock: WithOptionalBytes<SignedBlindedBeaconBlock>
): Promise<SignedBlockContents> {
const res = await this.api.submitBlindedBlock(
{signedBlindedBlock},
{retries: 2, requestWireFormat: this.sszSupported ? WireFormat.ssz : WireFormat.json}
);
const {executionPayload, blobsBundle} = parseExecutionPayloadAndBlobsBundle(res.value());
// for the sake of timely proposals we can skip matching the payload with payloadHeader
// if the roots (transactions, withdrawals) don't match, this will likely lead to a block with
// invalid signature, but there is no recourse to this anyway so lets just proceed and will
// probably need diagonis if this block turns out to be invalid because of some bug
//
const fork = this.config.getForkName(signedBlindedBlock.data.message.slot);
return reconstructSignedBlockContents(fork, signedBlindedBlock.data, executionPayload, blobsBundle);
}
async submitBlindedBlockNoResponse(signedBlindedBlock: WithOptionalBytes<SignedBlindedBeaconBlock>): Promise<void> {
(
await this.api.submitBlindedBlockV2(
{signedBlindedBlock},
{retries: 2, requestWireFormat: this.sszSupported ? WireFormat.ssz : WireFormat.json}
)
).assertOk();
}
}