From f6907c2ddc13f4da8fa9dca98805a9b908526b14 Mon Sep 17 00:00:00 2001 From: Nazar Hussain Date: Tue, 26 Dec 2023 13:07:59 +0100 Subject: [PATCH] Move endpoints to standalone functions --- .../endpoints/getAggregatedAttestation.ts | 21 + .../validator/endpoints/getAttesterDuties.ts | 64 ++ .../impl/validator/endpoints/getLiveness.ts | 31 + .../validator/endpoints/getProposerDuties.ts | 95 ++ .../endpoints/getSyncCommitteeDuties.ts | 65 ++ .../endpoints/prepareBeaconCommitteeSubnet.ts | 32 + .../endpoints/prepareBeaconProposer.ts | 12 + .../endpoints/prepareSyncCommitteeSubnets.ts | 48 + .../endpoints/produceAttestationData.ts | 58 + .../endpoints/produceBlindedBlock.ts | 34 + .../impl/validator/endpoints/produceBlock.ts | 21 + .../validator/endpoints/produceBlockV2.ts | 86 ++ .../validator/endpoints/produceBlockV3.ts | 288 +++++ .../produceSyncCommitteeContribution.ts | 45 + .../endpoints/publishAggregateAndProofs.ts | 75 ++ .../endpoints/publishContributionAndProofs.ts | 59 + .../validator/endpoints/registerValidator.ts | 44 + .../submitBeaconCommitteeSelections.ts | 13 + .../submitSyncCommitteeSelections.ts | 13 + .../src/api/impl/validator/endpoints/types.ts | 14 + .../src/api/impl/validator/index.ts | 1012 ++--------------- 21 files changed, 1193 insertions(+), 937 deletions(-) create mode 100644 packages/beacon-node/src/api/impl/validator/endpoints/getAggregatedAttestation.ts create mode 100644 packages/beacon-node/src/api/impl/validator/endpoints/getAttesterDuties.ts create mode 100644 packages/beacon-node/src/api/impl/validator/endpoints/getLiveness.ts create mode 100644 packages/beacon-node/src/api/impl/validator/endpoints/getProposerDuties.ts create mode 100644 packages/beacon-node/src/api/impl/validator/endpoints/getSyncCommitteeDuties.ts create mode 100644 packages/beacon-node/src/api/impl/validator/endpoints/prepareBeaconCommitteeSubnet.ts create mode 100644 packages/beacon-node/src/api/impl/validator/endpoints/prepareBeaconProposer.ts create mode 100644 packages/beacon-node/src/api/impl/validator/endpoints/prepareSyncCommitteeSubnets.ts create mode 100644 packages/beacon-node/src/api/impl/validator/endpoints/produceAttestationData.ts create mode 100644 packages/beacon-node/src/api/impl/validator/endpoints/produceBlindedBlock.ts create mode 100644 packages/beacon-node/src/api/impl/validator/endpoints/produceBlock.ts create mode 100644 packages/beacon-node/src/api/impl/validator/endpoints/produceBlockV2.ts create mode 100644 packages/beacon-node/src/api/impl/validator/endpoints/produceBlockV3.ts create mode 100644 packages/beacon-node/src/api/impl/validator/endpoints/produceSyncCommitteeContribution.ts create mode 100644 packages/beacon-node/src/api/impl/validator/endpoints/publishAggregateAndProofs.ts create mode 100644 packages/beacon-node/src/api/impl/validator/endpoints/publishContributionAndProofs.ts create mode 100644 packages/beacon-node/src/api/impl/validator/endpoints/registerValidator.ts create mode 100644 packages/beacon-node/src/api/impl/validator/endpoints/submitBeaconCommitteeSelections.ts create mode 100644 packages/beacon-node/src/api/impl/validator/endpoints/submitSyncCommitteeSelections.ts create mode 100644 packages/beacon-node/src/api/impl/validator/endpoints/types.ts diff --git a/packages/beacon-node/src/api/impl/validator/endpoints/getAggregatedAttestation.ts b/packages/beacon-node/src/api/impl/validator/endpoints/getAggregatedAttestation.ts new file mode 100644 index 000000000000..de1de2112c62 --- /dev/null +++ b/packages/beacon-node/src/api/impl/validator/endpoints/getAggregatedAttestation.ts @@ -0,0 +1,21 @@ +import {ServerApi, routes} from "@lodestar/api"; +import {ApiModules} from "../../types.js"; +import {ValidatorEndpointDependencies} from "./types.js"; + +export function buildGetAggregatedAttestation( + {chain, metrics}: ApiModules, + {notWhileSyncing, waitForSlotWithDisparity}: ValidatorEndpointDependencies +): ServerApi["getAggregatedAttestation"] { + return async function getAggregatedAttestation(attestationDataRoot, slot) { + notWhileSyncing(); + + await waitForSlotWithDisparity(slot); // Must never request for a future slot > currentSlot + + const aggregate = chain.attestationPool.getAggregate(slot, attestationDataRoot); + metrics?.production.producedAggregateParticipants.observe(aggregate.aggregationBits.getTrueBitIndexes().length); + + return { + data: aggregate, + }; + }; +} diff --git a/packages/beacon-node/src/api/impl/validator/endpoints/getAttesterDuties.ts b/packages/beacon-node/src/api/impl/validator/endpoints/getAttesterDuties.ts new file mode 100644 index 000000000000..67f761f2615b --- /dev/null +++ b/packages/beacon-node/src/api/impl/validator/endpoints/getAttesterDuties.ts @@ -0,0 +1,64 @@ +import {ServerApi, routes} from "@lodestar/api"; +import {toHex} from "@lodestar/utils"; +import {attesterShufflingDecisionRoot} from "@lodestar/state-transition"; +import {RegenCaller} from "../../../../chain/regen/interface.js"; +import {isOptimisticBlock} from "../../../../util/forkChoice.js"; +import {ApiModules} from "../../types.js"; +import {getPubkeysForIndices} from "../utils.js"; +import {ApiError} from "../../errors.js"; +import {ValidatorEndpointDependencies} from "./types.js"; + +export function buildGetAttesterDuties( + {chain}: ApiModules, + {notWhileSyncing, getGenesisBlockRoot, waitForNextClosestEpoch}: ValidatorEndpointDependencies +): ServerApi["getAttesterDuties"] { + return async function getAttesterDuties(epoch, validatorIndices) { + notWhileSyncing(); + + if (validatorIndices.length === 0) { + throw new ApiError(400, "No validator to get attester duties"); + } + + // May request for an epoch that's in the future + await waitForNextClosestEpoch(); + + // should not compare to headEpoch in order to handle skipped slots + // Check if the epoch is in the future after waiting for requested slot + if (epoch > chain.clock.currentEpoch + 1) { + throw new ApiError(400, "Cannot get duties for epoch more than one ahead"); + } + + const head = chain.forkChoice.getHead(); + const state = await chain.getHeadStateAtCurrentEpoch(RegenCaller.getDuties); + + // TODO: Determine what the current epoch would be if we fast-forward our system clock by + // `MAXIMUM_GOSSIP_CLOCK_DISPARITY`. + // + // Most of the time, `tolerantCurrentEpoch` will be equal to `currentEpoch`. However, during + // the first `MAXIMUM_GOSSIP_CLOCK_DISPARITY` duration of the epoch `tolerantCurrentEpoch` + // will equal `currentEpoch + 1` + + // Check that all validatorIndex belong to the state before calling getCommitteeAssignments() + const pubkeys = getPubkeysForIndices(state.validators, validatorIndices); + const committeeAssignments = state.epochCtx.getCommitteeAssignments(epoch, validatorIndices); + const duties: routes.validator.AttesterDuty[] = []; + for (let i = 0, len = validatorIndices.length; i < len; i++) { + const validatorIndex = validatorIndices[i]; + const duty = committeeAssignments.get(validatorIndex) as routes.validator.AttesterDuty | undefined; + if (duty) { + // Mutate existing object instead of re-creating another new object with spread operator + // Should be faster and require less memory + duty.pubkey = pubkeys[i]; + duties.push(duty); + } + } + + const dependentRoot = attesterShufflingDecisionRoot(state, epoch) || (await getGenesisBlockRoot(state)); + + return { + data: duties, + dependentRoot: toHex(dependentRoot), + executionOptimistic: isOptimisticBlock(head), + }; + }; +} diff --git a/packages/beacon-node/src/api/impl/validator/endpoints/getLiveness.ts b/packages/beacon-node/src/api/impl/validator/endpoints/getLiveness.ts new file mode 100644 index 000000000000..4c2862e1ae05 --- /dev/null +++ b/packages/beacon-node/src/api/impl/validator/endpoints/getLiveness.ts @@ -0,0 +1,31 @@ +import {ServerApi, routes} from "@lodestar/api"; +import {ApiModules} from "../../types.js"; +import {ApiError} from "../../errors.js"; +import {ValidatorEndpointDependencies} from "./types.js"; + +export function buildGetLiveness( + {chain}: ApiModules, + _deps: ValidatorEndpointDependencies +): ServerApi["getLiveness"] { + return async function getLiveness(epoch, validatorIndices) { + if (validatorIndices.length === 0) { + return { + data: [], + }; + } + const currentEpoch = chain.clock.currentEpoch; + if (epoch < currentEpoch - 1 || epoch > currentEpoch + 1) { + throw new ApiError( + 400, + `Request epoch ${epoch} is more than one epoch before or after the current epoch ${currentEpoch}` + ); + } + + return { + data: validatorIndices.map((index) => ({ + index, + isLive: chain.validatorSeenAtEpoch(index, epoch), + })), + }; + }; +} diff --git a/packages/beacon-node/src/api/impl/validator/endpoints/getProposerDuties.ts b/packages/beacon-node/src/api/impl/validator/endpoints/getProposerDuties.ts new file mode 100644 index 000000000000..38e2cf8a7afc --- /dev/null +++ b/packages/beacon-node/src/api/impl/validator/endpoints/getProposerDuties.ts @@ -0,0 +1,95 @@ +import {ServerApi, routes} from "@lodestar/api"; +import { + CachedBeaconStateAllForks, + computeStartSlotAtEpoch, + proposerShufflingDecisionRoot, +} from "@lodestar/state-transition"; +import {ValidatorIndex} from "@lodestar/types"; +import {SLOTS_PER_EPOCH} from "@lodestar/params"; +import {toHex} from "@lodestar/utils"; +import {RegenCaller} from "../../../../chain/regen/interface.js"; +import {ApiModules} from "../../types.js"; +import {getPubkeysForIndices} from "../utils.js"; +import {SCHEDULER_LOOKAHEAD_FACTOR} from "../../../../chain/prepareNextSlot.js"; +import {isOptimisticBlock} from "../../../../util/forkChoice.js"; +import {ValidatorEndpointDependencies} from "./types.js"; + +export function buildGetProposerDuties( + {chain, config, metrics}: ApiModules, + { + notWhileSyncing, + getGenesisBlockRoot, + currentEpochWithDisparity, + msToNextEpoch, + waitForCheckpointState, + }: ValidatorEndpointDependencies +): ServerApi["getProposerDuties"] { + return async function getProposerDuties(epoch) { + notWhileSyncing(); + + // Early check that epoch is within [current_epoch, current_epoch + 1], or allow for pre-genesis + const currentEpoch = currentEpochWithDisparity(); + const nextEpoch = currentEpoch + 1; + if (currentEpoch >= 0 && epoch !== currentEpoch && epoch !== nextEpoch) { + throw Error(`Requested epoch ${epoch} must equal current ${currentEpoch} or next epoch ${nextEpoch}`); + } + + const head = chain.forkChoice.getHead(); + let state: CachedBeaconStateAllForks | undefined = undefined; + const slotMs = config.SECONDS_PER_SLOT * 1000; + const prepareNextSlotLookAheadMs = slotMs / SCHEDULER_LOOKAHEAD_FACTOR; + const toNextEpochMs = msToNextEpoch(); + // validators may request next epoch's duties when it's close to next epoch + // this is to avoid missed block proposal due to 0 epoch look ahead + if (epoch === nextEpoch && toNextEpochMs < prepareNextSlotLookAheadMs) { + // wait for maximum 1 slot for cp state which is the timeout of validator api + const cpState = await waitForCheckpointState({rootHex: head.blockRoot, epoch}); + if (cpState) { + state = cpState; + metrics?.duties.requestNextEpochProposalDutiesHit.inc(); + } else { + metrics?.duties.requestNextEpochProposalDutiesMiss.inc(); + } + } + + if (!state) { + state = await chain.getHeadStateAtCurrentEpoch(RegenCaller.getDuties); + } + + const stateEpoch = state.epochCtx.epoch; + let indexes: ValidatorIndex[] = []; + + if (epoch === stateEpoch) { + indexes = state.epochCtx.getBeaconProposers(); + } else if (epoch === stateEpoch + 1) { + // Requesting duties for next epoch is allow since they can be predicted with high probabilities. + // @see `epochCtx.getBeaconProposersNextEpoch` JSDocs for rationale. + indexes = state.epochCtx.getBeaconProposersNextEpoch(); + } else { + // Should never happen, epoch is checked to be in bounds above + throw Error(`Proposer duties for epoch ${epoch} not supported, current epoch ${stateEpoch}`); + } + + // NOTE: this is the fastest way of getting compressed pubkeys. + // See benchmark -> packages/lodestar/test/perf/api/impl/validator/attester.test.ts + // After dropping the flat caches attached to the CachedBeaconState it's no longer available. + // TODO: Add a flag to just send 0x00 as pubkeys since the Lodestar validator does not need them. + const pubkeys = getPubkeysForIndices(state.validators, indexes); + + const startSlot = computeStartSlotAtEpoch(stateEpoch); + const duties: routes.validator.ProposerDuty[] = []; + for (let i = 0; i < SLOTS_PER_EPOCH; i++) { + duties.push({slot: startSlot + i, validatorIndex: indexes[i], pubkey: pubkeys[i]}); + } + + // Returns `null` on the one-off scenario where the genesis block decides its own shuffling. + // It should be set to the latest block applied to `self` or the genesis block root. + const dependentRoot = proposerShufflingDecisionRoot(state) || (await getGenesisBlockRoot(state)); + + return { + data: duties, + dependentRoot: toHex(dependentRoot), + executionOptimistic: isOptimisticBlock(head), + }; + }; +} diff --git a/packages/beacon-node/src/api/impl/validator/endpoints/getSyncCommitteeDuties.ts b/packages/beacon-node/src/api/impl/validator/endpoints/getSyncCommitteeDuties.ts new file mode 100644 index 000000000000..a78c5493dca2 --- /dev/null +++ b/packages/beacon-node/src/api/impl/validator/endpoints/getSyncCommitteeDuties.ts @@ -0,0 +1,65 @@ +import {ServerApi, routes} from "@lodestar/api"; +import {isOptimisticBlock} from "../../../../util/forkChoice.js"; +import {ApiModules} from "../../types.js"; +import {getPubkeysForIndices} from "../utils.js"; +import {ApiError} from "../../errors.js"; +import {ValidatorEndpointDependencies} from "./types.js"; + +export function buildGetSyncCommitteeDuties( + {chain}: ApiModules, + {notWhileSyncing, waitForNextClosestEpoch}: ValidatorEndpointDependencies +): ServerApi["getSyncCommitteeDuties"] { + /** + * `POST /eth/v1/validator/duties/sync/{epoch}` + * + * Requests the beacon node to provide a set of sync committee duties for a particular epoch. + * - Although pubkey can be inferred from the index we return it to keep this call analogous with the one that + * fetches attester duties. + * - `sync_committee_index` is the index of the validator in the sync committee. This can be used to infer the + * subnet to which the contribution should be broadcast. Note, there can be multiple per validator. + * + * https://github.com/ethereum/beacon-APIs/pull/134 + * + * @param validatorIndices an array of the validator indices for which to obtain the duties. + */ + return async function getSyncCommitteeDuties(epoch, validatorIndices) { + notWhileSyncing(); + + if (validatorIndices.length === 0) { + throw new ApiError(400, "No validator to get attester duties"); + } + + // May request for an epoch that's in the future + await waitForNextClosestEpoch(); + + // sync committee duties have a lookahead of 1 day. Assuming the validator only requests duties for upcoming + // epochs, the head state will very likely have the duties available for the requested epoch. + // Note: does not support requesting past duties + const head = chain.forkChoice.getHead(); + const state = chain.getHeadState(); + + // Check that all validatorIndex belong to the state before calling getCommitteeAssignments() + const pubkeys = getPubkeysForIndices(state.validators, validatorIndices); + // Ensures `epoch // EPOCHS_PER_SYNC_COMMITTEE_PERIOD <= current_epoch // EPOCHS_PER_SYNC_COMMITTEE_PERIOD + 1` + const syncCommitteeCache = state.epochCtx.getIndexedSyncCommitteeAtEpoch(epoch); + const syncCommitteeValidatorIndexMap = syncCommitteeCache.validatorIndexMap; + + const duties: routes.validator.SyncDuty[] = []; + for (let i = 0, len = validatorIndices.length; i < len; i++) { + const validatorIndex = validatorIndices[i]; + const validatorSyncCommitteeIndices = syncCommitteeValidatorIndexMap.get(validatorIndex); + if (validatorSyncCommitteeIndices) { + duties.push({ + pubkey: pubkeys[i], + validatorIndex, + validatorSyncCommitteeIndices, + }); + } + } + + return { + data: duties, + executionOptimistic: isOptimisticBlock(head), + }; + }; +} diff --git a/packages/beacon-node/src/api/impl/validator/endpoints/prepareBeaconCommitteeSubnet.ts b/packages/beacon-node/src/api/impl/validator/endpoints/prepareBeaconCommitteeSubnet.ts new file mode 100644 index 000000000000..3824d7bdc789 --- /dev/null +++ b/packages/beacon-node/src/api/impl/validator/endpoints/prepareBeaconCommitteeSubnet.ts @@ -0,0 +1,32 @@ +import {ServerApi, routes} from "@lodestar/api"; +import {ApiModules} from "../../types.js"; +import {computeSubnetForCommitteesAtSlot} from "../utils.js"; +import {ValidatorEndpointDependencies} from "./types.js"; + +export function buildPrepareBeaconCommitteeSubnet( + {metrics, network}: ApiModules, + {notWhileSyncing}: ValidatorEndpointDependencies +): ServerApi["prepareBeaconCommitteeSubnet"] { + return async function prepareBeaconCommitteeSubnet(subscriptions) { + notWhileSyncing(); + + await network.prepareBeaconCommitteeSubnets( + subscriptions.map(({validatorIndex, slot, isAggregator, committeesAtSlot, committeeIndex}) => ({ + validatorIndex: validatorIndex, + subnet: computeSubnetForCommitteesAtSlot(slot, committeesAtSlot, committeeIndex), + slot: slot, + isAggregator: isAggregator, + })) + ); + + // TODO: + // If the discovery mechanism isn't disabled, attempt to set up a peer discovery for the + // required subnets. + + if (metrics) { + for (const subscription of subscriptions) { + metrics.registerLocalValidator(subscription.validatorIndex); + } + } + }; +} diff --git a/packages/beacon-node/src/api/impl/validator/endpoints/prepareBeaconProposer.ts b/packages/beacon-node/src/api/impl/validator/endpoints/prepareBeaconProposer.ts new file mode 100644 index 000000000000..ac598a7fca01 --- /dev/null +++ b/packages/beacon-node/src/api/impl/validator/endpoints/prepareBeaconProposer.ts @@ -0,0 +1,12 @@ +import {ServerApi, routes} from "@lodestar/api"; +import {ApiModules} from "../../types.js"; +import {ValidatorEndpointDependencies} from "./types.js"; + +export function buildPrepareBeaconProposer( + {chain}: ApiModules, + _deps: ValidatorEndpointDependencies +): ServerApi["prepareBeaconProposer"] { + return async function prepareBeaconProposer(proposers) { + await chain.updateBeaconProposerData(chain.clock.currentEpoch, proposers); + }; +} diff --git a/packages/beacon-node/src/api/impl/validator/endpoints/prepareSyncCommitteeSubnets.ts b/packages/beacon-node/src/api/impl/validator/endpoints/prepareSyncCommitteeSubnets.ts new file mode 100644 index 000000000000..9344bc46a66c --- /dev/null +++ b/packages/beacon-node/src/api/impl/validator/endpoints/prepareSyncCommitteeSubnets.ts @@ -0,0 +1,48 @@ +import {ServerApi, routes} from "@lodestar/api"; +import {SYNC_COMMITTEE_SUBNET_SIZE} from "@lodestar/params"; +import {computeStartSlotAtEpoch} from "@lodestar/state-transition"; +import {CommitteeSubscription} from "../../../../network/subnets/interface.js"; +import {ApiModules} from "../../types.js"; +import {ValidatorEndpointDependencies} from "./types.js"; + +export function buildPrepareSyncCommitteeSubnets( + {network, metrics}: ApiModules, + {notWhileSyncing}: ValidatorEndpointDependencies +): ServerApi["prepareSyncCommitteeSubnets"] { + /** + * POST `/eth/v1/validator/sync_committee_subscriptions` + * + * Subscribe to a number of sync committee subnets. + * Sync committees are not present in phase0, but are required for Altair networks. + * Subscribing to sync committee subnets is an action performed by VC to enable network participation in Altair networks, + * and only required if the VC has an active validator in an active sync committee. + * + * https://github.com/ethereum/beacon-APIs/pull/136 + */ + return async function prepareSyncCommitteeSubnets(subscriptions) { + notWhileSyncing(); + + // A `validatorIndex` can be in multiple subnets, so compute the CommitteeSubscription with double for loop + const subs: CommitteeSubscription[] = []; + for (const sub of subscriptions) { + for (const committeeIndex of sub.syncCommitteeIndices) { + const subnet = Math.floor(committeeIndex / SYNC_COMMITTEE_SUBNET_SIZE); + subs.push({ + validatorIndex: sub.validatorIndex, + subnet: subnet, + // Subscribe until the end of `untilEpoch`: https://github.com/ethereum/beacon-APIs/pull/136#issuecomment-840315097 + slot: computeStartSlotAtEpoch(sub.untilEpoch + 1), + isAggregator: true, + }); + } + } + + await network.prepareSyncCommitteeSubnets(subs); + + if (metrics) { + for (const subscription of subscriptions) { + metrics.registerLocalValidatorInSyncCommittee(subscription.validatorIndex, subscription.untilEpoch); + } + } + }; +} diff --git a/packages/beacon-node/src/api/impl/validator/endpoints/produceAttestationData.ts b/packages/beacon-node/src/api/impl/validator/endpoints/produceAttestationData.ts new file mode 100644 index 000000000000..81c204b86f0a --- /dev/null +++ b/packages/beacon-node/src/api/impl/validator/endpoints/produceAttestationData.ts @@ -0,0 +1,58 @@ +import {fromHexString} from "@chainsafe/ssz"; +import {ServerApi, routes} from "@lodestar/api"; +import {computeEpochAtSlot, computeStartSlotAtEpoch, getBlockRootAtSlot} from "@lodestar/state-transition"; +import {RegenCaller} from "../../../../chain/regen/interface.js"; +import {ApiModules} from "../../types.js"; +import {ValidatorEndpointDependencies} from "./types.js"; + +export function buildProduceAttestationData( + {chain}: ApiModules, + {notWhileSyncing, waitForSlotWithDisparity, notOnOptimisticBlockRoot}: ValidatorEndpointDependencies +): ServerApi["produceAttestationData"] { + return async function produceAttestationData(committeeIndex, slot) { + notWhileSyncing(); + + await waitForSlotWithDisparity(slot); // Must never request for a future slot > currentSlot + + // This needs a state in the same epoch as `slot` such that state.currentJustifiedCheckpoint is correct. + // Note: This may trigger an epoch transition if there skipped slots at the beginning of the epoch. + const headState = chain.getHeadState(); + const headSlot = headState.slot; + const attEpoch = computeEpochAtSlot(slot); + const headBlockRootHex = chain.forkChoice.getHead().blockRoot; + const headBlockRoot = fromHexString(headBlockRootHex); + + const beaconBlockRoot = + slot >= headSlot + ? // When attesting to the head slot or later, always use the head of the chain. + headBlockRoot + : // Permit attesting to slots *prior* to the current head. This is desirable when + // the VC and BN are out-of-sync due to time issues or overloading. + getBlockRootAtSlot(headState, slot); + + const targetSlot = computeStartSlotAtEpoch(attEpoch); + const targetRoot = + targetSlot >= headSlot + ? // If the state is earlier than the target slot then the target *must* be the head block root. + headBlockRoot + : getBlockRootAtSlot(headState, targetSlot); + + // Check the execution status as validator shouldn't vote on an optimistic head + // Check on target is sufficient as a valid target would imply a valid source + notOnOptimisticBlockRoot(targetRoot); + + // To get the correct source we must get a state in the same epoch as the attestation's epoch. + // An epoch transition may change state.currentJustifiedCheckpoint + const attEpochState = await chain.getHeadStateAtEpoch(attEpoch, RegenCaller.produceAttestationData); + + return { + data: { + slot, + index: committeeIndex, + beaconBlockRoot, + source: attEpochState.currentJustifiedCheckpoint, + target: {epoch: attEpoch, root: targetRoot}, + }, + }; + }; +} diff --git a/packages/beacon-node/src/api/impl/validator/endpoints/produceBlindedBlock.ts b/packages/beacon-node/src/api/impl/validator/endpoints/produceBlindedBlock.ts new file mode 100644 index 000000000000..e0307a05e544 --- /dev/null +++ b/packages/beacon-node/src/api/impl/validator/endpoints/produceBlindedBlock.ts @@ -0,0 +1,34 @@ +import {ServerApi, routes} from "@lodestar/api"; +import {allForks, isBlindedBeaconBlock, isBlockContents} from "@lodestar/types"; +import {isForkExecution} from "@lodestar/params"; +import {beaconBlockToBlinded} from "@lodestar/state-transition"; +import {ApiModules} from "../../types.js"; +import {ValidatorEndpointDependencies} from "./types.js"; + +export function buildProduceBlindedBlock( + {config}: ApiModules, + {produceBlockV3}: ValidatorEndpointDependencies & {produceBlockV3: ServerApi["produceBlockV3"]} +): ServerApi["produceBlindedBlock"] { + return async function produceBlindedBlock(slot, randaoReveal, graffiti) { + const {data, executionPayloadValue, consensusBlockValue, version} = await produceBlockV3( + slot, + randaoReveal, + graffiti + ); + if (!isForkExecution(version)) { + throw Error(`Invalid fork=${version} for produceEngineOrBuilderBlindedBlock`); + } + const executionPayloadBlinded = true; + + if (isBlockContents(data)) { + const {block} = data; + const blindedBlock = beaconBlockToBlinded(config, block as allForks.AllForksExecution["BeaconBlock"]); + return {executionPayloadValue, consensusBlockValue, data: blindedBlock, executionPayloadBlinded, version}; + } else if (isBlindedBeaconBlock(data)) { + return {executionPayloadValue, consensusBlockValue, data, executionPayloadBlinded, version}; + } else { + const blindedBlock = beaconBlockToBlinded(config, data as allForks.AllForksExecution["BeaconBlock"]); + return {executionPayloadValue, consensusBlockValue, data: blindedBlock, executionPayloadBlinded, version}; + } + }; +} diff --git a/packages/beacon-node/src/api/impl/validator/endpoints/produceBlock.ts b/packages/beacon-node/src/api/impl/validator/endpoints/produceBlock.ts new file mode 100644 index 000000000000..cd6ce5ca1589 --- /dev/null +++ b/packages/beacon-node/src/api/impl/validator/endpoints/produceBlock.ts @@ -0,0 +1,21 @@ +import {ServerApi, routes} from "@lodestar/api"; +import {isForkBlobs} from "@lodestar/params"; +import {allForks} from "@lodestar/types"; +import {ApiModules} from "../../types.js"; +import {ValidatorEndpointDependencies} from "./types.js"; + +export function buildProduceBlock( + _modules: ApiModules, + {produceBlockV2}: ValidatorEndpointDependencies & {produceBlockV2: ServerApi["produceBlockV2"]} +): ServerApi["produceBlock"] { + return async function produceBlock(slot, randaoReveal, graffiti) { + const producedData = await produceBlockV2(slot, randaoReveal, graffiti); + if (isForkBlobs(producedData.version)) { + throw Error(`Invalid call to produceBlock for deneb+ fork=${producedData.version}`); + } else { + // TODO: need to figure out why typescript requires typecasting here + // by typing of produceFullBlockOrContents respose it should have figured this out itself + return producedData as {data: allForks.BeaconBlock}; + } + }; +} diff --git a/packages/beacon-node/src/api/impl/validator/endpoints/produceBlockV2.ts b/packages/beacon-node/src/api/impl/validator/endpoints/produceBlockV2.ts new file mode 100644 index 000000000000..8cdda61e4db1 --- /dev/null +++ b/packages/beacon-node/src/api/impl/validator/endpoints/produceBlockV2.ts @@ -0,0 +1,86 @@ +import {toHexString} from "@chainsafe/ssz"; +import {ServerApi, routes} from "@lodestar/api"; +import {BLSSignature, ProducedBlockSource, Slot, allForks, bellatrix} from "@lodestar/types"; +import {isForkBlobs, isForkExecution} from "@lodestar/params"; +import {toHex} from "@lodestar/utils"; +import {ApiModules} from "../../types.js"; +import {toGraffitiBuffer} from "../../../../util/graffiti.js"; +import {ValidatorEndpointDependencies} from "./types.js"; + +export function buildProduceBlockV2( + {metrics, chain, config, logger}: ApiModules, + {notWhileSyncing, waitForSlotWithDisparity}: ValidatorEndpointDependencies +): ServerApi["produceBlockV2"] { + return async function produceBlockV2( + slot: Slot, + randaoReveal: BLSSignature, + graffiti: string, + { + feeRecipient, + strictFeeRecipientCheck, + skipHeadChecksAndUpdate, + }: Omit & {skipHeadChecksAndUpdate?: boolean} = {} + ): Promise { + const source = ProducedBlockSource.engine; + metrics?.blockProductionRequests.inc({source}); + + if (skipHeadChecksAndUpdate !== true) { + notWhileSyncing(); + await waitForSlotWithDisparity(slot); // Must never request for a future slot > currentSlot + + // Process the queued attestations in the forkchoice for correct head estimation + // forkChoice.updateTime() might have already been called by the onSlot clock + // handler, in which case this should just return. + chain.forkChoice.updateTime(slot); + chain.recomputeForkChoiceHead(); + } + + let timer; + try { + timer = metrics?.blockProductionTime.startTimer(); + const {block, executionPayloadValue, consensusBlockValue} = await chain.produceBlock({ + slot, + randaoReveal, + graffiti: toGraffitiBuffer(graffiti || ""), + feeRecipient, + }); + const version = config.getForkName(block.slot); + if (strictFeeRecipientCheck && feeRecipient && isForkExecution(version)) { + const blockFeeRecipient = toHexString((block as bellatrix.BeaconBlock).body.executionPayload.feeRecipient); + if (blockFeeRecipient !== feeRecipient) { + throw Error(`Invalid feeRecipient set in engine block expected=${feeRecipient} actual=${blockFeeRecipient}`); + } + } + + metrics?.blockProductionSuccess.inc({source}); + metrics?.blockProductionNumAggregated.observe({source}, block.body.attestations.length); + logger.verbose("Produced execution block", { + slot, + executionPayloadValue, + consensusBlockValue, + root: toHexString(config.getForkTypes(slot).BeaconBlock.hashTreeRoot(block)), + }); + if (chain.opts.persistProducedBlocks) { + void chain.persistBlock(block, "produced_engine_block"); + } + if (isForkBlobs(version)) { + const blockHash = toHex((block as bellatrix.BeaconBlock).body.executionPayload.blockHash); + const contents = chain.producedContentsCache.get(blockHash); + if (contents === undefined) { + throw Error("contents missing in cache"); + } + + return { + data: {block, ...contents} as allForks.BlockContents, + version, + executionPayloadValue, + consensusBlockValue, + }; + } else { + return {data: block, version, executionPayloadValue, consensusBlockValue}; + } + } finally { + if (timer) timer({source}); + } + }; +} diff --git a/packages/beacon-node/src/api/impl/validator/endpoints/produceBlockV3.ts b/packages/beacon-node/src/api/impl/validator/endpoints/produceBlockV3.ts new file mode 100644 index 000000000000..51f1b67fe3c8 --- /dev/null +++ b/packages/beacon-node/src/api/impl/validator/endpoints/produceBlockV3.ts @@ -0,0 +1,288 @@ +import {ServerApi, routes} from "@lodestar/api"; +import {ForkSeq, isForkExecution} from "@lodestar/params"; +import {BLSSignature, ProducedBlockSource, Slot} from "@lodestar/types"; +import {RaceEvent, gweiToWei, racePromisesWithCutoff, toHexString} from "@lodestar/utils"; +import {ApiModules} from "../../types.js"; +import {toGraffitiBuffer} from "../../../../util/graffiti.js"; +import {ValidatorEndpointDependencies} from "./types.js"; + +/** + * Cutoff time to wait for execution and builder block production apis to resolve + * Post this time, race execution and builder to pick whatever resolves first + * + * Empirically the builder block resolves in ~1.5+ seconds, and execution should resolve <1 sec. + * So lowering the cutoff to 2 sec from 3 seconds to publish faster for successful proposal + * as proposals post 4 seconds into the slot seems to be not being included + */ +const BLOCK_PRODUCTION_RACE_CUTOFF_MS = 2_000; +/** Overall timeout for execution and block production apis */ +const BLOCK_PRODUCTION_RACE_TIMEOUT_MS = 12_000; + +export function buildProduceBlockV3( + modules: ApiModules, + deps: ValidatorEndpointDependencies & {produceBlockV2: ServerApi["produceBlockV2"]} +): ServerApi["produceBlockV3"] { + return async function produceBlockV3( + slot, + randaoReveal, + graffiti, + // TODO deneb: skip randao verification + _skipRandaoVerification?: boolean, + {feeRecipient, builderSelection, strictFeeRecipientCheck}: routes.validator.ExtraProduceBlockOps = {} + ): Promise { + const {chain, config, logger} = modules; + const {notWhileSyncing, waitForSlotWithDisparity, produceBlockV2} = deps; + notWhileSyncing(); + await waitForSlotWithDisparity(slot); // Must never request for a future slot > currentSlot + + // Process the queued attestations in the forkchoice for correct head estimation + // forkChoice.updateTime() might have already been called by the onSlot clock + // handler, in which case this should just return. + chain.forkChoice.updateTime(slot); + chain.recomputeForkChoiceHead(); + + const fork = config.getForkName(slot); + // set some sensible opts + builderSelection = builderSelection ?? routes.validator.BuilderSelection.MaxProfit; + const isBuilderEnabled = + ForkSeq[fork] >= ForkSeq.bellatrix && + chain.executionBuilder !== undefined && + builderSelection !== routes.validator.BuilderSelection.ExecutionOnly; + + logger.verbose("Assembling block with produceBlockV3 ", { + fork, + builderSelection, + slot, + isBuilderEnabled, + strictFeeRecipientCheck, + }); + // Start calls for building execution and builder blocks + const blindedBlockPromise = isBuilderEnabled + ? // can't do fee recipient checks as builder bid doesn't return feeRecipient as of now + produceBuilderBlindedBlock(modules, deps, { + slot, + randaoReveal, + graffiti, + feeRecipient, + // skip checking and recomputing head in these individual produce calls + skipHeadChecksAndUpdate: true, + }).catch((e) => { + logger.error("produceBuilderBlindedBlock failed to produce block", {slot}, e); + return null; + }) + : null; + + const fullBlockPromise = + // At any point either the builder or execution or both flows should be active. + // + // Ideally such a scenario should be prevented on startup, but proposerSettingsFile or keymanager + // configurations could cause a validator pubkey to have builder disabled with builder selection builder only + // (TODO: independently make sure such an options update is not successful for a validator pubkey) + // + // So if builder is disabled ignore builder selection of builderonly if caused by user mistake + !isBuilderEnabled || builderSelection !== routes.validator.BuilderSelection.BuilderOnly + ? // TODO deneb: builderSelection needs to be figured out if to be done beacon side + // || builderSelection !== BuilderSelection.BuilderOnly + produceBlockV2(slot, randaoReveal, graffiti, { + feeRecipient, + strictFeeRecipientCheck, + // skip checking and recomputing head in these individual produce calls + skipHeadChecksAndUpdate: true, + }).catch((e) => { + logger.error("produceEngineFullBlockOrContents failed to produce block", {slot}, e); + return null; + }) + : null; + + let blindedBlock, fullBlock; + if (blindedBlockPromise !== null && fullBlockPromise !== null) { + // reference index of promises in the race + const promisesOrder = [ProducedBlockSource.builder, ProducedBlockSource.engine]; + [blindedBlock, fullBlock] = await racePromisesWithCutoff< + routes.validator.ProduceBlockOrContentsRes | routes.validator.ProduceBlindedBlockRes | null + >( + [blindedBlockPromise, fullBlockPromise], + BLOCK_PRODUCTION_RACE_CUTOFF_MS, + BLOCK_PRODUCTION_RACE_TIMEOUT_MS, + // Callback to log the race events for better debugging capability + (event: RaceEvent, delayMs: number, index?: number) => { + const eventRef = index !== undefined ? {source: promisesOrder[index]} : {}; + logger.verbose("Block production race (builder vs execution)", { + event, + ...eventRef, + delayMs, + cutoffMs: BLOCK_PRODUCTION_RACE_CUTOFF_MS, + timeoutMs: BLOCK_PRODUCTION_RACE_TIMEOUT_MS, + slot, + }); + } + ); + if (blindedBlock instanceof Error) { + // error here means race cutoff exceeded + logger.error("Failed to produce builder block", {slot}, blindedBlock); + blindedBlock = null; + } + if (fullBlock instanceof Error) { + logger.error("Failed to produce execution block", {slot}, fullBlock); + fullBlock = null; + } + } else if (blindedBlockPromise !== null && fullBlockPromise === null) { + blindedBlock = await blindedBlockPromise; + fullBlock = null; + } else if (blindedBlockPromise === null && fullBlockPromise !== null) { + blindedBlock = null; + fullBlock = await fullBlockPromise; + } else { + throw Error( + `Internal Error: Neither builder nor execution proposal flow activated isBuilderEnabled=${isBuilderEnabled} builderSelection=${builderSelection}` + ); + } + + const builderPayloadValue = blindedBlock?.executionPayloadValue ?? BigInt(0); + const enginePayloadValue = fullBlock?.executionPayloadValue ?? BigInt(0); + const consensusBlockValueBuilder = blindedBlock?.consensusBlockValue ?? BigInt(0); + const consensusBlockValueEngine = fullBlock?.consensusBlockValue ?? BigInt(0); + + const blockValueBuilder = builderPayloadValue + gweiToWei(consensusBlockValueBuilder); // Total block value is in wei + const blockValueEngine = enginePayloadValue + gweiToWei(consensusBlockValueEngine); // Total block value is in wei + + let selectedSource: ProducedBlockSource | null = null; + + if (fullBlock && blindedBlock) { + switch (builderSelection) { + case routes.validator.BuilderSelection.MaxProfit: { + if (blockValueEngine >= blockValueBuilder) { + selectedSource = ProducedBlockSource.engine; + } else { + selectedSource = ProducedBlockSource.builder; + } + break; + } + + case routes.validator.BuilderSelection.ExecutionOnly: { + selectedSource = ProducedBlockSource.engine; + break; + } + + // For everything else just select the builder + default: { + selectedSource = ProducedBlockSource.builder; + } + } + logger.verbose(`Selected ${selectedSource} block`, { + builderSelection, + // winston logger doesn't like bigint + enginePayloadValue: `${enginePayloadValue}`, + builderPayloadValue: `${builderPayloadValue}`, + consensusBlockValueEngine: `${consensusBlockValueEngine}`, + consensusBlockValueBuilder: `${consensusBlockValueBuilder}`, + blockValueEngine: `${blockValueEngine}`, + blockValueBuilder: `${blockValueBuilder}`, + slot, + }); + } else if (fullBlock && !blindedBlock) { + selectedSource = ProducedBlockSource.engine; + logger.verbose("Selected engine block: no builder block produced", { + // winston logger doesn't like bigint + enginePayloadValue: `${enginePayloadValue}`, + consensusBlockValueEngine: `${consensusBlockValueEngine}`, + blockValueEngine: `${blockValueEngine}`, + slot, + }); + } else if (blindedBlock && !fullBlock) { + selectedSource = ProducedBlockSource.builder; + logger.verbose("Selected builder block: no engine block produced", { + // winston logger doesn't like bigint + builderPayloadValue: `${builderPayloadValue}`, + consensusBlockValueBuilder: `${consensusBlockValueBuilder}`, + blockValueBuilder: `${blockValueBuilder}`, + slot, + }); + } + + if (selectedSource === null) { + throw Error(`Failed to produce engine or builder block for slot=${slot}`); + } + + if (selectedSource === ProducedBlockSource.engine) { + return {...fullBlock, executionPayloadBlinded: false} as routes.validator.ProduceBlockOrContentsRes & { + executionPayloadBlinded: false; + }; + } else { + return { + ...blindedBlock, + executionPayloadBlinded: true, + } as routes.validator.ProduceFullOrBlindedBlockOrContentsRes; + } + }; +} + +async function produceBuilderBlindedBlock( + {chain, config, logger, metrics}: ApiModules, + {notWhileSyncing, waitForSlotWithDisparity}: ValidatorEndpointDependencies, + { + slot, + randaoReveal, + graffiti, + // as of now fee recipient checks can not be performed because builder does not return bid recipient + skipHeadChecksAndUpdate, + }: { + slot: Slot; + randaoReveal: BLSSignature; + graffiti: string; + } & Omit & {skipHeadChecksAndUpdate?: boolean} +): Promise { + const version = config.getForkName(slot); + if (!isForkExecution(version)) { + throw Error(`Invalid fork=${version} for produceBuilderBlindedBlock`); + } + + const source = ProducedBlockSource.builder; + metrics?.blockProductionRequests.inc({source}); + + // Error early for builder if builder flow not active + if (!chain.executionBuilder) { + throw Error("Execution builder not set"); + } + if (!chain.executionBuilder.status) { + throw Error("Execution builder disabled"); + } + + if (skipHeadChecksAndUpdate !== true) { + notWhileSyncing(); + await waitForSlotWithDisparity(slot); // Must never request for a future slot > currentSlot + + // Process the queued attestations in the forkchoice for correct head estimation + // forkChoice.updateTime() might have already been called by the onSlot clock + // handler, in which case this should just return. + chain.forkChoice.updateTime(slot); + chain.recomputeForkChoiceHead(); + } + + let timer; + try { + timer = metrics?.blockProductionTime.startTimer(); + const {block, executionPayloadValue, consensusBlockValue} = await chain.produceBlindedBlock({ + slot, + randaoReveal, + graffiti: toGraffitiBuffer(graffiti || ""), + }); + + metrics?.blockProductionSuccess.inc({source}); + metrics?.blockProductionNumAggregated.observe({source}, block.body.attestations.length); + logger.verbose("Produced blinded block", { + slot, + executionPayloadValue, + consensusBlockValue, + root: toHexString(config.getBlindedForkTypes(slot).BeaconBlock.hashTreeRoot(block)), + }); + + if (chain.opts.persistProducedBlocks) { + void chain.persistBlock(block, "produced_builder_block"); + } + + return {data: block, version, executionPayloadValue, consensusBlockValue}; + } finally { + if (timer) timer({source}); + } +} diff --git a/packages/beacon-node/src/api/impl/validator/endpoints/produceSyncCommitteeContribution.ts b/packages/beacon-node/src/api/impl/validator/endpoints/produceSyncCommitteeContribution.ts new file mode 100644 index 000000000000..e4e8f322d47b --- /dev/null +++ b/packages/beacon-node/src/api/impl/validator/endpoints/produceSyncCommitteeContribution.ts @@ -0,0 +1,45 @@ +import {toHexString} from "@chainsafe/ssz"; +import {ServerApi, routes} from "@lodestar/api"; +import {ApiModules} from "../../types.js"; +import {ApiError} from "../../errors.js"; +import {ValidatorEndpointDependencies} from "./types.js"; + +export function buildProduceSyncCommitteeContribution( + {chain, network, metrics}: ApiModules, + {notOnOptimisticBlockRoot}: ValidatorEndpointDependencies +): ServerApi["produceSyncCommitteeContribution"] { + /** + * GET `/eth/v1/validator/sync_committee_contribution` + * + * Requests that the beacon node produce a sync committee contribution. + * + * https://github.com/ethereum/beacon-APIs/pull/138 + * + * @param slot The slot for which a sync committee contribution should be created. + * @param subcommitteeIndex The subcommittee index for which to produce the contribution. + * @param beaconBlockRoot The block root for which to produce the contribution. + */ + return async function produceSyncCommitteeContribution(slot, subcommitteeIndex, beaconBlockRoot) { + // when a validator is configured with multiple beacon node urls, this beaconBlockRoot may come from another beacon node + // and it hasn't been in our forkchoice since we haven't seen / processing that block + // see https://github.com/ChainSafe/lodestar/issues/5063 + if (!chain.forkChoice.hasBlock(beaconBlockRoot)) { + const rootHex = toHexString(beaconBlockRoot); + network.searchUnknownSlotRoot({slot, root: rootHex}); + // if result of this call is false, i.e. block hasn't seen after 1 slot then the below notOnOptimisticBlockRoot call will throw error + await chain.waitForBlock(slot, rootHex); + } + + // Check the execution status as validator shouldn't contribute on an optimistic head + notOnOptimisticBlockRoot(beaconBlockRoot); + + const contribution = chain.syncCommitteeMessagePool.getContribution(subcommitteeIndex, slot, beaconBlockRoot); + if (!contribution) throw new ApiError(500, "No contribution available"); + + metrics?.production.producedSyncContributionParticipants.observe( + contribution.aggregationBits.getTrueBitIndexes().length + ); + + return {data: contribution}; + }; +} diff --git a/packages/beacon-node/src/api/impl/validator/endpoints/publishAggregateAndProofs.ts b/packages/beacon-node/src/api/impl/validator/endpoints/publishAggregateAndProofs.ts new file mode 100644 index 000000000000..e8482c90b939 --- /dev/null +++ b/packages/beacon-node/src/api/impl/validator/endpoints/publishAggregateAndProofs.ts @@ -0,0 +1,75 @@ +import {ServerApi, routes} from "@lodestar/api"; +import {ssz} from "@lodestar/types"; +import {ApiModules} from "../../types.js"; +import {AttestationError, AttestationErrorCode} from "../../../../chain/errors/attestationError.js"; +import {GossipAction} from "../../../../chain/errors/gossipValidation.js"; +import {validateApiAggregateAndProof} from "../../../../chain/validation/aggregateAndProof.js"; +import {validateGossipFnRetryUnknownRoot} from "../../../../network/processor/gossipHandlers.js"; +import {ValidatorEndpointDependencies} from "./types.js"; + +export function buildPublishAggregateAndProofs( + {chain, metrics, network, logger}: ApiModules, + {notWhileSyncing}: ValidatorEndpointDependencies +): ServerApi["publishAggregateAndProofs"] { + return async function publishAggregateAndProofs(signedAggregateAndProofs) { + notWhileSyncing(); + + const seenTimestampSec = Date.now() / 1000; + const errors: Error[] = []; + const fork = chain.config.getForkName(chain.clock.currentSlot); + + await Promise.all( + signedAggregateAndProofs.map(async (signedAggregateAndProof, i) => { + try { + // TODO: Validate in batch + // eslint-disable-next-line @typescript-eslint/explicit-function-return-type + const validateFn = () => validateApiAggregateAndProof(fork, chain, signedAggregateAndProof); + const {slot, beaconBlockRoot} = signedAggregateAndProof.message.aggregate.data; + // when a validator is configured with multiple beacon node urls, this attestation may come from another beacon node + // and the block hasn't been in our forkchoice since we haven't seen / processing that block + // see https://github.com/ChainSafe/lodestar/issues/5098 + const {indexedAttestation, committeeIndices, attDataRootHex} = await validateGossipFnRetryUnknownRoot( + validateFn, + network, + chain, + slot, + beaconBlockRoot + ); + + chain.aggregatedAttestationPool.add( + signedAggregateAndProof.message.aggregate, + attDataRootHex, + indexedAttestation.attestingIndices.length, + committeeIndices + ); + const sentPeers = await network.publishBeaconAggregateAndProof(signedAggregateAndProof); + metrics?.onPoolSubmitAggregatedAttestation(seenTimestampSec, indexedAttestation, sentPeers); + } catch (e) { + if (e instanceof AttestationError && e.type.code === AttestationErrorCode.AGGREGATOR_ALREADY_KNOWN) { + logger.debug("Ignoring known signedAggregateAndProof"); + return; // Ok to submit the same aggregate twice + } + + errors.push(e as Error); + logger.error( + `Error on publishAggregateAndProofs [${i}]`, + { + slot: signedAggregateAndProof.message.aggregate.data.slot, + index: signedAggregateAndProof.message.aggregate.data.index, + }, + e as Error + ); + if (e instanceof AttestationError && e.action === GossipAction.REJECT) { + chain.persistInvalidSszValue(ssz.phase0.SignedAggregateAndProof, signedAggregateAndProof, "api_reject"); + } + } + }) + ); + + if (errors.length > 1) { + throw Error("Multiple errors on publishAggregateAndProofs\n" + errors.map((e) => e.message).join("\n")); + } else if (errors.length === 1) { + throw errors[0]; + } + }; +} diff --git a/packages/beacon-node/src/api/impl/validator/endpoints/publishContributionAndProofs.ts b/packages/beacon-node/src/api/impl/validator/endpoints/publishContributionAndProofs.ts new file mode 100644 index 000000000000..54c71dbbb039 --- /dev/null +++ b/packages/beacon-node/src/api/impl/validator/endpoints/publishContributionAndProofs.ts @@ -0,0 +1,59 @@ +import {ServerApi, routes} from "@lodestar/api"; +import {ssz} from "@lodestar/types"; +import {GossipAction} from "../../../../chain/errors/gossipValidation.js"; +import {SyncCommitteeError} from "../../../../chain/errors/syncCommitteeError.js"; +import {validateSyncCommitteeGossipContributionAndProof} from "../../../../chain/validation/syncCommitteeContributionAndProof.js"; +import {ApiModules} from "../../types.js"; +import {ValidatorEndpointDependencies} from "./types.js"; + +export function buildPublishContributionAndProofs( + {chain, network, logger}: ApiModules, + {notWhileSyncing}: ValidatorEndpointDependencies +): ServerApi["publishContributionAndProofs"] { + /** + * POST `/eth/v1/validator/contribution_and_proofs` + * + * Publish multiple signed sync committee contribution and proofs + * + * https://github.com/ethereum/beacon-APIs/pull/137 + */ + return async function publishContributionAndProofs(contributionAndProofs) { + notWhileSyncing(); + + const errors: Error[] = []; + + await Promise.all( + contributionAndProofs.map(async (contributionAndProof, i) => { + try { + // TODO: Validate in batch + const {syncCommitteeParticipantIndices} = await validateSyncCommitteeGossipContributionAndProof( + chain, + contributionAndProof, + true // skip known participants check + ); + chain.syncContributionAndProofPool.add(contributionAndProof.message, syncCommitteeParticipantIndices.length); + await network.publishContributionAndProof(contributionAndProof); + } catch (e) { + errors.push(e as Error); + logger.error( + `Error on publishContributionAndProofs [${i}]`, + { + slot: contributionAndProof.message.contribution.slot, + subcommitteeIndex: contributionAndProof.message.contribution.subcommitteeIndex, + }, + e as Error + ); + if (e instanceof SyncCommitteeError && e.action === GossipAction.REJECT) { + chain.persistInvalidSszValue(ssz.altair.SignedContributionAndProof, contributionAndProof, "api_reject"); + } + } + }) + ); + + if (errors.length > 1) { + throw Error("Multiple errors on publishContributionAndProofs\n" + errors.map((e) => e.message).join("\n")); + } else if (errors.length === 1) { + throw errors[0]; + } + }; +} diff --git a/packages/beacon-node/src/api/impl/validator/endpoints/registerValidator.ts b/packages/beacon-node/src/api/impl/validator/endpoints/registerValidator.ts new file mode 100644 index 000000000000..fca225fcc781 --- /dev/null +++ b/packages/beacon-node/src/api/impl/validator/endpoints/registerValidator.ts @@ -0,0 +1,44 @@ +import {ServerApi, routes} from "@lodestar/api"; +import {getValidatorStatus} from "../../beacon/state/utils.js"; +import {ApiModules} from "../../types.js"; +import {ValidatorEndpointDependencies} from "./types.js"; + +export function buildRegisterValidator( + {chain, logger}: ApiModules, + _dep: ValidatorEndpointDependencies +): ServerApi["registerValidator"] { + return async function registerValidator(registrations) { + if (!chain.executionBuilder) { + throw Error("Execution builder not enabled"); + } + + // should only send active or pending validator to builder + // Spec: https://ethereum.github.io/builder-specs/#/Builder/registerValidator + const headState = chain.getHeadState(); + const currentEpoch = chain.clock.currentEpoch; + + const filteredRegistrations = registrations.filter((registration) => { + const {pubkey} = registration.message; + const validatorIndex = headState.epochCtx.pubkey2index.get(pubkey); + if (validatorIndex === undefined) return false; + + const validator = headState.validators.getReadonly(validatorIndex); + const status = getValidatorStatus(validator, currentEpoch); + return ( + status === "active" || + status === "active_exiting" || + status === "active_ongoing" || + status === "active_slashed" || + status === "pending_initialized" || + status === "pending_queued" + ); + }); + + await chain.executionBuilder.registerValidator(filteredRegistrations); + + logger.debug("Forwarded validator registrations to connected builder", { + epoch: currentEpoch, + count: filteredRegistrations.length, + }); + }; +} diff --git a/packages/beacon-node/src/api/impl/validator/endpoints/submitBeaconCommitteeSelections.ts b/packages/beacon-node/src/api/impl/validator/endpoints/submitBeaconCommitteeSelections.ts new file mode 100644 index 000000000000..ba3637b03b1f --- /dev/null +++ b/packages/beacon-node/src/api/impl/validator/endpoints/submitBeaconCommitteeSelections.ts @@ -0,0 +1,13 @@ +import {ServerApi, routes} from "@lodestar/api"; +import {ApiModules} from "../../types.js"; +import {OnlySupportedByDVT} from "../../errors.js"; +import {ValidatorEndpointDependencies} from "./types.js"; + +export function buildSubmitBeaconCommitteeSelections( + _modules: ApiModules, + _dep: ValidatorEndpointDependencies +): ServerApi["submitBeaconCommitteeSelections"] { + return async function submitBeaconCommitteeSelections() { + throw new OnlySupportedByDVT(); + }; +} diff --git a/packages/beacon-node/src/api/impl/validator/endpoints/submitSyncCommitteeSelections.ts b/packages/beacon-node/src/api/impl/validator/endpoints/submitSyncCommitteeSelections.ts new file mode 100644 index 000000000000..d80dc4930869 --- /dev/null +++ b/packages/beacon-node/src/api/impl/validator/endpoints/submitSyncCommitteeSelections.ts @@ -0,0 +1,13 @@ +import {ServerApi, routes} from "@lodestar/api"; +import {ApiModules} from "../../types.js"; +import {OnlySupportedByDVT} from "../../errors.js"; +import {ValidatorEndpointDependencies} from "./types.js"; + +export function buildSubmitSyncCommitteeSelections( + _modules: ApiModules, + _dep: ValidatorEndpointDependencies +): ServerApi["submitSyncCommitteeSelections"] { + return async function submitSyncCommitteeSelections() { + throw new OnlySupportedByDVT(); + }; +} diff --git a/packages/beacon-node/src/api/impl/validator/endpoints/types.ts b/packages/beacon-node/src/api/impl/validator/endpoints/types.ts new file mode 100644 index 000000000000..16e484805dc5 --- /dev/null +++ b/packages/beacon-node/src/api/impl/validator/endpoints/types.ts @@ -0,0 +1,14 @@ +import {CachedBeaconStateAllForks} from "@lodestar/state-transition"; +import {Epoch, Root, Slot} from "@lodestar/types"; +import {CheckpointHex} from "../../../../chain/index.js"; + +export interface ValidatorEndpointDependencies { + waitForSlotWithDisparity(slot: Slot): Promise; + notWhileSyncing(): void; + notOnOptimisticBlockRoot(root: Root): void; + getGenesisBlockRoot(state: CachedBeaconStateAllForks): Promise; + waitForNextClosestEpoch(): Promise; + currentEpochWithDisparity(): Epoch; + msToNextEpoch(): number; + waitForCheckpointState(cpHex: CheckpointHex): Promise; +} diff --git a/packages/beacon-node/src/api/impl/validator/index.ts b/packages/beacon-node/src/api/impl/validator/index.ts index 3f813f32a3fd..30e566e7c5ce 100644 --- a/packages/beacon-node/src/api/impl/validator/index.ts +++ b/packages/beacon-node/src/api/impl/validator/index.ts @@ -1,56 +1,39 @@ -import {fromHexString, toHexString} from "@chainsafe/ssz"; -import {routes, ServerApi} from "@lodestar/api"; +import {fromHexString} from "@chainsafe/ssz"; +import {ServerApi, routes} from "@lodestar/api"; +import {ExecutionStatus} from "@lodestar/fork-choice"; +import {GENESIS_SLOT, SLOTS_PER_EPOCH, SLOTS_PER_HISTORICAL_ROOT} from "@lodestar/params"; import { CachedBeaconStateAllForks, - computeStartSlotAtEpoch, - proposerShufflingDecisionRoot, - attesterShufflingDecisionRoot, - getBlockRootAtSlot, computeEpochAtSlot, + computeStartSlotAtEpoch, getCurrentSlot, - beaconBlockToBlinded, } from "@lodestar/state-transition"; -import { - GENESIS_SLOT, - SLOTS_PER_EPOCH, - SLOTS_PER_HISTORICAL_ROOT, - SYNC_COMMITTEE_SUBNET_SIZE, - isForkBlobs, - isForkExecution, - ForkSeq, -} from "@lodestar/params"; -import { - Root, - Slot, - ValidatorIndex, - ssz, - Epoch, - ProducedBlockSource, - bellatrix, - allForks, - BLSSignature, - isBlindedBeaconBlock, - isBlockContents, - phase0, -} from "@lodestar/types"; -import {ExecutionStatus} from "@lodestar/fork-choice"; -import {toHex, racePromisesWithCutoff, RaceEvent, gweiToWei} from "@lodestar/utils"; -import {AttestationError, AttestationErrorCode, GossipAction, SyncCommitteeError} from "../../../chain/errors/index.js"; -import {validateApiAggregateAndProof} from "../../../chain/validation/index.js"; +import {Epoch, Root, Slot, phase0, ssz} from "@lodestar/types"; +import {ChainEvent, CheckpointHex} from "../../../chain/index.js"; import {ZERO_HASH} from "../../../constants/index.js"; import {SyncState} from "../../../sync/index.js"; -import {isOptimisticBlock} from "../../../util/forkChoice.js"; -import {toGraffitiBuffer} from "../../../util/graffiti.js"; -import {ApiError, NodeIsSyncing, OnlySupportedByDVT} from "../errors.js"; -import {validateSyncCommitteeGossipContributionAndProof} from "../../../chain/validation/syncCommitteeContributionAndProof.js"; -import {CommitteeSubscription} from "../../../network/subnets/index.js"; +import {ApiError, NodeIsSyncing} from "../errors.js"; import {ApiModules} from "../types.js"; -import {RegenCaller} from "../../../chain/regen/index.js"; -import {getValidatorStatus} from "../beacon/state/utils.js"; -import {validateGossipFnRetryUnknownRoot} from "../../../network/processor/gossipHandlers.js"; -import {SCHEDULER_LOOKAHEAD_FACTOR} from "../../../chain/prepareNextSlot.js"; -import {ChainEvent, CheckpointHex} from "../../../chain/index.js"; -import {computeSubnetForCommitteesAtSlot, getPubkeysForIndices} from "./utils.js"; +import {buildGetAggregatedAttestation} from "./endpoints/getAggregatedAttestation.js"; +import {buildGetAttesterDuties} from "./endpoints/getAttesterDuties.js"; +import {buildGetLiveness} from "./endpoints/getLiveness.js"; +import {buildGetProposerDuties} from "./endpoints/getProposerDuties.js"; +import {buildGetSyncCommitteeDuties} from "./endpoints/getSyncCommitteeDuties.js"; +import {buildPrepareBeaconCommitteeSubnet} from "./endpoints/prepareBeaconCommitteeSubnet.js"; +import {buildPrepareBeaconProposer} from "./endpoints/prepareBeaconProposer.js"; +import {buildPrepareSyncCommitteeSubnets} from "./endpoints/prepareSyncCommitteeSubnets.js"; +import {buildProduceAttestationData} from "./endpoints/produceAttestationData.js"; +import {buildProduceBlindedBlock} from "./endpoints/produceBlindedBlock.js"; +import {buildProduceBlock} from "./endpoints/produceBlock.js"; +import {buildProduceBlockV2} from "./endpoints/produceBlockV2.js"; +import {buildProduceBlockV3} from "./endpoints/produceBlockV3.js"; +import {buildProduceSyncCommitteeContribution} from "./endpoints/produceSyncCommitteeContribution.js"; +import {buildPublishAggregateAndProofs} from "./endpoints/publishAggregateAndProofs.js"; +import {buildPublishContributionAndProofs} from "./endpoints/publishContributionAndProofs.js"; +import {buildRegisterValidator} from "./endpoints/registerValidator.js"; +import {buildSubmitBeaconCommitteeSelections} from "./endpoints/submitBeaconCommitteeSelections.js"; +import {buildSubmitSyncCommitteeSelections} from "./endpoints/submitSyncCommitteeSelections.js"; +import {ValidatorEndpointDependencies} from "./endpoints/types.js"; /** * If the node is within this many epochs from the head, we declare it to be synced regardless of @@ -67,30 +50,12 @@ import {computeSubnetForCommitteesAtSlot, getPubkeysForIndices} from "./utils.js */ const SYNC_TOLERANCE_EPOCHS = 1; -/** - * Cutoff time to wait for execution and builder block production apis to resolve - * Post this time, race execution and builder to pick whatever resolves first - * - * Empirically the builder block resolves in ~1.5+ seconds, and execution should resolve <1 sec. - * So lowering the cutoff to 2 sec from 3 seconds to publish faster for successful proposal - * as proposals post 4 seconds into the slot seems to be not being included - */ -const BLOCK_PRODUCTION_RACE_CUTOFF_MS = 2_000; -/** Overall timeout for execution and block production apis */ -const BLOCK_PRODUCTION_RACE_TIMEOUT_MS = 12_000; - /** * Server implementation for handling validator duties. * See `@lodestar/validator/src/api` for the client implementation). */ -export function getValidatorApi({ - chain, - config, - logger, - metrics, - network, - sync, -}: ApiModules): ServerApi { +export function getValidatorApi(modules: ApiModules): ServerApi { + const {chain, config, sync} = modules; let genesisBlockRoot: Root | null = null; /** @@ -279,883 +244,56 @@ export function getValidatorApi({ ); } - const produceBuilderBlindedBlock = async function produceBuilderBlindedBlock( - slot: Slot, - randaoReveal: BLSSignature, - graffiti: string, - // as of now fee recipient checks can not be performed because builder does not return bid recipient - { - skipHeadChecksAndUpdate, - }: Omit & {skipHeadChecksAndUpdate?: boolean} = {} - ): Promise { - const version = config.getForkName(slot); - if (!isForkExecution(version)) { - throw Error(`Invalid fork=${version} for produceBuilderBlindedBlock`); - } - - const source = ProducedBlockSource.builder; - metrics?.blockProductionRequests.inc({source}); - - // Error early for builder if builder flow not active - if (!chain.executionBuilder) { - throw Error("Execution builder not set"); - } - if (!chain.executionBuilder.status) { - throw Error("Execution builder disabled"); - } - - if (skipHeadChecksAndUpdate !== true) { - notWhileSyncing(); - await waitForSlot(slot); // Must never request for a future slot > currentSlot - - // Process the queued attestations in the forkchoice for correct head estimation - // forkChoice.updateTime() might have already been called by the onSlot clock - // handler, in which case this should just return. - chain.forkChoice.updateTime(slot); - chain.recomputeForkChoiceHead(); - } - - let timer; - try { - timer = metrics?.blockProductionTime.startTimer(); - const {block, executionPayloadValue, consensusBlockValue} = await chain.produceBlindedBlock({ - slot, - randaoReveal, - graffiti: toGraffitiBuffer(graffiti || ""), - }); - - metrics?.blockProductionSuccess.inc({source}); - metrics?.blockProductionNumAggregated.observe({source}, block.body.attestations.length); - logger.verbose("Produced blinded block", { - slot, - executionPayloadValue, - consensusBlockValue, - root: toHexString(config.getBlindedForkTypes(slot).BeaconBlock.hashTreeRoot(block)), - }); - - if (chain.opts.persistProducedBlocks) { - void chain.persistBlock(block, "produced_builder_block"); - } - - return {data: block, version, executionPayloadValue, consensusBlockValue}; - } finally { - if (timer) timer({source}); - } - }; - - const produceEngineFullBlockOrContents = async function produceEngineFullBlockOrContents( - slot: Slot, - randaoReveal: BLSSignature, - graffiti: string, - { - feeRecipient, - strictFeeRecipientCheck, - skipHeadChecksAndUpdate, - }: Omit & {skipHeadChecksAndUpdate?: boolean} = {} - ): Promise { - const source = ProducedBlockSource.engine; - metrics?.blockProductionRequests.inc({source}); - - if (skipHeadChecksAndUpdate !== true) { - notWhileSyncing(); - await waitForSlot(slot); // Must never request for a future slot > currentSlot - - // Process the queued attestations in the forkchoice for correct head estimation - // forkChoice.updateTime() might have already been called by the onSlot clock - // handler, in which case this should just return. - chain.forkChoice.updateTime(slot); - chain.recomputeForkChoiceHead(); - } - - let timer; - try { - timer = metrics?.blockProductionTime.startTimer(); - const {block, executionPayloadValue, consensusBlockValue} = await chain.produceBlock({ - slot, - randaoReveal, - graffiti: toGraffitiBuffer(graffiti || ""), - feeRecipient, - }); - const version = config.getForkName(block.slot); - if (strictFeeRecipientCheck && feeRecipient && isForkExecution(version)) { - const blockFeeRecipient = toHexString((block as bellatrix.BeaconBlock).body.executionPayload.feeRecipient); - if (blockFeeRecipient !== feeRecipient) { - throw Error(`Invalid feeRecipient set in engine block expected=${feeRecipient} actual=${blockFeeRecipient}`); - } - } - - metrics?.blockProductionSuccess.inc({source}); - metrics?.blockProductionNumAggregated.observe({source}, block.body.attestations.length); - logger.verbose("Produced execution block", { - slot, - executionPayloadValue, - consensusBlockValue, - root: toHexString(config.getForkTypes(slot).BeaconBlock.hashTreeRoot(block)), - }); - if (chain.opts.persistProducedBlocks) { - void chain.persistBlock(block, "produced_engine_block"); - } - if (isForkBlobs(version)) { - const blockHash = toHex((block as bellatrix.BeaconBlock).body.executionPayload.blockHash); - const contents = chain.producedContentsCache.get(blockHash); - if (contents === undefined) { - throw Error("contents missing in cache"); - } - - return { - data: {block, ...contents} as allForks.BlockContents, - version, - executionPayloadValue, - consensusBlockValue, - }; - } else { - return {data: block, version, executionPayloadValue, consensusBlockValue}; - } - } finally { - if (timer) timer({source}); - } - }; - - const produceBlockV3: ServerApi["produceBlockV3"] = async function produceBlockV3( - slot, - randaoReveal, - graffiti, - // TODO deneb: skip randao verification - _skipRandaoVerification?: boolean, - {feeRecipient, builderSelection, strictFeeRecipientCheck}: routes.validator.ExtraProduceBlockOps = {} - ) { - notWhileSyncing(); - await waitForSlot(slot); // Must never request for a future slot > currentSlot - - // Process the queued attestations in the forkchoice for correct head estimation - // forkChoice.updateTime() might have already been called by the onSlot clock - // handler, in which case this should just return. - chain.forkChoice.updateTime(slot); - chain.recomputeForkChoiceHead(); - - const fork = config.getForkName(slot); - // set some sensible opts - builderSelection = builderSelection ?? routes.validator.BuilderSelection.MaxProfit; - const isBuilderEnabled = - ForkSeq[fork] >= ForkSeq.bellatrix && - chain.executionBuilder !== undefined && - builderSelection !== routes.validator.BuilderSelection.ExecutionOnly; - - logger.verbose("Assembling block with produceBlockV3 ", { - fork, - builderSelection, - slot, - isBuilderEnabled, - strictFeeRecipientCheck, - }); - // Start calls for building execution and builder blocks - const blindedBlockPromise = isBuilderEnabled - ? // can't do fee recipient checks as builder bid doesn't return feeRecipient as of now - produceBuilderBlindedBlock(slot, randaoReveal, graffiti, { - feeRecipient, - // skip checking and recomputing head in these individual produce calls - skipHeadChecksAndUpdate: true, - }).catch((e) => { - logger.error("produceBuilderBlindedBlock failed to produce block", {slot}, e); - return null; - }) - : null; - - const fullBlockPromise = - // At any point either the builder or execution or both flows should be active. - // - // Ideally such a scenario should be prevented on startup, but proposerSettingsFile or keymanager - // configurations could cause a validator pubkey to have builder disabled with builder selection builder only - // (TODO: independently make sure such an options update is not successful for a validator pubkey) - // - // So if builder is disabled ignore builder selection of builderonly if caused by user mistake - !isBuilderEnabled || builderSelection !== routes.validator.BuilderSelection.BuilderOnly - ? // TODO deneb: builderSelection needs to be figured out if to be done beacon side - // || builderSelection !== BuilderSelection.BuilderOnly - produceEngineFullBlockOrContents(slot, randaoReveal, graffiti, { - feeRecipient, - strictFeeRecipientCheck, - // skip checking and recomputing head in these individual produce calls - skipHeadChecksAndUpdate: true, - }).catch((e) => { - logger.error("produceEngineFullBlockOrContents failed to produce block", {slot}, e); - return null; - }) - : null; - - let blindedBlock, fullBlock; - if (blindedBlockPromise !== null && fullBlockPromise !== null) { - // reference index of promises in the race - const promisesOrder = [ProducedBlockSource.builder, ProducedBlockSource.engine]; - [blindedBlock, fullBlock] = await racePromisesWithCutoff< - routes.validator.ProduceBlockOrContentsRes | routes.validator.ProduceBlindedBlockRes | null - >( - [blindedBlockPromise, fullBlockPromise], - BLOCK_PRODUCTION_RACE_CUTOFF_MS, - BLOCK_PRODUCTION_RACE_TIMEOUT_MS, - // Callback to log the race events for better debugging capability - (event: RaceEvent, delayMs: number, index?: number) => { - const eventRef = index !== undefined ? {source: promisesOrder[index]} : {}; - logger.verbose("Block production race (builder vs execution)", { - event, - ...eventRef, - delayMs, - cutoffMs: BLOCK_PRODUCTION_RACE_CUTOFF_MS, - timeoutMs: BLOCK_PRODUCTION_RACE_TIMEOUT_MS, - slot, - }); - } - ); - if (blindedBlock instanceof Error) { - // error here means race cutoff exceeded - logger.error("Failed to produce builder block", {slot}, blindedBlock); - blindedBlock = null; - } - if (fullBlock instanceof Error) { - logger.error("Failed to produce execution block", {slot}, fullBlock); - fullBlock = null; - } - } else if (blindedBlockPromise !== null && fullBlockPromise === null) { - blindedBlock = await blindedBlockPromise; - fullBlock = null; - } else if (blindedBlockPromise === null && fullBlockPromise !== null) { - blindedBlock = null; - fullBlock = await fullBlockPromise; - } else { - throw Error( - `Internal Error: Neither builder nor execution proposal flow activated isBuilderEnabled=${isBuilderEnabled} builderSelection=${builderSelection}` - ); - } - - const builderPayloadValue = blindedBlock?.executionPayloadValue ?? BigInt(0); - const enginePayloadValue = fullBlock?.executionPayloadValue ?? BigInt(0); - const consensusBlockValueBuilder = blindedBlock?.consensusBlockValue ?? BigInt(0); - const consensusBlockValueEngine = fullBlock?.consensusBlockValue ?? BigInt(0); - - const blockValueBuilder = builderPayloadValue + gweiToWei(consensusBlockValueBuilder); // Total block value is in wei - const blockValueEngine = enginePayloadValue + gweiToWei(consensusBlockValueEngine); // Total block value is in wei - - let selectedSource: ProducedBlockSource | null = null; - - if (fullBlock && blindedBlock) { - switch (builderSelection) { - case routes.validator.BuilderSelection.MaxProfit: { - if (blockValueEngine >= blockValueBuilder) { - selectedSource = ProducedBlockSource.engine; - } else { - selectedSource = ProducedBlockSource.builder; - } - break; - } - - case routes.validator.BuilderSelection.ExecutionOnly: { - selectedSource = ProducedBlockSource.engine; - break; - } - - // For everything else just select the builder - default: { - selectedSource = ProducedBlockSource.builder; - } - } - logger.verbose(`Selected ${selectedSource} block`, { - builderSelection, - // winston logger doesn't like bigint - enginePayloadValue: `${enginePayloadValue}`, - builderPayloadValue: `${builderPayloadValue}`, - consensusBlockValueEngine: `${consensusBlockValueEngine}`, - consensusBlockValueBuilder: `${consensusBlockValueBuilder}`, - blockValueEngine: `${blockValueEngine}`, - blockValueBuilder: `${blockValueBuilder}`, - slot, - }); - } else if (fullBlock && !blindedBlock) { - selectedSource = ProducedBlockSource.engine; - logger.verbose("Selected engine block: no builder block produced", { - // winston logger doesn't like bigint - enginePayloadValue: `${enginePayloadValue}`, - consensusBlockValueEngine: `${consensusBlockValueEngine}`, - blockValueEngine: `${blockValueEngine}`, - slot, - }); - } else if (blindedBlock && !fullBlock) { - selectedSource = ProducedBlockSource.builder; - logger.verbose("Selected builder block: no engine block produced", { - // winston logger doesn't like bigint - builderPayloadValue: `${builderPayloadValue}`, - consensusBlockValueBuilder: `${consensusBlockValueBuilder}`, - blockValueBuilder: `${blockValueBuilder}`, - slot, - }); - } - - if (selectedSource === null) { - throw Error(`Failed to produce engine or builder block for slot=${slot}`); - } - - if (selectedSource === ProducedBlockSource.engine) { - return {...fullBlock, executionPayloadBlinded: false} as routes.validator.ProduceBlockOrContentsRes & { - executionPayloadBlinded: false; - }; - } else { - return {...blindedBlock, executionPayloadBlinded: true} as routes.validator.ProduceBlindedBlockRes & { - executionPayloadBlinded: true; - }; - } - }; - - const produceBlock: ServerApi["produceBlock"] = async function produceBlock( - slot, - randaoReveal, - graffiti - ) { - const producedData = await produceEngineFullBlockOrContents(slot, randaoReveal, graffiti); - if (isForkBlobs(producedData.version)) { - throw Error(`Invalid call to produceBlock for deneb+ fork=${producedData.version}`); - } else { - // TODO: need to figure out why typescript requires typecasting here - // by typing of produceFullBlockOrContents respose it should have figured this out itself - return producedData as {data: allForks.BeaconBlock}; - } + const deps: ValidatorEndpointDependencies = { + notOnOptimisticBlockRoot, + notWhileSyncing, + waitForSlotWithDisparity: waitForSlot, + getGenesisBlockRoot, + waitForNextClosestEpoch, + currentEpochWithDisparity, + msToNextEpoch, + waitForCheckpointState, }; - const produceEngineOrBuilderBlindedBlock: ServerApi["produceBlindedBlock"] = - async function produceEngineOrBuilderBlindedBlock(slot, randaoReveal, graffiti) { - const {data, executionPayloadValue, consensusBlockValue, version} = await produceBlockV3( - slot, - randaoReveal, - graffiti - ); - if (!isForkExecution(version)) { - throw Error(`Invalid fork=${version} for produceEngineOrBuilderBlindedBlock`); - } - const executionPayloadBlinded = true; - - if (isBlockContents(data)) { - const {block} = data; - const blindedBlock = beaconBlockToBlinded(config, block as allForks.AllForksExecution["BeaconBlock"]); - return {executionPayloadValue, consensusBlockValue, data: blindedBlock, executionPayloadBlinded, version}; - } else if (isBlindedBeaconBlock(data)) { - return {executionPayloadValue, consensusBlockValue, data, executionPayloadBlinded, version}; - } else { - const blindedBlock = beaconBlockToBlinded(config, data as allForks.AllForksExecution["BeaconBlock"]); - return {executionPayloadValue, consensusBlockValue, data: blindedBlock, executionPayloadBlinded, version}; - } - }; + const produceBlockV2 = buildProduceBlockV2(modules, deps); + const produceBlock = buildProduceBlock(modules, {...deps, produceBlockV2}); + const produceBlockV3 = buildProduceBlockV3(modules, {...deps, produceBlockV2}); + const produceBlindedBlock = buildProduceBlindedBlock(modules, {...deps, produceBlockV3}); + const produceAttestationData = buildProduceAttestationData(modules, deps); + const produceSyncCommitteeContribution = buildProduceSyncCommitteeContribution(modules, deps); + const getProposerDuties = buildGetProposerDuties(modules, deps); + const getAttesterDuties = buildGetAttesterDuties(modules, deps); + const getSyncCommitteeDuties = buildGetSyncCommitteeDuties(modules, deps); + const getAggregatedAttestation = buildGetAggregatedAttestation(modules, deps); + const publishAggregateAndProofs = buildPublishAggregateAndProofs(modules, deps); + const publishContributionAndProofs = buildPublishContributionAndProofs(modules, deps); + const prepareBeaconCommitteeSubnet = buildPrepareBeaconCommitteeSubnet(modules, deps); + const prepareSyncCommitteeSubnets = buildPrepareSyncCommitteeSubnets(modules, deps); + const prepareBeaconProposer = buildPrepareBeaconProposer(modules, deps); + const submitBeaconCommitteeSelections = buildSubmitBeaconCommitteeSelections(modules, deps); + const submitSyncCommitteeSelections = buildSubmitSyncCommitteeSelections(modules, deps); + const getLiveness = buildGetLiveness(modules, deps); + const registerValidator = buildRegisterValidator(modules, deps); return { produceBlock, - produceBlockV2: produceEngineFullBlockOrContents, + produceBlockV2, produceBlockV3, - produceBlindedBlock: produceEngineOrBuilderBlindedBlock, - - async produceAttestationData(committeeIndex, slot) { - notWhileSyncing(); - - await waitForSlot(slot); // Must never request for a future slot > currentSlot - - // This needs a state in the same epoch as `slot` such that state.currentJustifiedCheckpoint is correct. - // Note: This may trigger an epoch transition if there skipped slots at the beginning of the epoch. - const headState = chain.getHeadState(); - const headSlot = headState.slot; - const attEpoch = computeEpochAtSlot(slot); - const headBlockRootHex = chain.forkChoice.getHead().blockRoot; - const headBlockRoot = fromHexString(headBlockRootHex); - - const beaconBlockRoot = - slot >= headSlot - ? // When attesting to the head slot or later, always use the head of the chain. - headBlockRoot - : // Permit attesting to slots *prior* to the current head. This is desirable when - // the VC and BN are out-of-sync due to time issues or overloading. - getBlockRootAtSlot(headState, slot); - - const targetSlot = computeStartSlotAtEpoch(attEpoch); - const targetRoot = - targetSlot >= headSlot - ? // If the state is earlier than the target slot then the target *must* be the head block root. - headBlockRoot - : getBlockRootAtSlot(headState, targetSlot); - - // Check the execution status as validator shouldn't vote on an optimistic head - // Check on target is sufficient as a valid target would imply a valid source - notOnOptimisticBlockRoot(targetRoot); - - // To get the correct source we must get a state in the same epoch as the attestation's epoch. - // An epoch transition may change state.currentJustifiedCheckpoint - const attEpochState = await chain.getHeadStateAtEpoch(attEpoch, RegenCaller.produceAttestationData); - - return { - data: { - slot, - index: committeeIndex, - beaconBlockRoot, - source: attEpochState.currentJustifiedCheckpoint, - target: {epoch: attEpoch, root: targetRoot}, - }, - }; - }, - - /** - * GET `/eth/v1/validator/sync_committee_contribution` - * - * Requests that the beacon node produce a sync committee contribution. - * - * https://github.com/ethereum/beacon-APIs/pull/138 - * - * @param slot The slot for which a sync committee contribution should be created. - * @param subcommitteeIndex The subcommittee index for which to produce the contribution. - * @param beaconBlockRoot The block root for which to produce the contribution. - */ - async produceSyncCommitteeContribution(slot, subcommitteeIndex, beaconBlockRoot) { - // when a validator is configured with multiple beacon node urls, this beaconBlockRoot may come from another beacon node - // and it hasn't been in our forkchoice since we haven't seen / processing that block - // see https://github.com/ChainSafe/lodestar/issues/5063 - if (!chain.forkChoice.hasBlock(beaconBlockRoot)) { - const rootHex = toHexString(beaconBlockRoot); - network.searchUnknownSlotRoot({slot, root: rootHex}); - // if result of this call is false, i.e. block hasn't seen after 1 slot then the below notOnOptimisticBlockRoot call will throw error - await chain.waitForBlock(slot, rootHex); - } - - // Check the execution status as validator shouldn't contribute on an optimistic head - notOnOptimisticBlockRoot(beaconBlockRoot); - - const contribution = chain.syncCommitteeMessagePool.getContribution(subcommitteeIndex, slot, beaconBlockRoot); - if (!contribution) throw new ApiError(500, "No contribution available"); - - metrics?.production.producedSyncContributionParticipants.observe( - contribution.aggregationBits.getTrueBitIndexes().length - ); - - return {data: contribution}; - }, - - async getProposerDuties(epoch) { - notWhileSyncing(); - - // Early check that epoch is within [current_epoch, current_epoch + 1], or allow for pre-genesis - const currentEpoch = currentEpochWithDisparity(); - const nextEpoch = currentEpoch + 1; - if (currentEpoch >= 0 && epoch !== currentEpoch && epoch !== nextEpoch) { - throw Error(`Requested epoch ${epoch} must equal current ${currentEpoch} or next epoch ${nextEpoch}`); - } - - const head = chain.forkChoice.getHead(); - let state: CachedBeaconStateAllForks | undefined = undefined; - const slotMs = config.SECONDS_PER_SLOT * 1000; - const prepareNextSlotLookAheadMs = slotMs / SCHEDULER_LOOKAHEAD_FACTOR; - const toNextEpochMs = msToNextEpoch(); - // validators may request next epoch's duties when it's close to next epoch - // this is to avoid missed block proposal due to 0 epoch look ahead - if (epoch === nextEpoch && toNextEpochMs < prepareNextSlotLookAheadMs) { - // wait for maximum 1 slot for cp state which is the timeout of validator api - const cpState = await waitForCheckpointState({rootHex: head.blockRoot, epoch}); - if (cpState) { - state = cpState; - metrics?.duties.requestNextEpochProposalDutiesHit.inc(); - } else { - metrics?.duties.requestNextEpochProposalDutiesMiss.inc(); - } - } - - if (!state) { - state = await chain.getHeadStateAtCurrentEpoch(RegenCaller.getDuties); - } - - const stateEpoch = state.epochCtx.epoch; - let indexes: ValidatorIndex[] = []; - - if (epoch === stateEpoch) { - indexes = state.epochCtx.getBeaconProposers(); - } else if (epoch === stateEpoch + 1) { - // Requesting duties for next epoch is allow since they can be predicted with high probabilities. - // @see `epochCtx.getBeaconProposersNextEpoch` JSDocs for rationale. - indexes = state.epochCtx.getBeaconProposersNextEpoch(); - } else { - // Should never happen, epoch is checked to be in bounds above - throw Error(`Proposer duties for epoch ${epoch} not supported, current epoch ${stateEpoch}`); - } - - // NOTE: this is the fastest way of getting compressed pubkeys. - // See benchmark -> packages/lodestar/test/perf/api/impl/validator/attester.test.ts - // After dropping the flat caches attached to the CachedBeaconState it's no longer available. - // TODO: Add a flag to just send 0x00 as pubkeys since the Lodestar validator does not need them. - const pubkeys = getPubkeysForIndices(state.validators, indexes); - - const startSlot = computeStartSlotAtEpoch(stateEpoch); - const duties: routes.validator.ProposerDuty[] = []; - for (let i = 0; i < SLOTS_PER_EPOCH; i++) { - duties.push({slot: startSlot + i, validatorIndex: indexes[i], pubkey: pubkeys[i]}); - } - - // Returns `null` on the one-off scenario where the genesis block decides its own shuffling. - // It should be set to the latest block applied to `self` or the genesis block root. - const dependentRoot = proposerShufflingDecisionRoot(state) || (await getGenesisBlockRoot(state)); - - return { - data: duties, - dependentRoot: toHex(dependentRoot), - executionOptimistic: isOptimisticBlock(head), - }; - }, - - async getAttesterDuties(epoch, validatorIndices) { - notWhileSyncing(); - - if (validatorIndices.length === 0) { - throw new ApiError(400, "No validator to get attester duties"); - } - - // May request for an epoch that's in the future - await waitForNextClosestEpoch(); - - // should not compare to headEpoch in order to handle skipped slots - // Check if the epoch is in the future after waiting for requested slot - if (epoch > chain.clock.currentEpoch + 1) { - throw new ApiError(400, "Cannot get duties for epoch more than one ahead"); - } - - const head = chain.forkChoice.getHead(); - const state = await chain.getHeadStateAtCurrentEpoch(RegenCaller.getDuties); - - // TODO: Determine what the current epoch would be if we fast-forward our system clock by - // `MAXIMUM_GOSSIP_CLOCK_DISPARITY`. - // - // Most of the time, `tolerantCurrentEpoch` will be equal to `currentEpoch`. However, during - // the first `MAXIMUM_GOSSIP_CLOCK_DISPARITY` duration of the epoch `tolerantCurrentEpoch` - // will equal `currentEpoch + 1` - - // Check that all validatorIndex belong to the state before calling getCommitteeAssignments() - const pubkeys = getPubkeysForIndices(state.validators, validatorIndices); - const committeeAssignments = state.epochCtx.getCommitteeAssignments(epoch, validatorIndices); - const duties: routes.validator.AttesterDuty[] = []; - for (let i = 0, len = validatorIndices.length; i < len; i++) { - const validatorIndex = validatorIndices[i]; - const duty = committeeAssignments.get(validatorIndex) as routes.validator.AttesterDuty | undefined; - if (duty) { - // Mutate existing object instead of re-creating another new object with spread operator - // Should be faster and require less memory - duty.pubkey = pubkeys[i]; - duties.push(duty); - } - } - - const dependentRoot = attesterShufflingDecisionRoot(state, epoch) || (await getGenesisBlockRoot(state)); - - return { - data: duties, - dependentRoot: toHex(dependentRoot), - executionOptimistic: isOptimisticBlock(head), - }; - }, - - /** - * `POST /eth/v1/validator/duties/sync/{epoch}` - * - * Requests the beacon node to provide a set of sync committee duties for a particular epoch. - * - Although pubkey can be inferred from the index we return it to keep this call analogous with the one that - * fetches attester duties. - * - `sync_committee_index` is the index of the validator in the sync committee. This can be used to infer the - * subnet to which the contribution should be broadcast. Note, there can be multiple per validator. - * - * https://github.com/ethereum/beacon-APIs/pull/134 - * - * @param validatorIndices an array of the validator indices for which to obtain the duties. - */ - async getSyncCommitteeDuties(epoch, validatorIndices) { - notWhileSyncing(); - - if (validatorIndices.length === 0) { - throw new ApiError(400, "No validator to get attester duties"); - } - - // May request for an epoch that's in the future - await waitForNextClosestEpoch(); - - // sync committee duties have a lookahead of 1 day. Assuming the validator only requests duties for upcoming - // epochs, the head state will very likely have the duties available for the requested epoch. - // Note: does not support requesting past duties - const head = chain.forkChoice.getHead(); - const state = chain.getHeadState(); - - // Check that all validatorIndex belong to the state before calling getCommitteeAssignments() - const pubkeys = getPubkeysForIndices(state.validators, validatorIndices); - // Ensures `epoch // EPOCHS_PER_SYNC_COMMITTEE_PERIOD <= current_epoch // EPOCHS_PER_SYNC_COMMITTEE_PERIOD + 1` - const syncCommitteeCache = state.epochCtx.getIndexedSyncCommitteeAtEpoch(epoch); - const syncCommitteeValidatorIndexMap = syncCommitteeCache.validatorIndexMap; - - const duties: routes.validator.SyncDuty[] = []; - for (let i = 0, len = validatorIndices.length; i < len; i++) { - const validatorIndex = validatorIndices[i]; - const validatorSyncCommitteeIndices = syncCommitteeValidatorIndexMap.get(validatorIndex); - if (validatorSyncCommitteeIndices) { - duties.push({ - pubkey: pubkeys[i], - validatorIndex, - validatorSyncCommitteeIndices, - }); - } - } - - return { - data: duties, - executionOptimistic: isOptimisticBlock(head), - }; - }, - - async getAggregatedAttestation(attestationDataRoot, slot) { - notWhileSyncing(); - - await waitForSlot(slot); // Must never request for a future slot > currentSlot - - const aggregate = chain.attestationPool.getAggregate(slot, attestationDataRoot); - metrics?.production.producedAggregateParticipants.observe(aggregate.aggregationBits.getTrueBitIndexes().length); - - return { - data: aggregate, - }; - }, - - async publishAggregateAndProofs(signedAggregateAndProofs) { - notWhileSyncing(); - - const seenTimestampSec = Date.now() / 1000; - const errors: Error[] = []; - const fork = chain.config.getForkName(chain.clock.currentSlot); - - await Promise.all( - signedAggregateAndProofs.map(async (signedAggregateAndProof, i) => { - try { - // TODO: Validate in batch - // eslint-disable-next-line @typescript-eslint/explicit-function-return-type - const validateFn = () => validateApiAggregateAndProof(fork, chain, signedAggregateAndProof); - const {slot, beaconBlockRoot} = signedAggregateAndProof.message.aggregate.data; - // when a validator is configured with multiple beacon node urls, this attestation may come from another beacon node - // and the block hasn't been in our forkchoice since we haven't seen / processing that block - // see https://github.com/ChainSafe/lodestar/issues/5098 - const {indexedAttestation, committeeIndices, attDataRootHex} = await validateGossipFnRetryUnknownRoot( - validateFn, - network, - chain, - slot, - beaconBlockRoot - ); - - chain.aggregatedAttestationPool.add( - signedAggregateAndProof.message.aggregate, - attDataRootHex, - indexedAttestation.attestingIndices.length, - committeeIndices - ); - const sentPeers = await network.publishBeaconAggregateAndProof(signedAggregateAndProof); - metrics?.onPoolSubmitAggregatedAttestation(seenTimestampSec, indexedAttestation, sentPeers); - } catch (e) { - if (e instanceof AttestationError && e.type.code === AttestationErrorCode.AGGREGATOR_ALREADY_KNOWN) { - logger.debug("Ignoring known signedAggregateAndProof"); - return; // Ok to submit the same aggregate twice - } - - errors.push(e as Error); - logger.error( - `Error on publishAggregateAndProofs [${i}]`, - { - slot: signedAggregateAndProof.message.aggregate.data.slot, - index: signedAggregateAndProof.message.aggregate.data.index, - }, - e as Error - ); - if (e instanceof AttestationError && e.action === GossipAction.REJECT) { - chain.persistInvalidSszValue(ssz.phase0.SignedAggregateAndProof, signedAggregateAndProof, "api_reject"); - } - } - }) - ); - - if (errors.length > 1) { - throw Error("Multiple errors on publishAggregateAndProofs\n" + errors.map((e) => e.message).join("\n")); - } else if (errors.length === 1) { - throw errors[0]; - } - }, - - /** - * POST `/eth/v1/validator/contribution_and_proofs` - * - * Publish multiple signed sync committee contribution and proofs - * - * https://github.com/ethereum/beacon-APIs/pull/137 - */ - async publishContributionAndProofs(contributionAndProofs) { - notWhileSyncing(); - - const errors: Error[] = []; - - await Promise.all( - contributionAndProofs.map(async (contributionAndProof, i) => { - try { - // TODO: Validate in batch - const {syncCommitteeParticipantIndices} = await validateSyncCommitteeGossipContributionAndProof( - chain, - contributionAndProof, - true // skip known participants check - ); - chain.syncContributionAndProofPool.add( - contributionAndProof.message, - syncCommitteeParticipantIndices.length - ); - await network.publishContributionAndProof(contributionAndProof); - } catch (e) { - errors.push(e as Error); - logger.error( - `Error on publishContributionAndProofs [${i}]`, - { - slot: contributionAndProof.message.contribution.slot, - subcommitteeIndex: contributionAndProof.message.contribution.subcommitteeIndex, - }, - e as Error - ); - if (e instanceof SyncCommitteeError && e.action === GossipAction.REJECT) { - chain.persistInvalidSszValue(ssz.altair.SignedContributionAndProof, contributionAndProof, "api_reject"); - } - } - }) - ); - - if (errors.length > 1) { - throw Error("Multiple errors on publishContributionAndProofs\n" + errors.map((e) => e.message).join("\n")); - } else if (errors.length === 1) { - throw errors[0]; - } - }, - - async prepareBeaconCommitteeSubnet(subscriptions) { - notWhileSyncing(); - - await network.prepareBeaconCommitteeSubnets( - subscriptions.map(({validatorIndex, slot, isAggregator, committeesAtSlot, committeeIndex}) => ({ - validatorIndex: validatorIndex, - subnet: computeSubnetForCommitteesAtSlot(slot, committeesAtSlot, committeeIndex), - slot: slot, - isAggregator: isAggregator, - })) - ); - - // TODO: - // If the discovery mechanism isn't disabled, attempt to set up a peer discovery for the - // required subnets. - - if (metrics) { - for (const subscription of subscriptions) { - metrics.registerLocalValidator(subscription.validatorIndex); - } - } - }, - - /** - * POST `/eth/v1/validator/sync_committee_subscriptions` - * - * Subscribe to a number of sync committee subnets. - * Sync committees are not present in phase0, but are required for Altair networks. - * Subscribing to sync committee subnets is an action performed by VC to enable network participation in Altair networks, - * and only required if the VC has an active validator in an active sync committee. - * - * https://github.com/ethereum/beacon-APIs/pull/136 - */ - async prepareSyncCommitteeSubnets(subscriptions) { - notWhileSyncing(); - - // A `validatorIndex` can be in multiple subnets, so compute the CommitteeSubscription with double for loop - const subs: CommitteeSubscription[] = []; - for (const sub of subscriptions) { - for (const committeeIndex of sub.syncCommitteeIndices) { - const subnet = Math.floor(committeeIndex / SYNC_COMMITTEE_SUBNET_SIZE); - subs.push({ - validatorIndex: sub.validatorIndex, - subnet: subnet, - // Subscribe until the end of `untilEpoch`: https://github.com/ethereum/beacon-APIs/pull/136#issuecomment-840315097 - slot: computeStartSlotAtEpoch(sub.untilEpoch + 1), - isAggregator: true, - }); - } - } - - await network.prepareSyncCommitteeSubnets(subs); - - if (metrics) { - for (const subscription of subscriptions) { - metrics.registerLocalValidatorInSyncCommittee(subscription.validatorIndex, subscription.untilEpoch); - } - } - }, - - async prepareBeaconProposer(proposers) { - await chain.updateBeaconProposerData(chain.clock.currentEpoch, proposers); - }, - - async submitBeaconCommitteeSelections() { - throw new OnlySupportedByDVT(); - }, - - async submitSyncCommitteeSelections() { - throw new OnlySupportedByDVT(); - }, - - async getLiveness(epoch, validatorIndices) { - if (validatorIndices.length === 0) { - return { - data: [], - }; - } - const currentEpoch = chain.clock.currentEpoch; - if (epoch < currentEpoch - 1 || epoch > currentEpoch + 1) { - throw new ApiError( - 400, - `Request epoch ${epoch} is more than one epoch before or after the current epoch ${currentEpoch}` - ); - } - - return { - data: validatorIndices.map((index) => ({ - index, - isLive: chain.validatorSeenAtEpoch(index, epoch), - })), - }; - }, - - async registerValidator(registrations) { - if (!chain.executionBuilder) { - throw Error("Execution builder not enabled"); - } - - // should only send active or pending validator to builder - // Spec: https://ethereum.github.io/builder-specs/#/Builder/registerValidator - const headState = chain.getHeadState(); - const currentEpoch = chain.clock.currentEpoch; - - const filteredRegistrations = registrations.filter((registration) => { - const {pubkey} = registration.message; - const validatorIndex = headState.epochCtx.pubkey2index.get(pubkey); - if (validatorIndex === undefined) return false; - - const validator = headState.validators.getReadonly(validatorIndex); - const status = getValidatorStatus(validator, currentEpoch); - return ( - status === "active" || - status === "active_exiting" || - status === "active_ongoing" || - status === "active_slashed" || - status === "pending_initialized" || - status === "pending_queued" - ); - }); - - await chain.executionBuilder.registerValidator(filteredRegistrations); - - logger.debug("Forwarded validator registrations to connected builder", { - epoch: currentEpoch, - count: filteredRegistrations.length, - }); - }, + produceBlindedBlock, + produceAttestationData, + produceSyncCommitteeContribution, + getProposerDuties, + getAttesterDuties, + getSyncCommitteeDuties, + getAggregatedAttestation, + publishAggregateAndProofs, + publishContributionAndProofs, + prepareBeaconCommitteeSubnet, + prepareSyncCommitteeSubnets, + prepareBeaconProposer, + submitBeaconCommitteeSelections, + submitSyncCommitteeSelections, + getLiveness, + registerValidator, }; }