@lodestar/beacon-node
Version:
A Typescript implementation of the beacon chain
698 lines • 26.5 kB
JavaScript
import { NUMBER_OF_COLUMNS } from "@lodestar/params";
import { byteArrayEquals, fromHex, prettyBytes, toRootHex, withTimeout } from "@lodestar/utils";
import { kzgCommitmentToVersionedHash } from "../../../util/blobs.js";
import { BlockInputError, BlockInputErrorCode } from "./errors.js";
import { DAType, } from "./types.js";
export function isBlockInputPreDeneb(blockInput) {
return blockInput.type === DAType.PreData;
}
export function isBlockInputBlobs(blockInput) {
return blockInput.type === DAType.Blobs;
}
export function isBlockInputColumns(blockInput) {
return blockInput.type === DAType.Columns;
}
export function isBlockInputNoData(blockInput) {
return blockInput.type === DAType.NoData;
}
function createPromise() {
let resolve;
let reject;
const promise = new Promise((_resolve, _reject) => {
resolve = _resolve;
reject = _reject;
});
return {
promise,
resolve,
reject,
};
}
class AbstractBlockInput {
daOutOfRange;
timeCreatedSec;
forkName;
slot;
blockRootHex;
parentRootHex;
blockPromise = createPromise();
dataPromise = createPromise();
constructor(init) {
this.daOutOfRange = init.daOutOfRange;
this.timeCreatedSec = init.timeCreated;
this.forkName = init.forkName;
this.slot = init.slot;
this.blockRootHex = init.blockRootHex;
this.parentRootHex = init.parentRootHex;
}
hasBlock() {
return this.state.hasBlock;
}
getBlock() {
if (!this.state.hasBlock) {
throw new BlockInputError({
code: BlockInputErrorCode.MISSING_BLOCK,
blockRoot: this.blockRootHex,
}, "Cannot getBlock from BlockInput without a block");
}
return this.state.block;
}
getBlockSource() {
if (!this.state.hasBlock) {
throw new BlockInputError({
code: BlockInputErrorCode.MISSING_BLOCK,
blockRoot: this.blockRootHex,
}, "Cannot getBlockSource from BlockInput without a block");
}
return this.state.source;
}
hasAllData() {
return this.state.hasAllData;
}
hasBlockAndAllData() {
return this.state.hasBlock && this.state.hasAllData;
}
getLogMeta() {
return {
slot: this.slot,
blockRoot: prettyBytes(this.blockRootHex),
timeCreatedSec: this.timeCreatedSec,
};
}
getTimeComplete() {
if (!this.state.hasBlock || !this.state.hasAllData) {
throw new BlockInputError({
code: BlockInputErrorCode.MISSING_TIME_COMPLETE,
blockRoot: this.blockRootHex,
}, "Cannot getTimeComplete from BlockInput without a block and data");
}
return this.state.timeCompleteSec;
}
waitForBlock(timeout, signal) {
if (!this.state.hasBlock) {
return withTimeout(() => this.blockPromise.promise, timeout, signal);
}
return Promise.resolve(this.state.block);
}
waitForAllData(timeout, signal) {
return withTimeout(() => this.dataPromise.promise, timeout, signal);
}
async waitForBlockAndAllData(timeout, signal) {
if (!this.state.hasBlock || !this.state.hasAllData) {
await withTimeout(() => Promise.all([this.blockPromise.promise, this.dataPromise.promise]), timeout, signal);
}
return this;
}
}
/**
* Pre-DA, BlockInput only has a single state.
* - the block simply exists
*/
export class BlockInputPreData extends AbstractBlockInput {
type = DAType.PreData;
state;
constructor(init, state) {
super(init);
this.state = state;
this.dataPromise.resolve(null);
this.blockPromise.resolve(state.block);
}
static createFromBlock(props) {
const init = {
daOutOfRange: props.daOutOfRange,
timeCreated: props.seenTimestampSec,
forkName: props.forkName,
slot: props.block.message.slot,
blockRootHex: props.blockRootHex,
parentRootHex: toRootHex(props.block.message.parentRoot),
};
const state = {
hasBlock: true,
hasAllData: true,
block: props.block,
source: {
source: props.source,
seenTimestampSec: props.seenTimestampSec,
peerIdStr: props.peerIdStr,
},
timeCompleteSec: props.seenTimestampSec,
};
return new BlockInputPreData(init, state);
}
addBlock(_, opts = { throwOnDuplicateAdd: true }) {
if (opts.throwOnDuplicateAdd) {
throw new BlockInputError({
code: BlockInputErrorCode.INVALID_CONSTRUCTION,
blockRoot: this.blockRootHex,
}, "Cannot addBlock to BlockInputPreData");
}
}
getSerializedCacheKeys() {
return [this.state.block];
}
}
/**
* With blobs, BlockInput has several states:
* - The block is seen and all blobs are seen
* - The block is seen and all blobs are not yet seen
* - The block is yet not seen and its unknown if all blobs are seen
*/
export class BlockInputBlobs extends AbstractBlockInput {
type = DAType.Blobs;
state;
blobsCache = new Map();
constructor(init, state) {
super(init);
this.state = state;
}
static createFromBlock(props) {
const hasAllData = props.daOutOfRange || props.block.message.body.blobKzgCommitments.length === 0;
const state = {
hasBlock: true,
hasAllData,
versionedHashes: props.block.message.body.blobKzgCommitments.map(kzgCommitmentToVersionedHash),
block: props.block,
source: {
source: props.source,
seenTimestampSec: props.seenTimestampSec,
peerIdStr: props.peerIdStr,
},
timeCompleteSec: hasAllData ? props.seenTimestampSec : undefined,
};
const init = {
daOutOfRange: props.daOutOfRange,
timeCreated: props.seenTimestampSec,
forkName: props.forkName,
slot: props.block.message.slot,
blockRootHex: props.blockRootHex,
parentRootHex: toRootHex(props.block.message.parentRoot),
};
const blockInput = new BlockInputBlobs(init, state);
blockInput.blockPromise.resolve(props.block);
if (hasAllData) {
blockInput.dataPromise.resolve([]);
}
return blockInput;
}
static createFromBlob(props) {
const state = {
hasBlock: false,
hasAllData: false,
};
const init = {
daOutOfRange: props.daOutOfRange,
timeCreated: props.seenTimestampSec,
forkName: props.forkName,
blockRootHex: props.blockRootHex,
parentRootHex: toRootHex(props.blobSidecar.signedBlockHeader.message.parentRoot),
slot: props.blobSidecar.signedBlockHeader.message.slot,
};
const blockInput = new BlockInputBlobs(init, state);
blockInput.blobsCache.set(props.blobSidecar.index, {
blobSidecar: props.blobSidecar,
source: props.source,
seenTimestampSec: props.seenTimestampSec,
peerIdStr: props.peerIdStr,
});
return blockInput;
}
getLogMeta() {
return {
slot: this.slot,
blockRoot: prettyBytes(this.blockRootHex),
timeCreatedSec: this.timeCreatedSec,
expectedBlobs: this.state.hasBlock ? this.state.block.message.body.blobKzgCommitments.length : "unknown",
receivedBlobs: this.blobsCache.size,
};
}
addBlock({ blockRootHex, block, source, seenTimestampSec, peerIdStr }, opts = { throwOnDuplicateAdd: true }) {
// this check suffices for checking slot, parentRoot, and forkName
if (blockRootHex !== this.blockRootHex) {
throw new BlockInputError({
code: BlockInputErrorCode.MISMATCHED_ROOT_HEX,
blockInputRoot: this.blockRootHex,
mismatchedRoot: blockRootHex,
source,
peerId: `${peerIdStr}`,
}, "addBlock blockRootHex does not match BlockInput.blockRootHex");
}
if (!opts.throwOnDuplicateAdd) {
return;
}
if (this.state.hasBlock) {
throw new BlockInputError({
code: BlockInputErrorCode.INVALID_CONSTRUCTION,
blockRoot: this.blockRootHex,
}, "Cannot addBlock to BlockInputBlobs after it already has a block");
}
for (const { blobSidecar } of this.blobsCache.values()) {
if (!blockAndBlobArePaired(block, blobSidecar)) {
this.blobsCache.delete(blobSidecar.index);
// TODO: (@matthewkeil) spec says to ignore invalid blobs but should we downscore the peer maybe?
// this.logger?.error(`Removing blobIndex=${blobSidecar.index} from BlockInput`, {}, err);
}
}
const hasAllData = this.blobsCache.size === block.message.body.blobKzgCommitments.length;
this.state = {
...this.state,
hasBlock: true,
hasAllData,
block,
versionedHashes: block.message.body.blobKzgCommitments.map(kzgCommitmentToVersionedHash),
source: {
source,
seenTimestampSec,
peerIdStr,
},
timeCompleteSec: hasAllData ? seenTimestampSec : undefined,
};
this.blockPromise.resolve(block);
if (hasAllData) {
this.dataPromise.resolve(this.getBlobs());
}
}
hasBlob(blobIndex) {
return this.blobsCache.has(blobIndex);
}
getBlob(blobIndex) {
return this.blobsCache.get(blobIndex)?.blobSidecar;
}
addBlob({ blockRootHex, blobSidecar, source, peerIdStr, seenTimestampSec }, opts = { throwOnDuplicateAdd: true }) {
// this check suffices for checking slot, parentRoot, and forkName
if (blockRootHex !== this.blockRootHex) {
throw new BlockInputError({
code: BlockInputErrorCode.MISMATCHED_ROOT_HEX,
blockInputRoot: this.blockRootHex,
mismatchedRoot: blockRootHex,
source: source,
peerId: `${peerIdStr}`,
}, "Blob BeaconBlockHeader blockRootHex does not match BlockInput.blockRootHex");
}
const isDuplicate = this.blobsCache.has(blobSidecar.index);
if (isDuplicate && opts.throwOnDuplicateAdd) {
throw new BlockInputError({
code: BlockInputErrorCode.INVALID_CONSTRUCTION,
blockRoot: this.blockRootHex,
}, "Cannot addBlob to BlockInputBlobs with duplicate blobIndex");
}
if (this.state.hasBlock) {
assertBlockAndBlobArePaired(this.blockRootHex, this.state.block, blobSidecar);
}
if (isDuplicate) {
return;
}
this.blobsCache.set(blobSidecar.index, { blobSidecar, source, seenTimestampSec, peerIdStr });
if (this.state.hasBlock && this.blobsCache.size === this.state.block.message.body.blobKzgCommitments.length) {
this.state = {
...this.state,
hasAllData: true,
timeCompleteSec: seenTimestampSec,
};
this.dataPromise.resolve([...this.blobsCache.values()].map(({ blobSidecar }) => blobSidecar));
}
}
getVersionedHashes() {
if (!this.state.hasBlock) {
throw new BlockInputError({
code: BlockInputErrorCode.INCOMPLETE_DATA,
...this.getLogMeta(),
}, "Cannot get versioned hashes. Block is unknown");
}
return this.state.versionedHashes;
}
getMissingBlobMeta() {
if (!this.state.hasBlock) {
throw new BlockInputError({
code: BlockInputErrorCode.INCOMPLETE_DATA,
...this.getLogMeta(),
}, "Cannot get missing blobs. Block is unknown");
}
if (this.state.hasAllData) {
return [];
}
const blobMeta = [];
const versionedHashes = this.state.versionedHashes;
for (let index = 0; index < versionedHashes.length; index++) {
if (!this.blobsCache.has(index)) {
blobMeta.push({
index,
blockRoot: fromHex(this.blockRootHex),
versionedHash: versionedHashes[index],
});
}
}
return blobMeta;
}
getAllBlobsWithSource() {
if (!this.state.hasAllData) {
throw new BlockInputError({
code: BlockInputErrorCode.INCOMPLETE_DATA,
...this.getLogMeta(),
}, "Cannot get all blobs. DA status is not complete");
}
return [...this.blobsCache.values()];
}
getBlobs() {
return this.getAllBlobsWithSource().map(({ blobSidecar }) => blobSidecar);
}
getSerializedCacheKeys() {
const objects = [];
if (this.state.hasBlock) {
objects.push(this.state.block);
}
for (const { blobSidecar } of this.blobsCache.values()) {
objects.push(blobSidecar);
}
return objects;
}
}
function blockAndBlobArePaired(block, blobSidecar) {
const blockCommitment = block.message.body.blobKzgCommitments[blobSidecar.index];
if (!blockCommitment || !blobSidecar.kzgCommitment) {
return false;
}
return byteArrayEquals(blockCommitment, blobSidecar.kzgCommitment);
}
function assertBlockAndBlobArePaired(blockRootHex, block, blobSidecar) {
if (!blockAndBlobArePaired(block, blobSidecar)) {
// TODO: (@matthewkeil) should this eject the bad blob instead? No way to tell if the blob or the block
// has the invalid commitment. Guessing it would be the blob though because we match via block
// hashTreeRoot and we do not take a hashTreeRoot of the BlobSidecar
throw new BlockInputError({
code: BlockInputErrorCode.MISMATCHED_KZG_COMMITMENT,
blockRoot: blockRootHex,
slot: block.message.slot,
sidecarIndex: blobSidecar.index,
}, "BlobSidecar commitment does not match block commitment");
}
}
/**
* With columns, BlockInput has several states:
* - The block is seen and all required sampled columns are seen
* - The block is seen and all required sampled columns are not yet seen
* - The block is not yet seen and all required sampled columns are seen
* - The block is not yet seen and all required sampled columns are not yet seen
*/
export class BlockInputColumns extends AbstractBlockInput {
type = DAType.Columns;
state;
columnsCache = new Map();
sampledColumns;
custodyColumns;
/**
* This promise resolves when all sampled columns are available
*
* This is different from `dataPromise` which resolves when all data is available or could become available (e.g. through reconstruction)
*/
computedDataPromise = createPromise();
constructor(init, state, sampledColumns, custodyColumns) {
super(init);
this.state = state;
this.sampledColumns = sampledColumns;
this.custodyColumns = custodyColumns;
}
get columnCount() {
return this.columnsCache.size;
}
static createFromBlock(props) {
const hasAllData = props.daOutOfRange ||
props.block.message.body.blobKzgCommitments.length === 0 ||
props.sampledColumns.length === 0;
const state = {
hasBlock: true,
hasAllData,
hasComputedAllData: hasAllData,
versionedHashes: props.block.message.body.blobKzgCommitments.map(kzgCommitmentToVersionedHash),
block: props.block,
source: {
source: props.source,
seenTimestampSec: props.seenTimestampSec,
peerIdStr: props.peerIdStr,
},
timeCreated: props.seenTimestampSec,
timeCompleteSec: hasAllData ? props.seenTimestampSec : undefined,
};
const init = {
daOutOfRange: props.daOutOfRange,
timeCreated: props.seenTimestampSec,
forkName: props.forkName,
blockRootHex: props.blockRootHex,
parentRootHex: toRootHex(props.block.message.parentRoot),
slot: props.block.message.slot,
};
const blockInput = new BlockInputColumns(init, state, props.sampledColumns, props.custodyColumns);
blockInput.blockPromise.resolve(props.block);
if (hasAllData) {
blockInput.dataPromise.resolve([]);
blockInput.computedDataPromise.resolve([]);
}
return blockInput;
}
static createFromColumn(props) {
const hasAllData = props.daOutOfRange || props.columnSidecar.kzgCommitments.length === 0 || props.sampledColumns.length === 0;
const state = {
hasBlock: false,
hasAllData,
hasComputedAllData: hasAllData,
versionedHashes: props.columnSidecar.kzgCommitments.map(kzgCommitmentToVersionedHash),
};
const init = {
daOutOfRange: false,
timeCreated: props.seenTimestampSec,
forkName: props.forkName,
blockRootHex: props.blockRootHex,
parentRootHex: toRootHex(props.columnSidecar.signedBlockHeader.message.parentRoot),
slot: props.columnSidecar.signedBlockHeader.message.slot,
};
const blockInput = new BlockInputColumns(init, state, props.sampledColumns, props.custodyColumns);
if (hasAllData) {
blockInput.dataPromise.resolve([]);
blockInput.computedDataPromise.resolve([]);
}
return blockInput;
}
getLogMeta() {
return {
slot: this.slot,
blockRoot: prettyBytes(this.blockRootHex),
timeCreatedSec: this.timeCreatedSec,
expectedColumns: this.state.hasBlock && this.state.block.message.body.blobKzgCommitments.length === 0
? 0
: this.sampledColumns.length,
receivedColumns: this.getSampledColumns().length,
};
}
addBlock(props, opts = { throwOnDuplicateAdd: true }) {
if (props.blockRootHex !== this.blockRootHex) {
throw new BlockInputError({
code: BlockInputErrorCode.MISMATCHED_ROOT_HEX,
blockInputRoot: this.blockRootHex,
mismatchedRoot: props.blockRootHex,
source: props.source,
peerId: `${props.peerIdStr}`,
}, "addBlock blockRootHex does not match BlockInput.blockRootHex");
}
if (!opts.throwOnDuplicateAdd) {
return;
}
if (this.state.hasBlock) {
throw new BlockInputError({
code: BlockInputErrorCode.INVALID_CONSTRUCTION,
blockRoot: this.blockRootHex,
}, "Cannot addBlock to BlockInputColumns after it already has a block");
}
const hasAllData = props.block.message.body.blobKzgCommitments.length === 0 ||
this.state.hasAllData;
const hasComputedAllData = props.block.message.body.blobKzgCommitments.length === 0 || this.state.hasComputedAllData;
this.state = {
...this.state,
hasBlock: true,
hasAllData,
hasComputedAllData,
block: props.block,
source: {
source: props.source,
seenTimestampSec: props.seenTimestampSec,
peerIdStr: props.peerIdStr,
},
timeCompleteSec: hasAllData ? props.seenTimestampSec : undefined,
};
this.blockPromise.resolve(props.block);
}
addColumn({ blockRootHex, columnSidecar, source, seenTimestampSec, peerIdStr }, opts = { throwOnDuplicateAdd: true }) {
if (blockRootHex !== this.blockRootHex) {
throw new BlockInputError({
code: BlockInputErrorCode.MISMATCHED_ROOT_HEX,
blockInputRoot: this.blockRootHex,
mismatchedRoot: blockRootHex,
source: source,
peerId: `${peerIdStr}`,
}, "Column BeaconBlockHeader blockRootHex does not match BlockInput.blockRootHex");
}
const isDuplicate = this.columnsCache.has(columnSidecar.index);
if (isDuplicate && opts.throwOnDuplicateAdd) {
throw new BlockInputError({
code: BlockInputErrorCode.INVALID_CONSTRUCTION,
blockRoot: this.blockRootHex,
}, "Cannot addColumn to BlockInputColumns with duplicate column index");
}
if (isDuplicate) {
return;
}
this.columnsCache.set(columnSidecar.index, { columnSidecar, source, seenTimestampSec, peerIdStr });
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;
this.state = {
...this.state,
hasAllData: hasAllData || this.state.hasAllData,
hasComputedAllData: hasComputedAllData || this.state.hasComputedAllData,
timeCompleteSec: hasAllData ? seenTimestampSec : undefined,
};
if (hasAllData && sampledColumns !== null) {
this.dataPromise.resolve(sampledColumns);
}
if (hasComputedAllData && sampledColumns !== null) {
this.computedDataPromise.resolve(sampledColumns);
}
}
hasColumn(columnIndex) {
return this.columnsCache.has(columnIndex);
}
getColumn(columnIndex) {
return this.columnsCache.get(columnIndex)?.columnSidecar;
}
getVersionedHashes() {
return this.state.versionedHashes;
}
getCustodyColumns() {
const columns = [];
for (const index of this.custodyColumns) {
const column = this.columnsCache.get(index);
if (column) {
columns.push(column.columnSidecar);
}
}
return columns;
}
getSampledColumnsWithSource() {
const columns = [];
for (const index of this.sampledColumns) {
const column = this.columnsCache.get(index);
if (column) {
columns.push(column);
}
}
return columns;
}
getSampledColumns() {
const columns = [];
for (const index of this.sampledColumns) {
const column = this.columnsCache.get(index);
if (column) {
columns.push(column.columnSidecar);
}
}
return columns;
}
getAllColumnsWithSource() {
return [...this.columnsCache.values()];
}
getAllColumns() {
return this.getAllColumnsWithSource().map(({ columnSidecar }) => columnSidecar);
}
/**
* Strictly checks missing sampled columns. Does NOT short-circuit on `state.hasAllData`.
*/
getMissingSampledColumnMeta() {
if (this.state.hasComputedAllData) {
return {
missing: [],
versionedHashes: this.state.versionedHashes,
};
}
const missing = [];
for (const index of this.sampledColumns) {
if (!this.columnsCache.has(index)) {
missing.push(index);
}
}
return {
missing,
versionedHashes: this.state.versionedHashes,
};
}
hasComputedAllData() {
return this.state.hasComputedAllData;
}
waitForComputedAllData(timeout, signal) {
if (!this.state.hasComputedAllData) {
return withTimeout(() => this.computedDataPromise.promise, timeout, signal);
}
return Promise.resolve(this.getSampledColumns());
}
getSerializedCacheKeys() {
const objects = [];
if (this.state.hasBlock) {
objects.push(this.state.block);
}
objects.push(...this.getAllColumns());
return objects;
}
}
export class BlockInputNoData extends AbstractBlockInput {
type = DAType.NoData;
state;
constructor(init, state) {
super(init);
this.state = state;
this.dataPromise.resolve(null);
this.blockPromise.resolve(state.block);
}
static createFromBlock(props) {
const init = {
daOutOfRange: props.daOutOfRange,
timeCreated: props.seenTimestampSec,
forkName: props.forkName,
slot: props.block.message.slot,
blockRootHex: props.blockRootHex,
parentRootHex: toRootHex(props.block.message.parentRoot),
};
const state = {
hasBlock: true,
hasAllData: true,
block: props.block,
source: {
source: props.source,
seenTimestampSec: props.seenTimestampSec,
peerIdStr: props.peerIdStr,
},
timeCompleteSec: props.seenTimestampSec,
};
return new BlockInputNoData(init, state);
}
addBlock(_, opts = { throwOnDuplicateAdd: true }) {
if (opts.throwOnDuplicateAdd) {
throw new BlockInputError({
code: BlockInputErrorCode.INVALID_CONSTRUCTION,
blockRoot: this.blockRootHex,
}, "Cannot addBlock to BlockInputNoData - block already exists");
}
}
getBlobKzgCommitments() {
return this.state.block.message.body.signedExecutionPayloadBid.message
.blobKzgCommitments;
}
getSerializedCacheKeys() {
return [this.state.block];
}
}
//# sourceMappingURL=blockInput.js.map