@lodestar/beacon-node
Version:
A Typescript implementation of the beacon chain
522 lines (469 loc) • 20 kB
text/typescript
import {digest} from "@chainsafe/as-sha256";
import {Tree} from "@chainsafe/persistent-merkle-tree";
import {ChainForkConfig} from "@lodestar/config";
import {
ForkAll,
ForkName,
ForkPostDeneb,
ForkPostFulu,
ForkPreGloas,
KZG_COMMITMENTS_GINDEX,
NUMBER_OF_COLUMNS,
isForkPostGloas,
} from "@lodestar/params";
import {signedBlockToSignedHeader} from "@lodestar/state-transition";
import {
BeaconBlockBody,
ColumnIndex,
CustodyIndex,
DataColumnSidecar,
Root,
SSZTypesFor,
SignedBeaconBlock,
SignedBeaconBlockHeader,
Slot,
deneb,
fulu,
gloas,
isGloasDataColumnSidecar,
ssz,
} from "@lodestar/types";
import {bytesToBigInt} from "@lodestar/utils";
import {BlockInputColumns} from "../chain/blocks/blockInput/blockInput.js";
import {BlockInputSource} from "../chain/blocks/blockInput/types.js";
import {PayloadEnvelopeInput, PayloadEnvelopeInputSource} from "../chain/blocks/payloadEnvelopeInput/index.js";
import {ChainEvent, ChainEventEmitter} from "../chain/emitter.js";
import {Metrics} from "../metrics/metrics.js";
import {NodeId} from "../network/subnets/index.js";
import {dataColumnMatrixRecovery} from "./blobs.js";
import {kzg} from "./kzg.js";
export enum RecoverResult {
// the recover is not attempted because we have less than `NUMBER_OF_COLUMNS / 2` columns
NotAttemptedLessThanHalf = "not_attempted_less_than_half",
// the recover is not attempted because it has full data columns
NotAttemptedFull = "not_attempted_full",
// the recover is a success and it helps resolve availability
SuccessResolved = "success_resolved",
// the recover is a success but it's late, availability is already resolved by either gossip or getBlobsV2
SuccessLate = "success_late",
// the recover failed
Failed = "failed",
}
export type CustodyConfigOpts = {
nodeId: NodeId;
config: ChainForkConfig;
initialCustodyGroupCount?: number;
};
export class CustodyConfig {
/**
* The number of custody groups the node should subscribe to
*/
targetCustodyGroupCount: number;
/**
* The custody columns the node should subscribe to
*/
custodyColumns: ColumnIndex[];
/**
* Custody columns map which column maps to which index in the array of columns custodied
* with zero representing it is not custodied
*/
custodyColumnsIndex: Uint8Array;
/**
* The number of custody groups the node will sample
*/
sampledGroupCount: number;
/**
* Custody groups sampled by the node as part of custody sampling
*/
sampleGroups: CustodyIndex[];
/**
* Data columns sampled by the node as part of custody sampling
* https://github.com/ethereum/consensus-specs/blob/v1.6.0-alpha.4/specs/fulu/das-core.md#custody-sampling
*
* TODO: Consider race conditions if this updates during sync/backfill
*/
sampledColumns: ColumnIndex[];
/**
* Subnets sampled by the node as part of custody sampling
*/
sampledSubnets: number[];
private config: ChainForkConfig;
private nodeId: NodeId;
constructor(opts: CustodyConfigOpts) {
this.config = opts.config;
this.nodeId = opts.nodeId;
this.targetCustodyGroupCount = opts.initialCustodyGroupCount ?? this.config.CUSTODY_REQUIREMENT;
this.custodyColumns = getDataColumns(this.config, this.nodeId, this.targetCustodyGroupCount);
this.custodyColumnsIndex = this.getCustodyColumnsIndex(this.custodyColumns);
this.sampledGroupCount = Math.max(this.targetCustodyGroupCount, this.config.SAMPLES_PER_SLOT);
this.sampleGroups = getCustodyGroups(this.config, this.nodeId, this.sampledGroupCount);
this.sampledColumns = getDataColumns(this.config, this.nodeId, this.sampledGroupCount);
this.sampledSubnets = this.sampledColumns.map((columnIndex) =>
computeSubnetForDataColumn(this.config, columnIndex)
);
}
updateTargetCustodyGroupCount(targetCustodyGroupCount: number) {
this.targetCustodyGroupCount = targetCustodyGroupCount;
this.custodyColumns = getDataColumns(this.config, this.nodeId, this.targetCustodyGroupCount);
this.custodyColumnsIndex = this.getCustodyColumnsIndex(this.custodyColumns);
// TODO: Porting this over to match current behavior, but I think this incorrectly mixes units:
// SAMPLES_PER_SLOT is in columns, and CUSTODY_GROUP_COUNT is in groups
this.sampledGroupCount = Math.max(this.targetCustodyGroupCount, this.config.SAMPLES_PER_SLOT);
this.sampleGroups = getCustodyGroups(this.config, this.nodeId, this.sampledGroupCount);
this.sampledColumns = getDataColumns(this.config, this.nodeId, this.sampledGroupCount);
this.sampledSubnets = this.sampledColumns.map((columnIndex) =>
computeSubnetForDataColumn(this.config, columnIndex)
);
}
private getCustodyColumnsIndex(custodyColumns: ColumnIndex[]): Uint8Array {
// custody columns map which column maps to which index in the array of columns custodied
// with zero representing it is not custodied
const custodyColumnsIndex = new Uint8Array(NUMBER_OF_COLUMNS);
let custodyAtIndex = 1;
for (const columnIndex of custodyColumns) {
custodyColumnsIndex[columnIndex] = custodyAtIndex;
custodyAtIndex++;
}
return custodyColumnsIndex;
}
}
function computeSubnetForDataColumn(config: ChainForkConfig, columnIndex: ColumnIndex): number {
return columnIndex % config.DATA_COLUMN_SIDECAR_SUBNET_COUNT;
}
/**
* Calculate the number of custody groups the node should subscribe to based on the node's effective balance
*
* SPEC FUNCTION
* https://github.com/ethereum/consensus-specs/blob/v1.6.0-alpha.3/specs/fulu/validator.md#validator-custody
*/
export function getValidatorsCustodyRequirement(config: ChainForkConfig, effectiveBalances: number[]): number {
if (effectiveBalances.length === 0) {
return config.CUSTODY_REQUIREMENT;
}
const totalNodeEffectiveBalance = effectiveBalances.reduce((total, effectiveBalance) => {
return total + effectiveBalance;
}, 0);
// Must custody one group for every BALANCE_PER_ADDITIONAL_CUSTODY_GROUP of effective balance
let validatorsCustodyRequirement = Math.floor(
totalNodeEffectiveBalance / config.BALANCE_PER_ADDITIONAL_CUSTODY_GROUP
);
// Any node with at least 1 validator must custody at least VALIDATOR_CUSTODY_REQUIREMENT
validatorsCustodyRequirement = Math.max(validatorsCustodyRequirement, config.VALIDATOR_CUSTODY_REQUIREMENT);
// Cannot custody more than NUMBER_OF_CUSTODY_GROUPS
validatorsCustodyRequirement = Math.min(validatorsCustodyRequirement, config.NUMBER_OF_CUSTODY_GROUPS);
return validatorsCustodyRequirement;
}
/**
* Converts a custody group to an array of column indices. Should be 1-1 as long there are 128
* columns and 128 custody groups.
*
* SPEC FUNCTION
* https://github.com/ethereum/consensus-specs/blob/v1.6.0-alpha.4/specs/fulu/das-core.md#compute_columns_for_custody_group
*/
export function computeColumnsForCustodyGroup(config: ChainForkConfig, custodyIndex: CustodyIndex): ColumnIndex[] {
if (custodyIndex >= config.NUMBER_OF_CUSTODY_GROUPS) {
throw Error(`Invalid custody index ${custodyIndex} >= ${config.NUMBER_OF_CUSTODY_GROUPS}`);
}
const columnsPerCustodyGroup = Number(NUMBER_OF_COLUMNS / config.NUMBER_OF_CUSTODY_GROUPS);
const columnIndexes = [];
for (let i = 0; i < columnsPerCustodyGroup; i++) {
columnIndexes.push(config.NUMBER_OF_CUSTODY_GROUPS * i + custodyIndex);
}
columnIndexes.sort((a, b) => a - b);
return columnIndexes;
}
/**
* Converts nodeId and a the number of custody groups to an array of custody indices. Indexes must be
* further converted to column indices
*
* SPEC FUNCTION
* https://github.com/ethereum/consensus-specs/blob/v1.6.0-alpha.4/specs/fulu/das-core.md#get_custody_groups
*/
export function getCustodyGroups(config: ChainForkConfig, nodeId: NodeId, custodyGroupCount: number): CustodyIndex[] {
if (custodyGroupCount > config.NUMBER_OF_CUSTODY_GROUPS) {
throw Error(`Invalid custody group count ${custodyGroupCount} > ${config.NUMBER_OF_CUSTODY_GROUPS}`);
}
// Skip computation if all groups are custodied
if (custodyGroupCount === config.NUMBER_OF_CUSTODY_GROUPS) {
return Array.from({length: config.NUMBER_OF_CUSTODY_GROUPS}, (_, i) => i);
}
const custodyGroups: CustodyIndex[] = [];
// nodeId is in bigendian and all computes are in little endian
let currentId = bytesToBigInt(nodeId, "be");
while (custodyGroups.length < custodyGroupCount) {
// could be optimized
const currentIdBytes = ssz.UintBn256.serialize(currentId);
const custodyGroup = Number(
ssz.UintBn64.deserialize(digest(currentIdBytes).slice(0, 8)) % BigInt(config.NUMBER_OF_CUSTODY_GROUPS)
);
if (!custodyGroups.includes(custodyGroup)) {
custodyGroups.push(custodyGroup);
}
const willOverflow = currentIdBytes.reduce((acc, elem) => acc && elem === 0xff, true);
if (willOverflow) {
currentId = BigInt(0);
} else {
currentId++;
}
}
custodyGroups.sort((a, b) => a - b);
return custodyGroups;
}
export function computePostFuluKzgCommitmentsInclusionProof(
fork: ForkName,
body: BeaconBlockBody
): fulu.KzgCommitmentsInclusionProof {
const bodyView = (ssz[fork].BeaconBlockBody as SSZTypesFor<ForkAll, "BeaconBlockBody">).toView(body);
return new Tree(bodyView.node).getSingleProof(BigInt(KZG_COMMITMENTS_GINDEX));
}
export function getDataColumns(config: ChainForkConfig, nodeId: NodeId, custodyGroupCount: number): ColumnIndex[] {
return getCustodyGroups(config, nodeId, custodyGroupCount)
.flatMap((custodyIndex) => computeColumnsForCustodyGroup(config, custodyIndex))
.sort((a, b) => a - b);
}
/**
* Computes the cells for each blob and combines them with cell proofs.
* Similar to the computeMatrix function described below.
*
* SPEC FUNCTION (note: spec currently computes proofs, but we already have them)
* https://github.com/ethereum/consensus-specs/blob/v1.6.0-alpha.4/specs/fulu/das-core.md#compute_matrix
*/
export async function getCellsAndProofs(
blobBundles: fulu.BlobAndProofV2[]
): Promise<{cells: Uint8Array[]; proofs: Uint8Array[]}[]> {
const blobsAndProofs: {cells: Uint8Array[]; proofs: Uint8Array[]}[] = [];
for (const {blob, proofs} of blobBundles) {
const cells = await kzg.asyncComputeCells(blob);
blobsAndProofs.push({cells, proofs});
}
return blobsAndProofs;
}
/**
* Get blob KZG commitments from a signed block, handling the different locations
* in pre-Gloas (directly in block body) vs post-Gloas (in execution payload bid).
*/
export function getBlobKzgCommitments(
fork: ForkName,
signedBlock: SignedBeaconBlock<ForkPostDeneb>
): deneb.KZGCommitment[] {
if (isForkPostGloas(fork)) {
return (signedBlock as gloas.SignedBeaconBlock).message.body.signedExecutionPayloadBid.message.blobKzgCommitments;
}
return (signedBlock.message.body as BeaconBlockBody<ForkPostFulu & ForkPreGloas>).blobKzgCommitments;
}
/**
* Given a signed block header and the commitments, inclusion proof, cells/proofs associated with
* each blob in the block, assemble the sidecars which can be distributed to peers.
*
* SPEC FUNCTION
* https://github.com/ethereum/consensus-specs/blob/v1.6.0-alpha.4/specs/fulu/validator.md#get_data_column_sidecars
*/
export function getFuluDataColumnSidecars(
signedBlockHeader: SignedBeaconBlockHeader,
kzgCommitments: deneb.KZGCommitment[],
kzgCommitmentsInclusionProof: fulu.KzgCommitmentsInclusionProof,
cellsAndKzgProofs: {cells: Uint8Array[]; proofs: Uint8Array[]}[]
): fulu.DataColumnSidecar[] {
if (cellsAndKzgProofs.length !== kzgCommitments.length) {
throw Error("Invalid cellsAndKzgProofs length for getDataColumnSidecars");
}
const sidecars: fulu.DataColumnSidecar[] = [];
for (let columnIndex = 0; columnIndex < NUMBER_OF_COLUMNS; columnIndex++) {
const columnCells = [];
const columnProofs = [];
for (const {cells, proofs} of cellsAndKzgProofs) {
columnCells.push(cells[columnIndex]);
columnProofs.push(proofs[columnIndex]);
}
sidecars.push({
index: columnIndex,
column: columnCells,
kzgCommitments,
kzgProofs: columnProofs,
signedBlockHeader,
kzgCommitmentsInclusionProof,
});
}
return sidecars;
}
/**
* Given a signed block and the cells/proofs associated with each blob in the
* block, assemble the sidecars which can be distributed to peers.
*
* SPEC FUNCTION
* fulu: https://github.com/ethereum/consensus-specs/blob/v1.6.0-alpha.4/specs/fulu/validator.md#get_data_column_sidecars_from_block
* gloas: https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.3/specs/gloas/builder.md#modified-get_data_column_sidecars_from_block
*/
export function getDataColumnSidecarsFromBlock(
config: ChainForkConfig,
signedBlock: SignedBeaconBlock<ForkPostFulu>,
cellsAndKzgProofs: {cells: Uint8Array[]; proofs: Uint8Array[]}[]
): DataColumnSidecar[] {
const fork = config.getForkName(signedBlock.message.slot);
const blobKzgCommitments = getBlobKzgCommitments(fork, signedBlock);
// No need to create data column sidecars if there are no blobs
if (blobKzgCommitments.length === 0) {
return [];
}
if (isForkPostGloas(fork)) {
const beaconBlockRoot = config.getForkTypes(signedBlock.message.slot).BeaconBlock.hashTreeRoot(signedBlock.message);
return getGloasDataColumnSidecars(signedBlock.message.slot, beaconBlockRoot, cellsAndKzgProofs);
}
const signedBlockHeader = signedBlockToSignedHeader(config, signedBlock);
const kzgCommitmentsInclusionProof = computePostFuluKzgCommitmentsInclusionProof(fork, signedBlock.message.body);
return getFuluDataColumnSidecars(
signedBlockHeader,
blobKzgCommitments,
kzgCommitmentsInclusionProof,
cellsAndKzgProofs
);
}
/**
* Given a DataColumnSidecar and the cells/proofs associated with each blob corresponding
* to the commitments it contains, assemble all sidecars for distribution to peers.
*
* SPEC FUNCTION
* fulu: https://github.com/ethereum/consensus-specs/blob/v1.6.0-alpha.4/specs/fulu/validator.md#get_data_column_sidecars_from_column_sidecar
* gloas: https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.3/specs/gloas/validator.md#modified-get_data_column_sidecars_from_column_sidecar
*/
export function getDataColumnSidecarsFromColumnSidecar(
sidecar: DataColumnSidecar,
cellsAndKzgProofs: {cells: Uint8Array[]; proofs: Uint8Array[]}[]
): DataColumnSidecar[] {
if (isGloasDataColumnSidecar(sidecar)) {
return getGloasDataColumnSidecars(sidecar.slot, sidecar.beaconBlockRoot, cellsAndKzgProofs);
}
return getFuluDataColumnSidecars(
sidecar.signedBlockHeader,
sidecar.kzgCommitments,
sidecar.kzgCommitmentsInclusionProof,
cellsAndKzgProofs
);
}
export function getDataColumnSidecarSlot(sidecar: DataColumnSidecar): Slot {
if (isGloasDataColumnSidecar(sidecar)) {
return sidecar.slot;
}
return sidecar.signedBlockHeader.message.slot;
}
/**
* In Gloas, data column sidecars have a simplified structure with `slot` and `beaconBlockRoot`
* instead of `signedBlockHeader`, `kzgCommitments`, and `kzgCommitmentsInclusionProof`.
*
* SPEC FUNCTION
* https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.3/specs/gloas/builder.md#modified-get_data_column_sidecars
*/
export function getGloasDataColumnSidecars(
slot: Slot,
beaconBlockRoot: Root,
cellsAndKzgProofs: {cells: Uint8Array[]; proofs: Uint8Array[]}[]
): gloas.DataColumnSidecar[] {
// No need to create data column sidecars if there are no blobs
if (cellsAndKzgProofs.length === 0) {
return [];
}
const sidecars: gloas.DataColumnSidecar[] = [];
for (let columnIndex = 0; columnIndex < NUMBER_OF_COLUMNS; columnIndex++) {
const column: Uint8Array[] = [];
const kzgProofs: Uint8Array[] = [];
for (const {cells, proofs} of cellsAndKzgProofs) {
column.push(cells[columnIndex]);
kzgProofs.push(proofs[columnIndex]);
}
sidecars.push({
index: columnIndex,
column,
kzgProofs,
slot,
beaconBlockRoot,
});
}
return sidecars;
}
/**
* If we receive more than half of NUMBER_OF_COLUMNS (64) we should recover all remaining columns
*/
export async function recoverDataColumnSidecars(
input: BlockInputColumns | PayloadEnvelopeInput,
emitter: ChainEventEmitter,
metrics: Metrics | null
): Promise<DataColumnReconstructionCode> {
const existingColumns = input.getAllColumns();
const columnCount = existingColumns.length;
if (columnCount >= NUMBER_OF_COLUMNS) {
// We have all columns
return DataColumnReconstructionCode.NotAttemptedAlreadyFull;
}
if (columnCount < NUMBER_OF_COLUMNS / 2) {
// We don't have enough columns to recover
return DataColumnReconstructionCode.NotAttemptedHaveLessThanHalf;
}
metrics?.recoverDataColumnSidecars.custodyBeforeReconstruction.set(columnCount);
const partialSidecars = new Map<number, DataColumnSidecar>();
for (const columnSidecar of existingColumns) {
// the more columns we put, the slower the recover
if (partialSidecars.size >= NUMBER_OF_COLUMNS / 2) {
break;
}
partialSidecars.set(columnSidecar.index, columnSidecar);
}
const timer = metrics?.peerDas.dataColumnsReconstructionTime.startTimer();
// if this function throws, we catch at the consumer side
const fullSidecars = await dataColumnMatrixRecovery(partialSidecars).catch(() => null);
timer?.();
if (fullSidecars == null) {
return DataColumnReconstructionCode.NullReturned;
}
if (input.getAllColumns().length === NUMBER_OF_COLUMNS) {
// either gossip or getBlobsV2 resolved availability while we were recovering
metrics?.dataColumns.alreadyAdded.inc(fullSidecars.length);
return DataColumnReconstructionCode.SuccessLate;
}
// Once the node obtains a column through reconstruction,
// the node MUST expose the new column as if it had received it over the network.
// If the node is subscribed to the subnet corresponding to the column,
// it MUST send the reconstructed DataColumnSidecar to its topic mesh neighbors.
// If instead the node is not subscribed to the corresponding subnet,
// it SHOULD still expose the availability of the DataColumnSidecar as part of the gossip emission process.
// After exposing the reconstructed DataColumnSidecar to the network,
// the node MAY delete the DataColumnSidecar if it is not part of the node's custody requirement.
const sidecarsToPublish = [];
for (const columnSidecar of fullSidecars) {
if (!input.hasColumn(columnSidecar.index)) {
if (input instanceof PayloadEnvelopeInput) {
if (!isGloasDataColumnSidecar(columnSidecar)) {
throw new Error(`Expected gloas DataColumnSidecar for block ${input.blockRootHex}`);
}
input.addColumn({
columnSidecar,
seenTimestampSec: Date.now() / 1000,
source: PayloadEnvelopeInputSource.recovery,
});
} else {
if (isGloasDataColumnSidecar(columnSidecar)) {
throw new Error(`Expected fulu DataColumnSidecar for block ${input.blockRootHex}`);
}
input.addColumn({
blockRootHex: input.blockRootHex,
columnSidecar,
seenTimestampSec: Date.now() / 1000,
source: BlockInputSource.recovery,
});
}
sidecarsToPublish.push(columnSidecar);
}
}
metrics?.peerDas.reconstructedColumns.inc(sidecarsToPublish.length);
metrics?.dataColumns.bySource.inc({source: BlockInputSource.recovery}, sidecarsToPublish.length);
emitter.emit(ChainEvent.publishDataColumns, sidecarsToPublish);
// TODO: Can we record dataColumns.sentPeersPerSubnet metric somehow
return DataColumnReconstructionCode.SuccessResolved;
}
export enum DataColumnReconstructionCode {
NotAttemptedAlreadyFull = "not_attempted_full",
NotAttemptedHaveLessThanHalf = "not_attempted_less_than_half",
NullReturned = "null_returned",
SuccessLate = "success_late",
SuccessResolved = "success_resolved",
Failed = "failed",
}