@lodestar/beacon-node
Version:
A Typescript implementation of the beacon chain
1,241 lines (1,098 loc) • 56.9 kB
text/typescript
import {ChainForkConfig} from "@lodestar/config";
import {MIN_ATTESTATION_INCLUSION_DELAY, SLOTS_PER_EPOCH} from "@lodestar/params";
import {
IBeaconStateView,
ParticipationFlags,
computeEpochAtSlot,
computeStartSlotAtEpoch,
computeTimeAtSlot,
getCurrentSlot,
isStatePostAltair,
parseAttesterFlags,
parseParticipationFlags,
} from "@lodestar/state-transition";
import {
BeaconBlock,
Epoch,
IndexedAttestation,
RootHex,
SignedAggregateAndProof,
Slot,
SubnetID,
ValidatorIndex,
altair,
deneb,
gloas,
} from "@lodestar/types";
import {LogData, LogHandler, LogLevel, Logger, MapDef, MapDefMax, prettyPrintIndices, toRootHex} from "@lodestar/utils";
import {GENESIS_SLOT} from "../constants/constants.js";
import {RegistryMetricCreator} from "../metrics/index.js";
/** The validator monitor collects per-epoch data about each monitored validator.
* Historical data will be kept around for `HISTORIC_EPOCHS` before it is pruned.
*/
const MAX_CACHED_EPOCHS = 4;
const MAX_CACHED_DISTINCT_TARGETS = 4;
// TODO GLOAS: re-evaluate these timings
const LATE_ATTESTATION_SUBMISSION_BPS = 5000;
const LATE_BLOCK_SUBMISSION_BPS = 2500;
/** Number of epochs to retain registered validators after their last registration */
const RETAIN_REGISTERED_VALIDATORS_EPOCHS = 2;
type Seconds = number;
export enum OpSource {
api = "api",
gossip = "gossip",
}
export type ValidatorMonitor = {
registerLocalValidator(index: number): void;
registerLocalValidatorInSyncCommittee(index: number, untilEpoch: Epoch): void;
registerValidatorStatuses(
currentEpoch: Epoch,
inclusionDelays: number[],
flags: number[],
isActiveCurrEpoch: boolean[],
isActivePrevEpoch: boolean[],
balances?: number[]
): void;
registerBeaconBlock(src: OpSource, delaySec: Seconds, block: BeaconBlock): void;
registerBlobSidecar(src: OpSource, seenTimestampSec: Seconds, blob: deneb.BlobSidecar): void;
registerExecutionPayloadEnvelope(
src: OpSource,
delaySec: Seconds,
envelope: gloas.SignedExecutionPayloadEnvelope
): void;
registerImportedBlock(block: BeaconBlock, data: {proposerBalanceDelta: number}): void;
onPoolSubmitUnaggregatedAttestation(
seenTimestampSec: number,
indexedAttestation: IndexedAttestation,
subnet: SubnetID,
sentPeers: number
): void;
onPoolSubmitAggregatedAttestation(
seenTimestampSec: number,
indexedAttestation: IndexedAttestation,
sentPeers: number
): void;
registerGossipUnaggregatedAttestation(seenTimestampSec: Seconds, indexedAttestation: IndexedAttestation): void;
registerGossipAggregatedAttestation(
seenTimestampSec: Seconds,
signedAggregateAndProof: SignedAggregateAndProof,
indexedAttestation: IndexedAttestation
): void;
registerAttestationInBlock(
indexedAttestation: IndexedAttestation,
parentSlot: Slot,
correctHead: boolean,
missedSlotVote: boolean,
inclusionBlockRoot: RootHex,
inclusionBlockSlot: Slot
): void;
registerGossipSyncContributionAndProof(
syncContributionAndProof: altair.ContributionAndProof,
syncCommitteeParticipantIndices: ValidatorIndex[]
): void;
registerSyncAggregateInBlock(
epoch: Epoch,
syncAggregate: altair.SyncAggregate,
syncCommitteeIndices: Uint32Array
): void;
onceEveryEndOfEpoch(state: IBeaconStateView): void;
scrapeMetrics(slotClock: Slot): void;
/** Returns the list of validator indices currently being monitored */
getMonitoredValidatorIndices(): ValidatorIndex[];
};
export type ValidatorMonitorOpts = {
/** Log validator monitor events as info */
validatorMonitorLogs?: boolean;
};
export const defaultValidatorMonitorOpts: ValidatorMonitorOpts = {
validatorMonitorLogs: false,
};
/** Information required to reward some validator during the current and previous epoch. */
type ValidatorStatus = {
/** True if the validator has been slashed, ever. */
isSlashed: boolean;
/** True if the validator was active in the state's _current_ epoch. */
isActiveInCurrentEpoch: boolean;
/** True if the validator was active in the state's _previous_ epoch. */
isActiveInPreviousEpoch: boolean;
/** The validator's effective balance in the _current_ epoch. */
currentEpochEffectiveBalance: number;
/** True if the validator had an attestation included in the _previous_ epoch. */
isPrevSourceAttester: boolean;
/** True if the validator's beacon block root attestation for the first slot of the _previous_
epoch matches the block root known to the state. */
isPrevTargetAttester: boolean;
/** True if the validator's beacon block root attestation in the _previous_ epoch at the
attestation's slot (`attestation_data.slot`) matches the block root known to the state. */
isPrevHeadAttester: boolean;
/** True if the validator had an attestation included in the _current_ epoch. */
isCurrSourceAttester: boolean;
/** True if the validator's beacon block root attestation for the first slot of the _current_
epoch matches the block root known to the state. */
isCurrTargetAttester: boolean;
/** True if the validator's beacon block root attestation in the _current_ epoch at the
attestation's slot (`attestation_data.slot`) matches the block root known to the state. */
isCurrHeadAttester: boolean;
/** The distance between the attestation slot and the slot that attestation was included in a block. */
inclusionDistance: number;
};
function statusToSummary(
inclusionDelay: number,
flag: number,
isActiveInCurrentEpoch: boolean,
isActiveInPreviousEpoch: boolean
): ValidatorStatus {
const flags = parseAttesterFlags(flag);
return {
isSlashed: flags.unslashed,
isActiveInCurrentEpoch,
isActiveInPreviousEpoch,
// TODO: Implement
currentEpochEffectiveBalance: 0,
isPrevSourceAttester: flags.prevSourceAttester,
isPrevTargetAttester: flags.prevTargetAttester,
isPrevHeadAttester: flags.prevHeadAttester,
isCurrSourceAttester: flags.currSourceAttester,
isCurrTargetAttester: flags.currTargetAttester,
isCurrHeadAttester: flags.currHeadAttester,
inclusionDistance: inclusionDelay,
};
}
/** Contains data pertaining to one validator for one epoch. */
type EpochSummary = {
// Attestations with a target in the current epoch.
/** The number of attestations seen. */
attestations: number;
/** The delay between when the attestation should have been produced and when it was observed. */
attestationMinDelay: Seconds | null;
/** The number of times a validators attestation was seen in an aggregate. */
attestationAggregateInclusions: number;
/** The number of times a validators attestation was seen in a block. */
attestationBlockInclusions: number;
/** The minimum observed inclusion distance for an attestation for this epoch.. */
attestationMinBlockInclusionDistance: Slot | null;
/** The attestation contains the correct head or not */
attestationCorrectHead: boolean | null;
// Blocks with a slot in the current epoch.
/** The number of blocks observed. */
blocks: number;
/** The delay between when the block should have been produced and when it was observed. */
blockMinDelay: Seconds | null;
// Aggregates with a target in the current epoch
/** The number of signed aggregate and proofs observed. */
aggregates: number;
/** The delay between when the aggregate should have been produced and when it was observed. */
aggregateMinDelay: Seconds | null;
/** Count of times validator expected in sync aggregate participated */
syncCommitteeHits: number;
/** Count of times validator expected in sync aggregate failed to participate */
syncCommitteeMisses: number;
/** Number of times a validator's sync signature was seen in an aggregate */
syncSignatureAggregateInclusions: number;
/** Submitted proposals from this validator at this epoch */
blockProposals: BlockProposals[];
};
type BlockProposals = {
blockRoot: RootHex;
blockSlot: Slot;
poolSubmitDelaySec: number | null;
successfullyImported: boolean;
};
function getEpochSummary(validator: MonitoredValidator, epoch: Epoch): EpochSummary {
let summary = validator.summaries.get(epoch);
if (!summary) {
summary = {
attestations: 0,
attestationMinDelay: null,
attestationAggregateInclusions: 0,
attestationBlockInclusions: 0,
attestationMinBlockInclusionDistance: null,
blocks: 0,
blockMinDelay: null,
aggregates: 0,
aggregateMinDelay: null,
attestationCorrectHead: null,
syncCommitteeHits: 0,
syncCommitteeMisses: 0,
syncSignatureAggregateInclusions: 0,
blockProposals: [],
};
validator.summaries.set(epoch, summary);
}
// Prune
const toPrune = validator.summaries.size - MAX_CACHED_EPOCHS;
if (toPrune > 0) {
let pruned = 0;
for (const idx of validator.summaries.keys()) {
validator.summaries.delete(idx);
if (++pruned >= toPrune) break;
}
}
return summary;
}
// To uniquely identify an attestation:
// `index=$validator_index target=$target_epoch:$target_root
type AttestationSummary = {
poolSubmitDelayMinSec: number | null;
poolSubmitSentPeers: number | null;
aggregateInclusionDelaysSec: number[];
blockInclusions: AttestationBlockInclusion[];
};
type AttestationBlockInclusion = {
blockRoot: RootHex;
blockSlot: Slot;
votedCorrectHeadRoot: boolean;
votedForMissedSlot: boolean;
attestationSlot: Slot;
};
/** `$target_epoch:$target_root` */
type TargetRoot = string;
/// A validator that is being monitored by the `ValidatorMonitor`. */
type MonitoredValidator = {
/// A history of the validator over time. */
summaries: Map<Epoch, EpochSummary>;
inSyncCommitteeUntilEpoch: number;
// Unless the validator slashes itself, there MUST be one attestation per target checkpoint
attestations: MapDefMax<Epoch, MapDefMax<TargetRoot, AttestationSummary>>;
lastRegisteredTimeMs: number;
};
export function createValidatorMonitor(
metricsRegister: RegistryMetricCreator | null,
config: ChainForkConfig,
genesisTime: number,
logger: Logger,
opts: ValidatorMonitorOpts
): ValidatorMonitor {
const logLevel = opts.validatorMonitorLogs ? LogLevel.info : LogLevel.debug;
const log: LogHandler = (message: string, context?: LogData) => {
logger[logLevel](message, context);
};
// Calculate retain time dynamically based on slot duration (2 epochs)
const retainRegisteredValidatorsMs = SLOTS_PER_EPOCH * config.SLOT_DURATION_MS * RETAIN_REGISTERED_VALIDATORS_EPOCHS;
/** The validators that require additional monitoring. */
const validators = new MapDef<ValidatorIndex, MonitoredValidator>(() => ({
summaries: new Map<Epoch, EpochSummary>(),
inSyncCommitteeUntilEpoch: -1,
attestations: new MapDefMax(
() =>
new MapDefMax(
() => ({
poolSubmitDelayMinSec: null,
poolSubmitSentPeers: null,
aggregateInclusionDelaysSec: [],
blockInclusions: [],
}),
MAX_CACHED_DISTINCT_TARGETS
),
MAX_CACHED_EPOCHS
),
lastRegisteredTimeMs: 0,
}));
let lastRegisteredStatusEpoch = -1;
// Track validator additions/removals per epoch for logging
const addedValidatorsInEpoch: Set<ValidatorIndex> = new Set();
const removedValidatorsInEpoch: Set<ValidatorIndex> = new Set();
const validatorMonitorMetrics = metricsRegister ? createValidatorMonitorMetrics(metricsRegister) : null;
const validatorMonitor: ValidatorMonitor = {
registerLocalValidator(index) {
const isNewValidator = !validators.has(index);
validators.getOrDefault(index).lastRegisteredTimeMs = Date.now();
if (isNewValidator) {
addedValidatorsInEpoch.add(index);
}
},
registerLocalValidatorInSyncCommittee(index, untilEpoch) {
const validator = validators.get(index);
if (validator) {
validator.inSyncCommitteeUntilEpoch = Math.max(untilEpoch, validator.inSyncCommitteeUntilEpoch ?? -1);
}
},
registerValidatorStatuses(currentEpoch, inclusionDelays, flags, isActiveCurrEpoch, isActiveInPrevEpoch, balances) {
// Prevent registering status for the same epoch twice. processEpoch() may be ran more than once for the same epoch.
if (currentEpoch <= lastRegisteredStatusEpoch) {
return;
}
lastRegisteredStatusEpoch = currentEpoch;
const previousEpoch = currentEpoch - 1;
// There won't be any validator activity in epoch -1
if (previousEpoch === -1) {
return;
}
// Track total balance instead of per-validator balance to reduce metric cardinality
let totalBalance = 0;
for (const [index, monitoredValidator] of validators.entries()) {
// We subtract two from the state of the epoch that generated these summaries.
//
// - One to account for it being the previous epoch.
// - One to account for the state advancing an epoch whilst generating the validator
// statuses.
const summary = statusToSummary(
inclusionDelays[index],
flags[index],
isActiveCurrEpoch[index],
isActiveInPrevEpoch[index]
);
if (summary.isPrevSourceAttester) {
validatorMonitorMetrics?.prevEpochOnChainSourceAttesterHit.inc();
} else {
validatorMonitorMetrics?.prevEpochOnChainSourceAttesterMiss.inc();
}
if (summary.isPrevHeadAttester) {
validatorMonitorMetrics?.prevEpochOnChainHeadAttesterHit.inc();
} else {
validatorMonitorMetrics?.prevEpochOnChainHeadAttesterMiss.inc();
}
if (summary.isPrevTargetAttester) {
validatorMonitorMetrics?.prevEpochOnChainTargetAttesterHit.inc();
} else {
validatorMonitorMetrics?.prevEpochOnChainTargetAttesterMiss.inc();
}
const prevEpochSummary = monitoredValidator.summaries.get(previousEpoch);
const attestationCorrectHead = prevEpochSummary?.attestationCorrectHead;
if (attestationCorrectHead !== null && attestationCorrectHead !== undefined) {
if (attestationCorrectHead) {
validatorMonitorMetrics?.prevOnChainAttesterCorrectHead.inc();
} else {
validatorMonitorMetrics?.prevOnChainAttesterIncorrectHead.inc();
}
}
const attestationMinBlockInclusionDistance = prevEpochSummary?.attestationMinBlockInclusionDistance;
const inclusionDistance =
attestationMinBlockInclusionDistance != null && attestationMinBlockInclusionDistance > 0
? // altair, attestation is not missed
attestationMinBlockInclusionDistance
: summary.inclusionDistance
? // phase0, this is from the state transition
summary.inclusionDistance
: null;
if (inclusionDistance !== null) {
validatorMonitorMetrics?.prevEpochOnChainInclusionDistance.observe(inclusionDistance);
validatorMonitorMetrics?.prevEpochOnChainAttesterHit.inc();
} else {
validatorMonitorMetrics?.prevEpochOnChainAttesterMiss.inc();
}
const balance = balances?.[index];
if (balance !== undefined) {
totalBalance += balance;
}
if (!summary.isPrevSourceAttester || !summary.isPrevTargetAttester || !summary.isPrevHeadAttester) {
log("Failed attestation in previous epoch", {
validator: index,
prevEpoch: currentEpoch - 1,
isPrevSourceAttester: summary.isPrevSourceAttester,
isPrevHeadAttester: summary.isPrevHeadAttester,
isPrevTargetAttester: summary.isPrevTargetAttester,
// inclusionDistance is not available in summary since altair
inclusionDistance,
});
}
}
if (balances !== undefined) {
validatorMonitorMetrics?.prevEpochOnChainBalance.set(totalBalance);
}
},
registerBeaconBlock(src, delaySec, block) {
const validator = validators.get(block.proposerIndex);
// Returns the delay between the start of `block.slot` and `seenTimestamp`.
if (validator) {
validatorMonitorMetrics?.beaconBlockTotal.inc({src});
validatorMonitorMetrics?.beaconBlockDelaySeconds.observe({src}, delaySec);
const summary = getEpochSummary(validator, computeEpochAtSlot(block.slot));
summary.blockProposals.push({
blockRoot: toRootHex(config.getForkTypes(block.slot).BeaconBlock.hashTreeRoot(block)),
blockSlot: block.slot,
poolSubmitDelaySec: delaySec,
successfullyImported: false,
});
}
},
registerBlobSidecar(_src, _seenTimestampSec, _blob) {
//TODO: freetheblobs
},
registerExecutionPayloadEnvelope(_src, _delaySec, _envelope) {
// TODO GLOAS: implement execution payload envelope monitoring
},
registerImportedBlock(block, {proposerBalanceDelta}) {
const validator = validators.get(block.proposerIndex);
if (validator) {
validatorMonitorMetrics?.proposerBalanceDeltaKnown.observe(proposerBalanceDelta);
// There should be alredy a summary for the block. Could be missing when using one VC multiple BNs
const summary = getEpochSummary(validator, computeEpochAtSlot(block.slot));
const proposal = summary.blockProposals.find((p) => p.blockSlot === block.slot);
if (proposal) {
proposal.successfullyImported = true;
} else {
summary.blockProposals.push({
blockRoot: toRootHex(config.getForkTypes(block.slot).BeaconBlock.hashTreeRoot(block)),
blockSlot: block.slot,
poolSubmitDelaySec: null,
successfullyImported: true,
});
}
}
},
onPoolSubmitUnaggregatedAttestation(seenTimestampSec, indexedAttestation, subnet, sentPeers) {
const data = indexedAttestation.data;
const fork = config.getForkName(data.slot);
// Returns the duration between when the attestation `data` could be produced (ATTESTATION_DUE_BPS through the slot) and `seenTimestamp`.
const delaySec =
seenTimestampSec -
(computeTimeAtSlot(config, data.slot, genesisTime) + config.getAttestationDueMs(fork) / 1000);
for (const index of indexedAttestation.attestingIndices) {
const validator = validators.get(index);
if (validator) {
validatorMonitorMetrics?.unaggregatedAttestationSubmittedSentPeers.observe(sentPeers);
validatorMonitorMetrics?.unaggregatedAttestationDelaySeconds.observe({src: OpSource.api}, delaySec);
log("Published unaggregated attestation", {
validator: index,
slot: data.slot,
committeeIndex: data.index,
subnet,
sentPeers,
delaySec: delaySec.toFixed(4),
});
const attestationSummary = validator.attestations
.getOrDefault(indexedAttestation.data.target.epoch)
.getOrDefault(toRootHex(indexedAttestation.data.target.root));
if (
attestationSummary.poolSubmitDelayMinSec === null ||
attestationSummary.poolSubmitDelayMinSec > delaySec
) {
attestationSummary.poolSubmitDelayMinSec = delaySec;
}
}
}
},
registerGossipUnaggregatedAttestation(seenTimestampSec, indexedAttestation) {
const src = OpSource.gossip;
const data = indexedAttestation.data;
const epoch = computeEpochAtSlot(data.slot);
const fork = config.getForkName(data.slot);
// Returns the duration between when the attestation `data` could be produced (ATTESTATION_DUE_BPS through the slot) and `seenTimestamp`.
const delaySec =
seenTimestampSec -
(computeTimeAtSlot(config, data.slot, genesisTime) + config.getAttestationDueMs(fork) / 1000);
for (const index of indexedAttestation.attestingIndices) {
const validator = validators.get(index);
if (validator) {
validatorMonitorMetrics?.unaggregatedAttestationTotal.inc({src});
validatorMonitorMetrics?.unaggregatedAttestationDelaySeconds.observe({src}, delaySec);
const summary = getEpochSummary(validator, epoch);
summary.attestations += 1;
summary.attestationMinDelay = Math.min(delaySec, summary.attestationMinDelay ?? Infinity);
}
}
},
onPoolSubmitAggregatedAttestation(seenTimestampSec, indexedAttestation, sentPeers) {
const data = indexedAttestation.data;
const fork = config.getForkName(data.slot);
// Returns the duration between when a `AggregateAndproof` with `data` could be produced (AGGREGATE_DUE_BPS through the slot) and `seenTimestamp`.
const delaySec =
seenTimestampSec - (computeTimeAtSlot(config, data.slot, genesisTime) + config.getAggregateDueMs(fork) / 1000);
for (const index of indexedAttestation.attestingIndices) {
const validator = validators.get(index);
if (validator) {
validatorMonitorMetrics?.aggregatedAttestationDelaySeconds.observe({src: OpSource.api}, delaySec);
log("Published aggregated attestation", {
validator: index,
slot: data.slot,
committeeIndex: data.index,
sentPeers,
delaySec: delaySec.toFixed(4),
});
validator.attestations
.getOrDefault(indexedAttestation.data.target.epoch)
.getOrDefault(toRootHex(indexedAttestation.data.target.root))
.aggregateInclusionDelaysSec.push(delaySec);
}
}
},
registerGossipAggregatedAttestation(seenTimestampSec, signedAggregateAndProof, indexedAttestation) {
const src = OpSource.gossip;
const data = indexedAttestation.data;
const epoch = computeEpochAtSlot(data.slot);
const fork = config.getForkName(data.slot);
// Returns the duration between when a `AggregateAndproof` with `data` could be produced (AGGREGATE_DUE_BPS through the slot) and `seenTimestamp`.
const delaySec =
seenTimestampSec - (computeTimeAtSlot(config, data.slot, genesisTime) + config.getAggregateDueMs(fork) / 1000);
const aggregatorIndex = signedAggregateAndProof.message.aggregatorIndex;
const validatorAggregator = validators.get(aggregatorIndex);
if (validatorAggregator) {
validatorMonitorMetrics?.aggregatedAttestationTotal.inc({src});
validatorMonitorMetrics?.aggregatedAttestationDelaySeconds.observe({src}, delaySec);
const summary = getEpochSummary(validatorAggregator, epoch);
summary.aggregates += 1;
summary.aggregateMinDelay = Math.min(delaySec, summary.aggregateMinDelay ?? Infinity);
}
for (const index of indexedAttestation.attestingIndices) {
const validator = validators.get(index);
if (validator) {
validatorMonitorMetrics?.attestationInAggregateTotal.inc({src});
validatorMonitorMetrics?.attestationInAggregateDelaySeconds.observe({src}, delaySec);
const summary = getEpochSummary(validator, epoch);
summary.attestationAggregateInclusions += 1;
log("Attestation is included in aggregate", {
validator: index,
slot: data.slot,
committeeIndex: data.index,
aggregatorIndex,
});
validator.attestations
.getOrDefault(indexedAttestation.data.target.epoch)
.getOrDefault(toRootHex(indexedAttestation.data.target.root))
.aggregateInclusionDelaysSec.push(delaySec);
}
}
},
// Register that the `indexed_attestation` was included in a *valid* `BeaconBlock`.
registerAttestationInBlock(
indexedAttestation,
parentSlot,
correctHead,
missedSlotVote,
inclusionBlockRoot,
inclusionBlockSlot
): void {
const data = indexedAttestation.data;
// optimal inclusion distance, not to count skipped slots between data.slot and blockSlot
const inclusionDistance = Math.max(parentSlot - data.slot, 0) + 1;
const delay = inclusionDistance - MIN_ATTESTATION_INCLUSION_DELAY;
const epoch = computeEpochAtSlot(data.slot);
const participants = indexedAttestation.attestingIndices.length;
for (const index of indexedAttestation.attestingIndices) {
const validator = validators.get(index);
if (validator) {
validatorMonitorMetrics?.attestationInBlockTotal.inc();
validatorMonitorMetrics?.attestationInBlockDelaySlots.observe(delay);
validatorMonitorMetrics?.attestationInBlockParticipants.observe(participants);
const summary = getEpochSummary(validator, epoch);
summary.attestationBlockInclusions += 1;
if (summary.attestationMinBlockInclusionDistance !== null) {
summary.attestationMinBlockInclusionDistance = Math.min(
summary.attestationMinBlockInclusionDistance,
inclusionDistance
);
} else {
summary.attestationMinBlockInclusionDistance = inclusionDistance;
}
summary.attestationCorrectHead = correctHead;
validator.attestations
.getOrDefault(indexedAttestation.data.target.epoch)
.getOrDefault(toRootHex(indexedAttestation.data.target.root))
.blockInclusions.push({
blockRoot: inclusionBlockRoot,
blockSlot: inclusionBlockSlot,
votedCorrectHeadRoot: correctHead,
votedForMissedSlot: missedSlotVote,
attestationSlot: indexedAttestation.data.slot,
});
log("Attestation is included in block", {
validator: index,
slot: data.slot,
committeeIndex: data.index,
inclusionDistance,
correctHead,
participants,
});
}
}
},
registerGossipSyncContributionAndProof(syncContributionAndProof, syncCommitteeParticipantIndices) {
const epoch = computeEpochAtSlot(syncContributionAndProof.contribution.slot);
for (const index of syncCommitteeParticipantIndices) {
const validator = validators.get(index);
if (validator) {
validatorMonitorMetrics?.syncSignatureInAggregateTotal.inc();
const summary = getEpochSummary(validator, epoch);
summary.syncSignatureAggregateInclusions += 1;
}
}
},
registerSyncAggregateInBlock(epoch, syncAggregate, syncCommitteeIndices) {
for (let i = 0; i < syncCommitteeIndices.length; i++) {
const validator = validators.get(syncCommitteeIndices[i]);
if (validator) {
const summary = getEpochSummary(validator, epoch);
if (syncAggregate.syncCommitteeBits.get(i)) {
summary.syncCommitteeHits++;
} else {
summary.syncCommitteeMisses++;
}
}
}
},
// Validator monitor tracks performance of validators in healthy network conditions.
// It does not attempt to track correctly duties on forking conditions deeper than 1 epoch.
// To guard against short re-orgs it will track the status of epoch N at the end of epoch N+1.
// This function **SHOULD** be called at the last slot of an epoch to have max possible information.
onceEveryEndOfEpoch(headState) {
if (headState.slot <= GENESIS_SLOT) {
// Before genesis, there won't be any validator activity
return;
}
// Prune validators not seen in a while
for (const [index, validator] of validators.entries()) {
if (Date.now() - validator.lastRegisteredTimeMs > retainRegisteredValidatorsMs) {
validators.delete(index);
removedValidatorsInEpoch.add(index);
}
}
// Log validator monitor status every epoch
const allIndices = Array.from(validators.keys()).sort((a, b) => a - b);
const addedIndices = Array.from(addedValidatorsInEpoch).sort((a, b) => a - b);
const removedIndices = Array.from(removedValidatorsInEpoch).sort((a, b) => a - b);
log("Validator monitor status", {
epoch: computeEpochAtSlot(headState.slot),
added: addedIndices.length > 0 ? prettyPrintIndices(addedIndices) : "none",
removed: removedIndices.length > 0 ? prettyPrintIndices(removedIndices) : "none",
total: validators.size,
indices: prettyPrintIndices(allIndices),
});
// Clear tracking sets for next epoch
addedValidatorsInEpoch.clear();
removedValidatorsInEpoch.clear();
// Compute summaries of previous epoch attestation performance
const prevEpoch = computeEpochAtSlot(headState.slot) - 1;
// During the end of first epoch, the prev epoch with be -1
// Skip this as there is no attestation and block proposal summary in epoch -1
if (prevEpoch === -1) {
return;
}
if (validators.size === 0) {
return;
}
const rootCache = new RootHexCache(headState);
if (isStatePostAltair(headState)) {
const prevEpochStartSlot = computeStartSlotAtEpoch(prevEpoch);
const prevEpochTargetRoot = toRootHex(headState.getBlockRootAtSlot(prevEpochStartSlot));
// Check attestation performance
for (const [index, validator] of validators.entries()) {
const flags = parseParticipationFlags(headState.getPreviousEpochParticipation(index));
const attestationSummary = validator.attestations.get(prevEpoch)?.get(prevEpochTargetRoot);
const summary = renderAttestationSummary(config, rootCache, attestationSummary, flags);
validatorMonitorMetrics?.prevEpochAttestationSummary.inc({summary});
log("Previous epoch attestation", {
validator: index,
epoch: prevEpoch,
summary,
});
}
}
if (headState.previousProposers !== null) {
// proposersPrevEpoch is null on the first epoch of `headState` being generated
for (const [slotIndex, validatorIndex] of headState.previousProposers.entries()) {
const validator = validators.get(validatorIndex);
if (validator) {
// If expected proposer is a tracked validator
const epochSummary = validator.summaries.get(prevEpoch);
const proposalSlot = SLOTS_PER_EPOCH * prevEpoch + slotIndex;
const summary = renderBlockProposalSummary(config, rootCache, epochSummary, proposalSlot);
validatorMonitorMetrics?.prevEpochBlockProposalSummary.inc({summary});
log("Previous epoch block proposal", {
validator: validatorIndex,
slot: proposalSlot,
epoch: prevEpoch,
summary,
});
}
}
}
},
/**
* Scrape `self` for metrics.
* Should be called whenever Prometheus is scraping.
*/
scrapeMetrics(slotClock) {
validatorMonitorMetrics?.validatorsConnected.set(validators.size);
// Update static metric with connected validator indices
if (validatorMonitorMetrics?.validatorsConnectedIndices) {
validatorMonitorMetrics.validatorsConnectedIndices.reset();
const allIndices = Array.from(validators.keys()).sort((a, b) => a - b);
validatorMonitorMetrics.validatorsConnectedIndices.set({indices: prettyPrintIndices(allIndices)}, 1);
}
const epoch = computeEpochAtSlot(slotClock);
const slotInEpoch = slotClock % SLOTS_PER_EPOCH;
// Only start to report on the current epoch once we've progressed past the point where
// all attestation should be included in a block.
//
// This allows us to set alarms on Grafana to detect when an attestation has been
// missed. If we didn't delay beyond the attestation inclusion period then we could
// expect some occasional false-positives on attestation misses.
//
// I have chosen 3 as an arbitrary number where we *probably* shouldn't see that many
// skip slots on mainnet.
const previousEpoch = slotInEpoch > MIN_ATTESTATION_INCLUSION_DELAY + 3 ? epoch - 1 : epoch - 2;
// reset() to mimic the behaviour of an aggregated .set({index})
validatorMonitorMetrics?.prevEpochAttestations.reset();
validatorMonitorMetrics?.prevEpochAttestationsMinDelaySeconds.reset();
validatorMonitorMetrics?.prevEpochAttestationAggregateInclusions.reset();
validatorMonitorMetrics?.prevEpochAttestationBlockInclusions.reset();
validatorMonitorMetrics?.prevEpochAttestationBlockMinInclusionDistance.reset();
validatorMonitorMetrics?.prevEpochSyncSignatureAggregateInclusions.reset();
let validatorsInSyncCommittee = 0;
let prevEpochSyncCommitteeHits = 0;
let prevEpochSyncCommitteeMisses = 0;
for (const validator of validators.values()) {
// Participation in sync committee
const validatorInSyncCommittee = validator.inSyncCommitteeUntilEpoch >= epoch;
if (validatorInSyncCommittee) {
validatorsInSyncCommittee++;
}
// Prev-epoch summary
const summary = validator.summaries.get(previousEpoch);
if (!summary) {
continue;
}
// Attestations
validatorMonitorMetrics?.prevEpochAttestations.observe(summary.attestations);
if (summary.attestationMinDelay !== null)
validatorMonitorMetrics?.prevEpochAttestationsMinDelaySeconds.observe(summary.attestationMinDelay);
validatorMonitorMetrics?.prevEpochAttestationAggregateInclusions.observe(
summary.attestationAggregateInclusions
);
validatorMonitorMetrics?.prevEpochAttestationBlockInclusions.observe(summary.attestationBlockInclusions);
if (summary.attestationMinBlockInclusionDistance !== null) {
validatorMonitorMetrics?.prevEpochAttestationBlockMinInclusionDistance.observe(
summary.attestationMinBlockInclusionDistance
);
}
// Blocks
validatorMonitorMetrics?.prevEpochBeaconBlocks.observe(summary.blocks);
if (summary.blockMinDelay !== null)
validatorMonitorMetrics?.prevEpochBeaconBlocksMinDelaySeconds.observe(summary.blockMinDelay);
// Aggregates
validatorMonitorMetrics?.prevEpochAggregatesTotal.observe(summary.aggregates);
if (summary.aggregateMinDelay !== null)
validatorMonitorMetrics?.prevEpochAggregatesMinDelaySeconds.observe(summary.aggregateMinDelay);
// Sync committee
prevEpochSyncCommitteeHits += summary.syncCommitteeHits;
prevEpochSyncCommitteeMisses += summary.syncCommitteeMisses;
// Only observe if included in sync committee to prevent distorting metrics
if (validatorInSyncCommittee) {
validatorMonitorMetrics?.prevEpochSyncSignatureAggregateInclusions.observe(
summary.syncSignatureAggregateInclusions
);
}
}
validatorMonitorMetrics?.validatorsInSyncCommittee.set(validatorsInSyncCommittee);
validatorMonitorMetrics?.prevEpochSyncCommitteeHits.set(prevEpochSyncCommitteeHits);
validatorMonitorMetrics?.prevEpochSyncCommitteeMisses.set(prevEpochSyncCommitteeMisses);
},
getMonitoredValidatorIndices() {
return Array.from(validators.keys()).sort((a, b) => a - b);
},
};
// Register a single collect() function to run all validatorMonitor metrics
validatorMonitorMetrics?.validatorsConnected.addCollect(() => {
const clockSlot = getCurrentSlot(config, genesisTime);
validatorMonitor.scrapeMetrics(clockSlot);
});
return validatorMonitor;
}
/**
* Best guess to automatically debug why validators do not achieve expected rewards.
* Tries to answer common questions such as:
* - Did the validator submit the attestation to this block?
* - Was the attestation seen in an aggregate?
* - Was the attestation seen in a block?
*/
function renderAttestationSummary(
config: ChainForkConfig,
rootCache: RootHexCache,
summary: AttestationSummary | undefined,
flags: ParticipationFlags
): string {
// Reference https://github.com/ethereum/consensus-specs/blob/v1.6.1/specs/altair/beacon-chain.md#get_attestation_participation_flag_indices
//
// is_matching_source = data.source == justified_checkpoint
// is_matching_target = is_matching_source and data.target.root == get_block_root(state, data.target.epoch)
// is_matching_head = is_matching_target and data.beacon_block_root == get_block_root_at_slot(state, data.slot)
//
// is_matching_source MUST be true for the attestation to be included in a block
//
// timely_source = is_matching_source and inclusion_delay <= integer_squareroot(SLOTS_PER_EPOCH):
// timely_target = is_matching_target and inclusion_delay <= SLOTS_PER_EPOCH:
// timely_head = is_matching_head and inclusion_delay == MIN_ATTESTATION_INCLUSION_DELAY:
if (flags.timelyHead) {
// NOTE: If timelyHead everything else MUST be true also
return "timely_head";
}
//
if (flags.timelyTarget) {
// timelyHead == false, means at least one is true
// - attestation voted incorrect head
// - attestation was included late
// Note: the same attestation can be included in multiple blocks. For example, block with parent A at slot N can
// include the attestation. Then block as slot N+1 re-orgs slot N setting as parent A and includes the attestations
// from block at slot N.
//
// TODO: Track block inclusions, and then check which ones are canonical
if (!summary) {
// In normal conditions should never happen, validator is expected to submit an attestation to the tracking node.
// If the validator is using multiple beacon nodes as fallback, this condition may be triggered.
return "unexpected_timely_target_without_summary";
}
const canonicalBlockInclusion = summary.blockInclusions.find((block) => isCanonical(rootCache, block));
if (!canonicalBlockInclusion) {
// Should never happen, because for a state to exist that registers a validator's participation this specific
// beacon node must have imported a block with the attestation that caused the change in participation.
return "unexpected_timely_target_without_canonical_inclusion";
}
const {votedCorrectHeadRoot, blockSlot, attestationSlot} = canonicalBlockInclusion;
const inclusionDistance = Math.max(blockSlot - attestationSlot - MIN_ATTESTATION_INCLUSION_DELAY, 0);
if (votedCorrectHeadRoot && inclusionDistance === 0) {
// Should never happen, in this case timelyHead must be true
return "unexpected_timely_head_as_timely_target";
}
// Why is the distance > 0?
// - Block that should have included the attestation was missed
// - Attestation was not included in any aggregate
// - Attestation was sent late
// Why is the head vote wrong?
// - We processed a block late and voted for the parent
// - We voted for a block that latter was missed
// - We voted for a block that was re-org for another chain
let out = "timely_target";
if (!votedCorrectHeadRoot) {
out += "_" + whyIsHeadVoteWrong(rootCache, canonicalBlockInclusion);
}
if (inclusionDistance > 0) {
out += "_" + whyIsDistanceNotOk(rootCache, canonicalBlockInclusion, summary);
}
return out;
}
//
if (flags.timelySource) {
// timelyTarget == false && timelySource == true means that
// - attestation voted the wrong target but distance is <= integer_squareroot(SLOTS_PER_EPOCH)
return "wrong_target_timely_source";
}
//
// timelySource == false, either:
// - attestation was not included in the block
// - included in block with wrong target (very unlikely)
// - included in block with distance > SLOTS_PER_EPOCH (very unlikely)
// Validator failed to submit an attestation for this epoch, validator client is probably offline
if (!summary || summary.poolSubmitDelayMinSec === null) {
return "no_submission";
}
const canonicalBlockInclusion = summary.blockInclusions.find((block) => isCanonical(rootCache, block));
if (canonicalBlockInclusion) {
// Canonical block inclusion with no participation flags set means wrong target + late source
return "wrong_target_late_source";
}
const submittedLate =
summary.poolSubmitDelayMinSec > config.getSlotComponentDurationMs(LATE_ATTESTATION_SUBMISSION_BPS) / 1000;
const aggregateInclusion = summary.aggregateInclusionDelaysSec.length > 0;
if (submittedLate && aggregateInclusion) {
return "late_submit";
}
if (submittedLate && !aggregateInclusion) {
return "late_submit_no_aggregate_inclusion";
}
if (!submittedLate && aggregateInclusion) {
// TODO: Why was it missed then?
if (summary.blockInclusions.length) {
return "block_inclusion_but_orphan";
}
return "aggregate_inclusion_but_missed";
// } else if (!submittedLate && !aggregateInclusion) {
}
// Did the node had enough peers?
if (summary.poolSubmitSentPeers === 0) {
return "sent_to_zero_peers";
}
return "no_aggregate_inclusion";
}
function whyIsHeadVoteWrong(rootCache: RootHexCache, canonicalBlockInclusion: AttestationBlockInclusion): string {
const {votedForMissedSlot, attestationSlot} = canonicalBlockInclusion;
const canonicalAttestationSlotMissed = isMissedSlot(rootCache, attestationSlot);
// __A_______C
// \_B1
// ^^ attestation slot
//
// We vote for B1, but the next proposer skips our voted block.
// This scenario happens sometimes when blocks are published late
// __A____________E
// \_B1__C__D
// ^^ attestation slot
//
// We vote for B1, and due to some issue a longer reorg happens orphaning our vote.
// This scenario is considered in the above
if (!votedForMissedSlot && canonicalAttestationSlotMissed) {
// TODO: Did the block arrive late?
return "vote_orphaned";
}
// __A__B1___C
// \_(A)
// ^^ attestation slot
//
// We vote for A assuming skip block, next proposer's view differs
// This scenario happens sometimes when blocks are published late
if (votedForMissedSlot && !canonicalAttestationSlotMissed) {
// TODO: Did the block arrive late?
return "wrong_skip_vote";
}
// __A__B2___C
// \_B1
// ^^ attestation slot
//
// We vote for B1, but the next proposer continues the chain on a competing block
// This scenario is unlikely to happen in short re-orgs given no slashings, won't consider.
//
// __A____B_______C
// \ \_(B)
// \_(A)_(A)
//
// Vote for different heads on skipped slot
return "wrong_head_vote";
}
function whyIsDistanceNotOk(
rootCache: RootHexCache,
canonicalBlockInclusion: AttestationBlockInclusion,
summary: AttestationSummary
): string {
// If the attestation is not included in any aggregate it's likely because it was sent late.
if (summary.aggregateInclusionDelaysSec.length === 0) {
return "no_aggregate_inclusion";
}
// If the next slot of an attestation is missed, distance will be > 0 even if everything else was timely
if (isMissedSlot(rootCache, canonicalBlockInclusion.attestationSlot + 1)) {
return "next_slot_missed";
}
//
return "late_unknown";
}
/** Returns true if the state's root record includes `block` */
function isCanonical(rootCache: RootHexCache, block: AttestationBlockInclusion): boolean {
return rootCache.getBlockRootAtSlot(block.blockSlot) === block.blockRoot;
}
/** Returns true if root at slot is the same at slot - 1 == there was no new block at slot */
function isMissedSlot(rootCache: RootHexCache, slot: Slot): boolean {
return slot > 0 && rootCache.getBlockRootAtSlot(slot) === rootCache.getBlockRootAtSlot(slot - 1);
}
function renderBlockProposalSummary(
config: ChainForkConfig,
rootCache: RootHexCache,
summary: EpochSummary | undefined,
proposalSlot: Slot
): string {
const proposal = summary?.blockProposals.find((proposal) => proposal.blockSlot === proposalSlot);
if (!proposal) {
return "not_submitted";
}
if (rootCache.getBlockRootAtSlot(proposalSlot) === proposal.blockRoot) {
// Canonical state includes our block
return "canonical";
}
let out = "orphaned";
if (isMissedSlot(rootCache, proposalSlot)) {
out += "_missed";
}
if (
proposal.poolSubmitDelaySec !== null &&
proposal.poolSubmitDelaySec > config.getSlotComponentDurationMs(LATE_BLOCK_SUBMISSION_BPS) / 1000
) {
out += "_late";
}
return out;
}
/**
* Cache to prevent accessing the state tree to fetch block roots repeteadly.
* In normal network conditions the same root is read multiple times, specially the target.
*/
export class RootHexCache {
private readonly blockRootSlotCache = new Map<Slot, RootHex>();
constructor(private readonly state: IBeaconStateView) {}
getBlockRootAtSlot(slot: Slot): RootHex {
let root = this.blockRootSlotCache.get(slot);
if (!root) {
root = toRootHex(this.state.getBlockRootAtSlot(slot));
this.blockRootSlotCache.set(slot, root);
}
return root;
}
}
function createValidatorMonitorMetrics(register: RegistryMetricCreator) {
return {
validatorsConnected: register.gauge({
name: "validator_monitor_validators",
help: "Count of validators that are specifically monitored by this beacon node",
}),
validatorsConnectedIndices: register.gauge<{indices: string}>({
name: "validator_monitor_indices",
help: "Static metric with connected validator indices as label, value is always 1",
labelNames: ["indices"],
}),
validatorsInSyncCommittee: register.gauge({
name: "validator_monitor_validators_in_sync_committee",
help: "Count of validators monitored by this beacon node that are part of sync committee",
}),
// Validator Monitor Metrics (per-epoch summaries)
prevEpochOnChainBalance: register.gauge({
name: "validator_monitor_prev_epoch_on_chain_balance",
help: "Total balance of all monitored validators after an epoch",
}),
prevEpochOnChainAttesterHit: register.gauge({
name: "validator_monitor_prev_epoch_on_chain_attester_hit_total",
help: "Incremented if validator's submitted attestation is included in some blocks",
}),
prevEpochOnChainAttesterMiss: register.gauge({
name: "validator_monitor_prev_epoch_on_chain_attester_miss_total",
help: "Incremented if validator's submitted attestation is not included in any blocks",
}),
prevEpochOnChainSourceAttesterHit: register.gauge({
name: "validator_monitor_prev_epoch_on_chain_source_attester_hit_total",
help: "Incremented if the validator is flagged as a previous epoch source attester during per epoch processing",
}),
prevEpochOnChainSourceAttesterMiss: register.gauge({
name: "validator_monitor_prev_epoch_on_chain_source_attester_miss_total",
help: "Incremented if the validator is not flagged as a previous epoch source attester during per epoch processing",
}),
prevEpochOnChainHeadAttesterHit: register.gauge({
name: "validator_monitor_prev_epoch_on_chain_head_attester_hit_total",
help: "Incremented if the validator is flagged as a previous epoch head attester during per epoch processing",
}),
prevEpochOnChainHeadAttesterMiss: register.gauge({
name: "validator_monitor_prev_epoch_on_chain_head_attester_miss_total",
help: "Incremented if the validator is not flagged as a previous epoch head attester during per epoch processing",
}),
prevOnChainAttesterCorrectHead: register.gauge({
name: "validator_monitor_prev_epoch_on_chain_attester_correct_head_total",
help: "Total count of times a validator votes correct head",
}),
prevOnChainAttesterIncorrectHead: register.gauge({
name: "validator_monitor_prev_epoch_on_chain_attester_incorrect_head_total",
help: "Total count of times a validator votes incorrect head",
}),
prevEpochOnChainTargetAttesterHit: register.gauge({
name: "validator_monitor_prev_epoch_on_chain_target_attester_hit_total",
help: "Incremented if the validator is flagged as a previous epoch target attester during per epoch processing",
}),
prevEpochOnChainTargetAttesterMiss: register.gauge({
name: "validator_monitor_prev_epoch_on_chain_target_attester_miss_total",
help: "Incremented if the validator is not flagged as a previous epoch target attester during per epoch processing",
}),
prevEpochOnChainInclusionDistance: register.histogram({
name: "validator_monitor_prev_epoch_on_chain_inclusion_distance",
help: "The attestation inclusion distance calculated during per epoch processing",
// min inclusion distance is 1, usual values are 1,2,3 max is 32 (1 epoch)
buckets: [1, 2, 3, 5, 10, 32],
}),
prevEpochAttestations: register.histogram({
name: "validator_monitor_prev_epoch_attestations",
help: "The number of unagg. attestations seen in the previous epoch",
buckets: [0, 1, 2, 3],
}),
prevEpochAttestationsMinDelaySeconds: register.histogram({
name: "validator_monitor_prev_epoch_attestations_min_delay_seconds",
help: "The min delay between when the validator should send the attestation and when it was received",
buckets: [0.1, 0.25, 0.5, 1, 2, 5, 10],
}),
prevEpochAttestationAggregateInclusions: register.histogram({
name: "validator_monitor_prev_epoch_attestation_aggregate_inclusions",
help: "The count of times an attestation was seen inside an aggregate",
buckets: [0, 1, 2, 3, 5, 10],
}),
prevEpochAttestationBlockInclusions: register.histogram({
name: "validator_monitor_prev_epoch_