@lodestar/beacon-node
Version:
A Typescript implementation of the beacon chain
439 lines (382 loc) • 13.5 kB
text/typescript
import {ForkName, NUMBER_OF_COLUMNS} from "@lodestar/params";
import {ColumnIndex, RootHex, Slot, ValidatorIndex, deneb, gloas} from "@lodestar/types";
import {toRootHex, withTimeout} from "@lodestar/utils";
import {VersionedHashes} from "../../../execution/index.js";
import {kzgCommitmentToVersionedHash} from "../../../util/blobs.js";
import {MissingColumnMeta} from "../blockInput/types.js";
import {
AddPayloadEnvelopeProps,
ColumnWithSource,
CreateFromBidProps,
CreateFromBlockProps,
SourceMeta,
} from "./types.js";
export type PayloadEnvelopeInputState =
| {
hasPayload: false;
hasAllData: false;
hasComputedAllData: false;
}
| {
hasPayload: false;
hasAllData: true;
hasComputedAllData: boolean;
}
| {
hasPayload: true;
hasAllData: false;
hasComputedAllData: false;
payloadEnvelope: gloas.SignedExecutionPayloadEnvelope;
payloadEnvelopeSource: SourceMeta;
}
| {
hasPayload: true;
hasAllData: true;
hasComputedAllData: boolean;
payloadEnvelope: gloas.SignedExecutionPayloadEnvelope;
payloadEnvelopeSource: SourceMeta;
timeCompleteSec: number;
};
type PromiseParts<T> = {
promise: Promise<T>;
resolve: (value: T) => void;
reject: (e: Error) => void;
};
function createPromise<T>(): PromiseParts<T> {
let resolve!: (value: T) => void;
let reject!: (e: Error) => void;
const promise = new Promise<T>((_resolve, _reject) => {
resolve = _resolve;
reject = _reject;
});
return {promise, resolve, reject};
}
/**
* Tracks bid + payload envelope + data columns for a Gloas block.
*
* Created during block import from signedExecutionPayloadBid in block body.
* Always has bid (required for creation).
*
* Completion requires: payload envelope + all sampled columns
*/
export class PayloadEnvelopeInput {
readonly blockRootHex: RootHex;
readonly slot: Slot;
readonly forkName: ForkName;
readonly proposerIndex: ValidatorIndex;
readonly bid: gloas.ExecutionPayloadBid;
readonly versionedHashes: VersionedHashes;
readonly daOutOfRange: boolean;
private columnsCache = new Map<ColumnIndex, ColumnWithSource>();
private readonly sampledColumns: ColumnIndex[];
private readonly custodyColumns: ColumnIndex[];
private timeCreatedSec: number;
private readonly payloadEnvelopeDataPromise: PromiseParts<gloas.SignedExecutionPayloadEnvelope>;
private readonly allDataPromise: PromiseParts<gloas.DataColumnSidecar[]>;
private readonly columnsDataPromise: PromiseParts<gloas.DataColumnSidecar[]>;
state: PayloadEnvelopeInputState;
private constructor(props: {
blockRootHex: RootHex;
slot: Slot;
forkName: ForkName;
proposerIndex: ValidatorIndex;
bid: gloas.ExecutionPayloadBid;
sampledColumns: ColumnIndex[];
custodyColumns: ColumnIndex[];
timeCreatedSec: number;
daOutOfRange: boolean;
}) {
this.blockRootHex = props.blockRootHex;
this.slot = props.slot;
this.forkName = props.forkName;
this.proposerIndex = props.proposerIndex;
this.bid = props.bid;
this.versionedHashes = props.bid.blobKzgCommitments.map(kzgCommitmentToVersionedHash);
this.sampledColumns = props.sampledColumns;
this.custodyColumns = props.custodyColumns;
this.timeCreatedSec = props.timeCreatedSec;
this.daOutOfRange = props.daOutOfRange;
this.payloadEnvelopeDataPromise = createPromise();
this.allDataPromise = createPromise();
this.columnsDataPromise = createPromise();
const noBlobs = props.bid.blobKzgCommitments.length === 0;
const noSampledColumns = props.sampledColumns.length === 0;
const hasAllData = props.daOutOfRange || noBlobs || noSampledColumns;
if (hasAllData) {
this.state = {hasPayload: false, hasAllData: true, hasComputedAllData: true};
this.allDataPromise.resolve(this.getSampledColumns());
this.columnsDataPromise.resolve(this.getSampledColumns());
} else {
this.state = {hasPayload: false, hasAllData: false, hasComputedAllData: false};
}
}
static createFromBlock(props: CreateFromBlockProps): PayloadEnvelopeInput {
const bid = (props.block.message.body as gloas.BeaconBlockBody).signedExecutionPayloadBid.message;
return new PayloadEnvelopeInput({
blockRootHex: props.blockRootHex,
slot: props.block.message.slot,
forkName: props.forkName,
proposerIndex: props.block.message.proposerIndex,
bid,
sampledColumns: props.sampledColumns,
custodyColumns: props.custodyColumns,
timeCreatedSec: props.timeCreatedSec,
daOutOfRange: props.daOutOfRange,
});
}
/**
* Create a `PayloadEnvelopeInput` from a state's `latestExecutionPayloadBid` (the bid
* recorded in beacon state for the latest imported block). Used when seeding the cache
* for a checkpoint anchor block — we have the bid via state but not the full
* SignedBeaconBlock body.
*/
static createFromBid(props: CreateFromBidProps): PayloadEnvelopeInput {
return new PayloadEnvelopeInput({
blockRootHex: props.blockRootHex,
slot: props.slot,
forkName: props.forkName,
proposerIndex: props.proposerIndex,
bid: props.bid,
sampledColumns: props.sampledColumns,
custodyColumns: props.custodyColumns,
timeCreatedSec: props.timeCreatedSec,
daOutOfRange: props.daOutOfRange,
});
}
getBid(): gloas.ExecutionPayloadBid {
return this.bid;
}
getBuilderIndex(): ValidatorIndex {
return this.bid.builderIndex;
}
getBlockHashHex(): RootHex {
return toRootHex(this.bid.blockHash);
}
getBlobKzgCommitments(): deneb.BlobKzgCommitments {
return this.bid.blobKzgCommitments;
}
addPayloadEnvelope(props: AddPayloadEnvelopeProps): void {
if (this.state.hasPayload) {
throw new Error(`Payload envelope already set for block ${this.blockRootHex}`);
}
if (toRootHex(props.envelope.message.beaconBlockRoot) !== this.blockRootHex) {
throw new Error("Payload envelope beacon_block_root mismatch");
}
// TODO GLOAS: track source by metrics, maybe inside the seen cache
const source: SourceMeta = {
source: props.source,
seenTimestampSec: props.seenTimestampSec,
peerIdStr: props.peerIdStr,
};
if (this.state.hasAllData) {
// Complete state
this.state = {
hasPayload: true,
hasAllData: true,
hasComputedAllData: this.state.hasComputedAllData,
payloadEnvelope: props.envelope,
payloadEnvelopeSource: source,
timeCompleteSec: props.seenTimestampSec,
};
this.payloadEnvelopeDataPromise.resolve(props.envelope);
} else {
// Has payload, waiting for columns
this.state = {
hasPayload: true,
hasAllData: false,
hasComputedAllData: false,
payloadEnvelope: props.envelope,
payloadEnvelopeSource: source,
};
}
}
addColumn(columnWithSource: ColumnWithSource): boolean {
const {columnSidecar, seenTimestampSec} = columnWithSource;
if (this.columnsCache.has(columnSidecar.index)) {
return false;
}
this.columnsCache.set(columnSidecar.index, columnWithSource);
const sampledColumns = this.getSampledColumns();
const hasAllData =
// already hasAllData
this.state.hasAllData ||
// has all sampled columns
sampledColumns.length === this.sampledColumns.length ||
// has enough columns to reconstruct the rest
this.columnsCache.size >= NUMBER_OF_COLUMNS / 2;
const hasComputedAllData =
// has all sampled columns
sampledColumns.length === this.sampledColumns.length;
if (!hasAllData) {
return true;
}
// Resolve allDataPromise on the first transition to hasAllData (either sampled-complete or
// reconstruction-threshold branch). Guarded so it fires exactly once.
if (!this.state.hasAllData && hasAllData) {
this.allDataPromise.resolve(sampledColumns);
}
if (hasComputedAllData) {
this.columnsDataPromise.resolve(sampledColumns);
}
if (this.state.hasPayload) {
// Complete state
this.state = {
hasPayload: true,
hasAllData: true,
hasComputedAllData: hasComputedAllData || this.state.hasComputedAllData,
payloadEnvelope: this.state.payloadEnvelope,
payloadEnvelopeSource: this.state.payloadEnvelopeSource,
timeCompleteSec: seenTimestampSec,
};
this.payloadEnvelopeDataPromise.resolve(this.state.payloadEnvelope);
} else {
// No payload yet, all data ready
this.state = {
hasPayload: false,
hasAllData: true,
hasComputedAllData: hasComputedAllData || this.state.hasComputedAllData,
};
}
return true;
}
hasColumn(index: ColumnIndex): boolean {
return this.columnsCache.has(index);
}
getColumn(index: ColumnIndex): gloas.DataColumnSidecar | undefined {
return this.columnsCache.get(index)?.columnSidecar;
}
getAllColumns(): gloas.DataColumnSidecar[] {
return [...this.columnsCache.values()].map(({columnSidecar}) => columnSidecar);
}
getVersionedHashes(): VersionedHashes {
return this.versionedHashes;
}
hasPayloadEnvelope(): boolean {
return this.state.hasPayload;
}
getPayloadEnvelope(): gloas.SignedExecutionPayloadEnvelope {
if (!this.state.hasPayload) throw new Error("Payload envelope not set");
return this.state.payloadEnvelope;
}
getPayloadEnvelopeSource(): SourceMeta {
if (!this.state.hasPayload) throw new Error("Payload envelope source not set");
return this.state.payloadEnvelopeSource;
}
getSampledColumns(): gloas.DataColumnSidecar[] {
const columns: gloas.DataColumnSidecar[] = [];
for (const index of this.sampledColumns) {
const column = this.columnsCache.get(index);
if (column) {
columns.push(column.columnSidecar);
}
}
return columns;
}
getSampledColumnsWithSource(): ColumnWithSource[] {
const columns: ColumnWithSource[] = [];
for (const index of this.sampledColumns) {
const column = this.columnsCache.get(index);
if (column) {
columns.push(column);
}
}
return columns;
}
getCustodyColumns(): gloas.DataColumnSidecar[] {
const columns: gloas.DataColumnSidecar[] = [];
for (const index of this.custodyColumns) {
const column = this.columnsCache.get(index);
if (column) {
columns.push(column.columnSidecar);
}
}
return columns;
}
hasAllData(): boolean {
return this.state.hasAllData;
}
/**
* Strictly checks missing sampled columns. Does NOT short-circuit on `state.hasAllData`.
*/
getMissingSampledColumnMeta(): MissingColumnMeta {
if (this.state.hasComputedAllData) {
return {missing: [], versionedHashes: this.versionedHashes};
}
const missing: ColumnIndex[] = [];
for (const index of this.sampledColumns) {
if (!this.columnsCache.has(index)) {
missing.push(index);
}
}
return {missing, versionedHashes: this.versionedHashes};
}
hasComputedAllData(): boolean {
return this.state.hasComputedAllData;
}
waitForAllData(timeout: number, signal?: AbortSignal): Promise<gloas.DataColumnSidecar[]> {
if (this.state.hasAllData) {
return Promise.resolve(this.getSampledColumns());
}
return withTimeout(() => this.allDataPromise.promise, timeout, signal);
}
async waitForEnvelopeAndAllData(timeout: number, signal?: AbortSignal): Promise<this> {
if (!this.state.hasPayload || !this.state.hasAllData) {
await withTimeout(
() => Promise.all([this.payloadEnvelopeDataPromise.promise, this.allDataPromise.promise]),
timeout,
signal
);
}
return this;
}
waitForComputedAllData(timeout: number, signal?: AbortSignal): Promise<gloas.DataColumnSidecar[]> {
if (this.state.hasComputedAllData) {
return Promise.resolve(this.getSampledColumns());
}
return withTimeout(() => this.columnsDataPromise.promise, timeout, signal);
}
getTimeCreated(): number {
return this.timeCreatedSec;
}
getTimeComplete(): number {
if (!this.state.hasPayload || !this.state.hasAllData) throw new Error("Not yet complete");
return this.state.timeCompleteSec;
}
isComplete(): boolean {
return this.state.hasPayload && this.state.hasAllData;
}
async waitForData(): Promise<gloas.SignedExecutionPayloadEnvelope> {
return this.payloadEnvelopeDataPromise.promise;
}
getSerializedCacheKeys(): object[] {
const objects: object[] = [];
if (this.state.hasPayload) {
objects.push(this.state.payloadEnvelope);
}
for (const {columnSidecar} of this.columnsCache.values()) {
objects.push(columnSidecar);
}
return objects;
}
getLogMeta(): {
slot: number;
blockRoot: string;
hasPayload: boolean;
hasAllData: boolean;
hasComputedAllData: boolean;
isComplete: boolean;
receivedColumns: number;
sampledColumnsCount: number;
} {
return {
slot: this.slot,
blockRoot: this.blockRootHex,
hasPayload: this.state.hasPayload,
hasAllData: this.state.hasAllData,
hasComputedAllData: this.state.hasComputedAllData,
isComplete: this.isComplete(),
receivedColumns: this.columnsCache.size,
sampledColumnsCount: this.sampledColumns.length,
};
}
}