@lodestar/beacon-node
Version:
A Typescript implementation of the beacon chain
515 lines (453 loc) • 22.4 kB
text/typescript
import crypto from "node:crypto";
import {ChainConfig} from "@lodestar/config";
import {
BLOB_TX_TYPE,
BYTES_PER_FIELD_ELEMENT,
FIELD_ELEMENTS_PER_BLOB,
ForkName,
ForkPostBellatrix,
ForkPostCapella,
ForkSeq,
SLOTS_PER_EPOCH,
} from "@lodestar/params";
import {computeTimeAtSlot} from "@lodestar/state-transition";
import {ExecutionPayload, RootHex, bellatrix, deneb, gloas, ssz} from "@lodestar/types";
import {fromHex, toRootHex} from "@lodestar/utils";
import {ZERO_HASH_HEX} from "../../constants/index.js";
import {INTEROP_BLOCK_HASH} from "../../node/utils/interop/state.js";
import {kzgCommitmentToVersionedHash} from "../../util/blobs.js";
import {kzg} from "../../util/kzg.js";
import {ClientCode, ExecutionPayloadStatus, PayloadIdCache} from "./interface.js";
import {
BlobsBundleRpc,
EngineApiRpcParamTypes,
EngineApiRpcReturnTypes,
ExecutionPayloadBodyRpc,
ExecutionPayloadRpc,
ExecutionRequestsRpc,
PayloadStatus,
deserializePayloadAttributes,
serializeBlobsBundle,
serializeExecutionPayload,
serializeExecutionRequests,
} from "./types.js";
import {JsonRpcBackend, quantityToNum} from "./utils.js";
const INTEROP_GAS_LIMIT = 30e6;
const PRUNE_PAYLOAD_ID_AFTER_MS = 5000;
export type ExecutionEngineMockOpts = {
genesisBlockHash?: string;
eth1BlockHash?: string;
onlyPredefinedResponses?: boolean;
genesisTime?: number;
config?: ChainConfig;
};
type ExecutionBlock = {
parentHash: RootHex;
blockHash: RootHex;
timestamp: number;
blockNumber: number;
};
const TX_TYPE_EIP1559 = 2;
type PreparedPayload = {
executionPayload: ExecutionPayloadRpc;
blobsBundle: BlobsBundleRpc;
executionRequests: ExecutionRequestsRpc;
};
/**
* Mock ExecutionEngine for fast prototyping and unit testing
*/
export class ExecutionEngineMockBackend implements JsonRpcBackend {
// Public state to check if notifyForkchoiceUpdate() is called properly
headBlockHash = ZERO_HASH_HEX;
safeBlockHash = ZERO_HASH_HEX;
finalizedBlockHash = ZERO_HASH_HEX;
readonly payloadIdCache = new PayloadIdCache();
/** Known valid blocks */
private readonly validBlocks = new Map<RootHex, ExecutionBlock>();
/** Preparing payloads to be retrieved via engine_getPayloadV1 */
private readonly preparingPayloads = new Map<number, PreparedPayload>();
private readonly payloadsForDeletion = new Map<number, number>();
private readonly predefinedPayloadStatuses = new Map<RootHex, PayloadStatus>();
private payloadId = 0;
private capellaForkTimestamp: number;
private denebForkTimestamp: number;
private electraForkTimestamp: number;
private fuluForkTimestamp: number;
private gloasForkTimestamp: number;
readonly handlers: {
[K in keyof EngineApiRpcParamTypes]: (...args: EngineApiRpcParamTypes[K]) => EngineApiRpcReturnTypes[K];
};
constructor(private readonly opts: ExecutionEngineMockOpts) {
this.validBlocks.set(opts.genesisBlockHash ?? ZERO_HASH_HEX, {
parentHash: ZERO_HASH_HEX,
blockHash: ZERO_HASH_HEX,
timestamp: 0,
blockNumber: 0,
});
const eth1BlockHash = opts.eth1BlockHash ?? toRootHex(INTEROP_BLOCK_HASH);
this.validBlocks.set(eth1BlockHash, {
parentHash: ZERO_HASH_HEX,
blockHash: eth1BlockHash,
timestamp: 0,
blockNumber: 1,
});
const {config} = opts;
this.capellaForkTimestamp =
opts.genesisTime && config
? computeTimeAtSlot(config, config.CAPELLA_FORK_EPOCH * SLOTS_PER_EPOCH, opts.genesisTime)
: Infinity;
this.denebForkTimestamp =
opts.genesisTime && config
? computeTimeAtSlot(config, config.DENEB_FORK_EPOCH * SLOTS_PER_EPOCH, opts.genesisTime)
: Infinity;
this.electraForkTimestamp =
opts.genesisTime && config
? computeTimeAtSlot(config, config.ELECTRA_FORK_EPOCH * SLOTS_PER_EPOCH, opts.genesisTime)
: Infinity;
this.fuluForkTimestamp =
opts.genesisTime && config
? computeTimeAtSlot(config, config.FULU_FORK_EPOCH * SLOTS_PER_EPOCH, opts.genesisTime)
: Infinity;
this.gloasForkTimestamp =
opts.genesisTime && config
? computeTimeAtSlot(config, config.GLOAS_FORK_EPOCH * SLOTS_PER_EPOCH, opts.genesisTime)
: Infinity;
this.handlers = {
engine_newPayloadV1: this.notifyNewPayload.bind(this),
engine_newPayloadV2: this.notifyNewPayload.bind(this),
engine_newPayloadV3: this.notifyNewPayload.bind(this),
engine_newPayloadV4: this.notifyNewPayload.bind(this),
engine_newPayloadV5: this.notifyNewPayload.bind(this),
engine_forkchoiceUpdatedV1: this.notifyForkchoiceUpdate.bind(this),
engine_forkchoiceUpdatedV2: this.notifyForkchoiceUpdate.bind(this),
engine_forkchoiceUpdatedV3: this.notifyForkchoiceUpdate.bind(this),
engine_forkchoiceUpdatedV4: this.notifyForkchoiceUpdate.bind(this),
engine_getPayloadV1: this.getPayloadV1.bind(this),
engine_getPayloadV2: this.getPayloadV5.bind(this),
engine_getPayloadV3: this.getPayloadV5.bind(this),
engine_getPayloadV4: this.getPayloadV5.bind(this),
engine_getPayloadV5: this.getPayloadV5.bind(this),
engine_getPayloadV6: this.getPayloadV5.bind(this),
engine_getPayloadBodiesByHashV1: this.getPayloadBodiesByHash.bind(this),
engine_getPayloadBodiesByRangeV1: this.getPayloadBodiesByRange.bind(this),
engine_getClientVersionV1: this.getClientVersionV1.bind(this),
engine_getBlobsV1: this.getBlobs.bind(this),
engine_getBlobsV2: this.getBlobsV2.bind(this),
};
}
private getPayloadBodiesByHash(
_blockHex: EngineApiRpcParamTypes["engine_getPayloadBodiesByHashV1"][0]
): EngineApiRpcReturnTypes["engine_getPayloadBodiesByHashV1"] {
return [] as ExecutionPayloadBodyRpc[];
}
private getPayloadBodiesByRange(
_start: EngineApiRpcParamTypes["engine_getPayloadBodiesByRangeV1"][0],
_count: EngineApiRpcParamTypes["engine_getPayloadBodiesByRangeV1"][1]
): EngineApiRpcReturnTypes["engine_getPayloadBodiesByRangeV1"] {
return [] as ExecutionPayloadBodyRpc[];
}
/**
* Mock manipulator to add predefined responses before execution engine client calls
*/
addPredefinedPayloadStatus(blockHash: RootHex, payloadStatus: PayloadStatus): void {
this.predefinedPayloadStatuses.set(blockHash, payloadStatus);
}
/**
* `engine_newPayloadV1`
*/
private notifyNewPayload(
executionPayloadRpc: EngineApiRpcParamTypes["engine_newPayloadV1"][0],
// add versionedHashes validation later if required
_versionedHashes?: EngineApiRpcParamTypes["engine_newPayloadV3"][1]
): EngineApiRpcReturnTypes["engine_newPayloadV1"] {
const blockHash = executionPayloadRpc.blockHash;
const parentHash = executionPayloadRpc.parentHash;
// For optimistic sync spec tests, allow to define responses ahead of time
const predefinedResponse = this.predefinedPayloadStatuses.get(blockHash);
if (predefinedResponse) {
return predefinedResponse;
}
if (this.opts.onlyPredefinedResponses) {
throw Error(`No predefined response for blockHash ${blockHash}`);
}
// 1. Client software MUST validate blockHash value as being equivalent to Keccak256(RLP(ExecutionBlockHeader)),
// where ExecutionBlockHeader is the execution layer block header (the former PoW block header structure).
// Fields of this object are set to the corresponding payload values and constant values according to the Block
// structure section of EIP-3675, extended with the corresponding section of EIP-4399. Client software MUST run
// this validation in all cases even if this branch or any other branches of the block tree are in an active sync
// process.
//
// > Mock does not do this validation
// 2. Client software MAY initiate a sync process if requisite data for payload validation is missing. Sync process
// is specified in the Sync section.
//
// > N/A: Mock can't sync
// 3. Client software MUST validate the payload if it extends the canonical chain and requisite data for the
// validation is locally available. The validation process is specified in the Payload validation section.
//
// > Mock only validates that parent is known
if (!this.validBlocks.has(parentHash)) {
// if requisite data for the payload's acceptance or validation is missing
// return {status: SYNCING, latestValidHash: null, validationError: null}
return {status: ExecutionPayloadStatus.SYNCING, latestValidHash: null, validationError: null};
}
// 4. Client software MAY NOT validate the payload if the payload doesn't belong to the canonical chain.
//
// > N/A: Mock does not track the chain dag
// Mock logic: persist valid payload as part of canonical chain
this.validBlocks.set(blockHash, {
parentHash,
blockHash,
timestamp: quantityToNum(executionPayloadRpc.timestamp),
blockNumber: quantityToNum(executionPayloadRpc.blockNumber),
});
// IF the payload has been fully validated while processing the call
// RETURN payload status from the Payload validation process
// If validation succeeds, the response MUST contain {status: VALID, latestValidHash: payload.blockHash}
return {status: ExecutionPayloadStatus.VALID, latestValidHash: blockHash, validationError: null};
}
/**
* `engine_forkchoiceUpdatedV1`
*/
private notifyForkchoiceUpdate(
forkChoiceData: EngineApiRpcParamTypes["engine_forkchoiceUpdatedV1"][0],
payloadAttributesRpc: EngineApiRpcParamTypes["engine_forkchoiceUpdatedV1"][1]
): EngineApiRpcReturnTypes["engine_forkchoiceUpdatedV1"] {
const {headBlockHash, safeBlockHash, finalizedBlockHash} = forkChoiceData;
// For optimistic sync spec tests, allow to define responses ahead of time
const predefinedResponse = this.predefinedPayloadStatuses.get(headBlockHash);
if (predefinedResponse) {
return {
payloadStatus: predefinedResponse,
payloadId: null,
};
}
if (this.opts.onlyPredefinedResponses) {
throw Error(`No predefined response for headBlockHash ${headBlockHash}`);
}
// 1. Client software MAY initiate a sync process if forkchoiceState.headBlockHash references an unknown payload or
// a payload that can't be validated because data that are requisite for the validation is missing. The sync
// process is specified in the Sync section.
//
// > N/A: Mock can't sync
// 2. Client software MAY skip an update of the forkchoice state and MUST NOT begin a payload build process if
// forkchoiceState.headBlockHash references an ancestor of the head of canonical chain. In the case of such an
// event, client software MUST return {payloadStatus:
// {status: VALID, latestValidHash: forkchoiceState.headBlockHash, validationError: null}, payloadId: null}.
//
// > TODO
// 3. If forkchoiceState.headBlockHash references a PoW block, client software MUST validate this block with
// respect to terminal block conditions according to EIP-3675. This check maps to the transition block validity
// section of the EIP. Additionally, if this validation fails, client software MUST NOT update the forkchoice
// state and MUST NOT begin a payload build process.
//
// > N/A: All networks have completed the merge transition
// 4. Before updating the forkchoice state, client software MUST ensure the validity of the payload referenced by
// forkchoiceState.headBlockHash, and MAY validate the payload while processing the call. The validation process
// is specified in the Payload validation section. If the validation process fails, client software MUST NOT
// update the forkchoice state and MUST NOT begin a payload build process.
//
// > N/A payload already validated
const headBlock = this.validBlocks.get(headBlockHash);
if (!headBlock) {
// IF references an unknown payload or a payload that can't be validated because requisite data is missing
// RETURN {payloadStatus: {status: SYNCING, latestValidHash: null, validationError: null}, payloadId: null}
//
return {
payloadStatus: {status: ExecutionPayloadStatus.SYNCING, latestValidHash: null, validationError: null},
payloadId: null,
};
}
// 5. Client software MUST update its forkchoice state if payloads referenced by forkchoiceState.headBlockHash and
// forkchoiceState.finalizedBlockHash are VALID.
if (!this.validBlocks.has(finalizedBlockHash)) {
throw Error(`Unknown finalizedBlockHash ${finalizedBlockHash}`);
}
this.headBlockHash = headBlockHash;
this.safeBlockHash = safeBlockHash;
this.finalizedBlockHash = finalizedBlockHash;
// 6. Client software MUST return -38002: Invalid forkchoice state error if the payload referenced by
// forkchoiceState.headBlockHash is VALID and a payload referenced by either forkchoiceState.finalizedBlockHash
// or forkchoiceState.safeBlockHash does not belong to the chain defined by forkchoiceState.headBlockHash.
//
// > N/A: Mock does not track the chain dag
if (payloadAttributesRpc) {
const payloadAttributes = deserializePayloadAttributes(payloadAttributesRpc);
// 7. Client software MUST ensure that payloadAttributes.timestamp is greater than timestamp of a block referenced
// by forkchoiceState.headBlockHash. If this condition isn't held client software MUST respond with
// `-38003: Invalid payload attributes` and MUST NOT begin a payload build process.
// In such an event, the forkchoiceState update MUST NOT be rolled back.
if (headBlock.timestamp > payloadAttributes.timestamp) {
throw Error("Invalid payload attributes");
}
// 8. Client software MUST begin a payload build process building on top of forkchoiceState.headBlockHash and
// identified via buildProcessId value if payloadAttributes is not null and the forkchoice state has been
// updated successfully. The build process is specified in the Payload building section.
const payloadId = this.payloadId++;
// Generate empty payload first to be correct with respect to fork
const fork = this.timestampToFork(payloadAttributes.timestamp);
const executionPayload = ssz[fork].ExecutionPayload.defaultValue();
// Make executionPayload valid
executionPayload.parentHash = fromHex(headBlockHash);
executionPayload.feeRecipient = fromHex(payloadAttributes.suggestedFeeRecipient);
executionPayload.prevRandao = payloadAttributes.prevRandao;
executionPayload.blockNumber = headBlock.blockNumber + 1;
executionPayload.gasLimit = INTEROP_GAS_LIMIT;
executionPayload.gasUsed = Math.floor(0.5 * INTEROP_GAS_LIMIT);
executionPayload.timestamp = payloadAttributes.timestamp;
executionPayload.blockHash = crypto.randomBytes(32);
executionPayload.transactions = [];
// Between 0 and 4 transactions for all forks
const eip1559TxCount = Math.round(4 * Math.random());
for (let i = 0; i < eip1559TxCount; i++) {
const tx = crypto.randomBytes(512);
tx[0] = TX_TYPE_EIP1559;
executionPayload.transactions.push(tx);
}
const commitments: deneb.KZGCommitment[] = [];
const blobs: deneb.Blob[] = [];
const proofs: deneb.KZGProof[] = [];
if (ForkSeq[fork] >= ForkSeq.fulu) {
// if post fulu, add between 0 and 3 data column transactions based on slot with BlobsBundleV2
const fuluTxCount = executionPayload.blockNumber % 4;
for (let i = 0; i < fuluTxCount; i++) {
const blob = generateRandomBlob();
const commitment = kzg.blobToKzgCommitment(blob);
const {proofs: cellProofs} = kzg.computeCellsAndKzgProofs(blob);
executionPayload.transactions.push(transactionForKzgCommitment(commitment));
commitments.push(commitment);
blobs.push(blob);
proofs.push(...cellProofs);
}
} else if (ForkSeq[fork] >= ForkSeq.deneb && ForkSeq[fork] < ForkSeq.fulu) {
// if post deneb, add between 0 and 2 blob transactions
const denebTxCount = executionPayload.blockNumber % 3;
for (let i = 0; i < denebTxCount; i++) {
const blob = generateRandomBlob();
const commitment = kzg.blobToKzgCommitment(blob);
const proof = kzg.computeBlobKzgProof(blob, commitment);
executionPayload.transactions.push(transactionForKzgCommitment(commitment));
commitments.push(commitment);
blobs.push(blob);
proofs.push(proof);
}
}
if (ForkSeq[fork] >= ForkSeq.capella) {
(executionPayload as ExecutionPayload<ForkPostCapella>).withdrawals = ssz.capella.Withdrawals.defaultValue();
}
if (ForkSeq[fork] >= ForkSeq.gloas && payloadAttributes.slotNumber != null) {
(executionPayload as gloas.ExecutionPayload).slotNumber = payloadAttributes.slotNumber;
}
this.preparingPayloads.set(payloadId, {
executionPayload: serializeExecutionPayload(fork, executionPayload),
blobsBundle: serializeBlobsBundle({
commitments,
blobs,
proofs,
}),
executionRequests: serializeExecutionRequests({
deposits: ssz.electra.DepositRequests.defaultValue(),
withdrawals: ssz.electra.WithdrawalRequests.defaultValue(),
consolidations: ssz.electra.ConsolidationRequests.defaultValue(),
}),
});
// IF the payload is deemed VALID and the build process has begun
// {payloadStatus: {status: VALID, latestValidHash: forkchoiceState.headBlockHash, validationError: null}, payloadId: buildProcessId}
return {
payloadStatus: {status: ExecutionPayloadStatus.VALID, latestValidHash: null, validationError: null},
payloadId: String(payloadId as number),
};
}
// Don't start build process
// IF the payload is deemed VALID and a build process hasn't been started
// {payloadStatus: {status: VALID, latestValidHash: forkchoiceState.headBlockHash, validationError: null}, payloadId: null}
return {
payloadStatus: {status: ExecutionPayloadStatus.VALID, latestValidHash: null, validationError: null},
payloadId: null,
};
}
/**
* `engine_getPayloadV1`
*
* 1. Given the payloadId client software MUST respond with the most recent version of the payload that is available in the corresponding building process at the time of receiving the call.
* 2. The call MUST be responded with 5: Unavailable payload error if the building process identified by the payloadId doesn't exist.
* 3. Client software MAY stop the corresponding building process after serving this call.
*/
private getPayloadV1(
payloadId: EngineApiRpcParamTypes["engine_getPayloadV1"][0]
): EngineApiRpcReturnTypes["engine_getPayloadV1"] {
return this.getPayloadV5(payloadId).executionPayload;
}
private getPayloadV5(
payloadId: EngineApiRpcParamTypes["engine_getPayloadV5"][0]
): EngineApiRpcReturnTypes["engine_getPayloadV5"] {
// 1. Given the payloadId client software MUST return the most recent version of the payload that is available in
// the corresponding build process at the time of receiving the call.
const payloadIdNbr = Number(payloadId);
const payload = this.preparingPayloads.get(payloadIdNbr);
// 2. The call MUST return -38001: Unknown payload error if the build process identified by the payloadId does not
// exist.
if (!payload) {
throw Error(`Unknown payloadId ${payloadId}`);
}
// 3. Client software MAY stop the corresponding build process after serving this call.
// Do after a while to allow getBlobsBundle()
const now = Date.now();
for (const [oldPayloadId, addedTimestampMs] of this.payloadsForDeletion.entries()) {
if (addedTimestampMs < now - PRUNE_PAYLOAD_ID_AFTER_MS) {
this.preparingPayloads.delete(oldPayloadId);
this.payloadsForDeletion.delete(oldPayloadId);
}
}
this.payloadsForDeletion.set(payloadIdNbr, now);
return {
executionPayload: payload.executionPayload,
blockValue: String(1e9),
blobsBundle: payload.blobsBundle,
executionRequests: payload.executionRequests,
};
}
private getClientVersionV1(
_clientVersion: EngineApiRpcParamTypes["engine_getClientVersionV1"][0]
): EngineApiRpcReturnTypes["engine_getClientVersionV1"] {
return [{code: ClientCode.XX, name: "mock", version: "", commit: ""}];
}
private getBlobs(
versionedHashes: EngineApiRpcParamTypes["engine_getBlobsV1"][0]
): EngineApiRpcReturnTypes["engine_getBlobsV1"] {
return versionedHashes.map((_vh) => null);
}
private getBlobsV2(
_versionedHashes: EngineApiRpcParamTypes["engine_getBlobsV2"][0]
): EngineApiRpcReturnTypes["engine_getBlobsV2"] {
return null;
}
private timestampToFork(timestamp: number): ForkPostBellatrix {
if (timestamp >= this.gloasForkTimestamp) return ForkName.gloas;
if (timestamp >= this.fuluForkTimestamp) return ForkName.fulu;
if (timestamp >= this.electraForkTimestamp) return ForkName.electra;
if (timestamp >= this.denebForkTimestamp) return ForkName.deneb;
if (timestamp >= this.capellaForkTimestamp) return ForkName.capella;
return ForkName.bellatrix;
}
}
function transactionForKzgCommitment(kzgCommitment: deneb.KZGCommitment): bellatrix.Transaction {
// Just use versionedHash as the transaction encoding to mock newPayloadV3 verification
// prefixed with BLOB_TX_TYPE
const transaction = new Uint8Array(33);
const versionedHash = kzgCommitmentToVersionedHash(kzgCommitment);
transaction[0] = BLOB_TX_TYPE;
transaction.set(versionedHash, 1);
return transaction;
}
/**
* Generate random blob of sequential integers such that each element is < BLS_MODULUS
*/
function generateRandomBlob(): deneb.Blob {
const blob = new Uint8Array(FIELD_ELEMENTS_PER_BLOB * BYTES_PER_FIELD_ELEMENT);
const dv = new DataView(blob.buffer, blob.byteOffset, blob.byteLength);
for (let i = 0; i < FIELD_ELEMENTS_PER_BLOB; i++) {
dv.setUint32(i * BYTES_PER_FIELD_ELEMENT, i);
}
return blob;
}