@lodestar/beacon-node
Version:
A Typescript implementation of the beacon chain
283 lines (249 loc) • 10.8 kB
text/typescript
import {Signature, aggregateSignatures} from "@chainsafe/blst";
import {BitArray} from "@chainsafe/ssz";
import {ChainForkConfig} from "@lodestar/config";
import {MAX_COMMITTEES_PER_SLOT, isForkPostElectra} from "@lodestar/params";
import {Attestation, RootHex, SingleAttestation, Slot, isElectraSingleAttestation} from "@lodestar/types";
import {MapDef, assert} from "@lodestar/utils";
import {Metrics} from "../../metrics/metrics.js";
import {IClock} from "../../util/clock.js";
import {InsertOutcome, OpPoolError, OpPoolErrorCode} from "./types.js";
import {isElectraAggregate, pruneBySlot, signatureFromBytesNoCheck} from "./utils.js";
/**
* The number of slots that will be stored in the pool.
*
* For example, if `SLOTS_RETAINED == 3` and the pool is pruned at slot `6`, then all attestations
* at slots less than `4` will be dropped and any future attestation with a slot less than `4`
* will be refused.
*/
const SLOTS_RETAINED = 3;
/**
* The maximum number of distinct `AttestationData` that will be stored in each slot.
*
* This is a DoS protection measure.
*/
const MAX_ATTESTATIONS_PER_SLOT = 16_384;
type AggregateFastPhase0 = {
data: Attestation["data"];
aggregationBits: BitArray;
signature: Signature;
};
export type AggregateFastElectra = AggregateFastPhase0 & {committeeBits: BitArray};
export type AggregateFast = AggregateFastPhase0 | AggregateFastElectra;
/** Hex string of DataRoot `TODO` */
type DataRootHex = string;
/** CommitteeIndex must be null for pre-electra. Must not be null post-electra */
type CommitteeIndex = number | null;
/**
* A pool of `Attestation` that is specially designed to store "unaggregated" attestations from
* the native aggregation scheme.
*
* **The `NaiveAggregationPool` does not do any signature or attestation verification. It assumes
* that all `Attestation` objects provided are valid.**
*
* ## Details
*
* The pool sorts the `Attestation` by `attestation.data.slot`, then by `attestation.data`.
*
* As each unaggregated attestation is added it is aggregated with any existing `attestation` with
* the same `AttestationData`. Considering that the pool only accepts attestations with a single
* signature, there should only ever be a single aggregated `Attestation` for any given
* `AttestationData`.
*
* The pool has a capacity for `SLOTS_RETAINED` slots, when a new `attestation.data.slot` is
* provided, the oldest slot is dropped and replaced with the new slot. The pool can also be
* pruned by supplying a `current_slot`; all existing attestations with a slot lower than
* `current_slot - SLOTS_RETAINED` will be removed and any future attestation with a slot lower
* than that will also be refused. Pruning is done automatically based upon the attestations it
* receives and it can be triggered manually.
*/
export class AttestationPool {
private readonly aggregateByIndexByRootBySlot = new MapDef<
Slot,
Map<DataRootHex, Map<CommitteeIndex, AggregateFast>>
>(() => new Map<DataRootHex, Map<CommitteeIndex, AggregateFast>>());
private lowestPermissibleSlot = 0;
constructor(
private readonly config: ChainForkConfig,
private readonly clock: IClock,
private readonly preaggregateSlotDistance = 0,
private readonly metrics: Metrics | null = null
) {}
/** Returns current count of pre-aggregated attestations with unique data */
getAttestationCount(): number {
let attestationCount = 0;
for (const attestationByIndexByRoot of this.aggregateByIndexByRootBySlot.values()) {
for (const attestationByIndex of attestationByIndexByRoot.values()) {
attestationCount += attestationByIndex.size;
}
}
return attestationCount;
}
/**
* Accepts an `VerifiedUnaggregatedAttestation` and attempts to apply it to the "naive
* aggregation pool".
*
* The naive aggregation pool is used by local validators to produce
* `SignedAggregateAndProof`.
*
* If the attestation is too old (low slot) to be included in the pool it is simply dropped
* and no error is returned. Also if it's at clock slot but come to the pool later than AGGREGATE_DUE_BPS
* of slot time, it's dropped too since it's not helpful for the validator anymore
*
* Expects the attestation to be fully validated:
* - Valid signature
* - Consistent bitlength
* - Valid committeeIndex
* - Valid data
*/
add(
committeeIndex: CommitteeIndex,
attestation: SingleAttestation,
attDataRootHex: RootHex,
validatorCommitteeIndex: number,
committeeSize: number,
priority?: boolean
): InsertOutcome {
const slot = attestation.data.slot;
const fork = this.config.getForkName(slot);
const lowestPermissibleSlot = this.lowestPermissibleSlot;
// Reject any attestations that are too old.
if (slot < lowestPermissibleSlot) {
return InsertOutcome.Old;
}
// Reject gossip attestations in the current slot but come to this pool very late
// for api attestations, we allow them to be added to the pool
if (!priority && this.clock.msFromSlot(slot) > this.config.getAggregateDueMs(fork)) {
return InsertOutcome.Late;
}
// Limit object per slot
const aggregateByRoot = this.aggregateByIndexByRootBySlot.getOrDefault(slot);
if (aggregateByRoot.size >= MAX_ATTESTATIONS_PER_SLOT) {
throw new OpPoolError({code: OpPoolErrorCode.REACHED_MAX_PER_SLOT});
}
if (isForkPostElectra(fork)) {
// Electra only: this should not happen because attestation should be validated before reaching this
assert.notNull(committeeIndex, "Committee index should not be null in attestation pool post-electra");
assert.true(isElectraSingleAttestation(attestation), "Attestation should be type electra.SingleAttestation");
} else {
assert.true(!isElectraSingleAttestation(attestation), "Attestation should be type phase0.Attestation");
committeeIndex = null; // For pre-electra, committee index info is encoded in attDataRootIndex
}
// Pre-aggregate the contribution with existing items
let aggregateByIndex = aggregateByRoot.get(attDataRootHex);
if (aggregateByIndex === undefined) {
aggregateByIndex = new Map<CommitteeIndex, AggregateFast>();
aggregateByRoot.set(attDataRootHex, aggregateByIndex);
}
const aggregate = aggregateByIndex.get(committeeIndex);
if (aggregate) {
// Aggregate mutating
return aggregateAttestationInto(aggregate, attestation, validatorCommitteeIndex);
}
// Create new aggregate
aggregateByIndex.set(committeeIndex, attestationToAggregate(attestation, validatorCommitteeIndex, committeeSize));
return InsertOutcome.NewData;
}
/**
* For validator API to get an aggregate
*/
getAggregate(slot: Slot, dataRootHex: RootHex, committeeIndex: CommitteeIndex): Attestation | null {
const fork = this.config.getForkName(slot);
const isPostElectra = isForkPostElectra(fork);
committeeIndex = isPostElectra ? committeeIndex : null;
const aggregate = this.aggregateByIndexByRootBySlot.get(slot)?.get(dataRootHex)?.get(committeeIndex);
if (!aggregate) {
this.metrics?.opPool.attestationPool.getAggregateCacheMisses.inc();
return null;
}
if (isPostElectra) {
assert.true(isElectraAggregate(aggregate), "Aggregate should be type AggregateFastElectra");
} else {
assert.true(!isElectraAggregate(aggregate), "Aggregate should be type AggregateFastPhase0");
}
return fastToAttestation(aggregate);
}
/**
* Removes any attestations with a slot lower than `current_slot - preaggregateSlotDistance`.
* By default, not interested in attestations in old slots, we only preaggregate attestations for the current slot.
*/
prune(clockSlot: Slot): void {
pruneBySlot(this.aggregateByIndexByRootBySlot, clockSlot, SLOTS_RETAINED);
// by default preaggregateSlotDistance is 0, i.e only accept attestations in the same clock slot.
this.lowestPermissibleSlot = Math.max(clockSlot - this.preaggregateSlotDistance, 0);
}
/**
* Get all attestations optionally filtered by `attestation.data.slot`
* @param bySlot slot to filter, `bySlot === attestation.data.slot`
*/
getAll(bySlot?: Slot): Attestation[] {
const attestations: Attestation[] = [];
const aggregateByRoots =
bySlot === undefined
? Array.from(this.aggregateByIndexByRootBySlot.values())
: [this.aggregateByIndexByRootBySlot.get(bySlot)];
for (const aggregateByRoot of aggregateByRoots) {
if (aggregateByRoot) {
for (const aggFastByIndex of aggregateByRoot.values()) {
for (const aggFast of aggFastByIndex.values()) {
attestations.push(fastToAttestation(aggFast));
}
}
}
}
return attestations;
}
}
// - Retrieve agg attestations by slot and data root
// - Insert attestations coming from gossip and API
/**
* Aggregate a new attestation into `aggregate` mutating it
*/
function aggregateAttestationInto(
aggregate: AggregateFast,
attestation: SingleAttestation,
validatorCommitteeIndex: number
): InsertOutcome {
let bitIndex: number | null;
if (isElectraSingleAttestation(attestation)) {
bitIndex = validatorCommitteeIndex;
} else {
bitIndex = attestation.aggregationBits.getSingleTrueBit();
}
// Should never happen, attestations are verified against this exact condition before
assert.notNull(bitIndex, "Invalid attestation in pool, not exactly one bit set");
if (aggregate.aggregationBits.get(bitIndex) === true) {
return InsertOutcome.AlreadyKnown;
}
aggregate.aggregationBits.set(bitIndex, true);
aggregate.signature = aggregateSignatures([aggregate.signature, signatureFromBytesNoCheck(attestation.signature)]);
return InsertOutcome.Aggregated;
}
/**
* Format `contribution` into an efficient `aggregate` to add more contributions in with aggregateContributionInto()
*/
function attestationToAggregate(
attestation: SingleAttestation,
validatorCommitteeIndex: number,
committeeSize: number
): AggregateFast {
if (isElectraSingleAttestation(attestation)) {
return {
data: attestation.data,
aggregationBits: BitArray.fromSingleBit(committeeSize, validatorCommitteeIndex),
committeeBits: BitArray.fromSingleBit(MAX_COMMITTEES_PER_SLOT, attestation.committeeIndex),
signature: signatureFromBytesNoCheck(attestation.signature),
};
}
return {
data: attestation.data,
// clone because it will be mutated
aggregationBits: attestation.aggregationBits.clone(),
signature: signatureFromBytesNoCheck(attestation.signature),
};
}
/**
* Unwrap AggregateFast to Attestation
*/
function fastToAttestation(aggFast: AggregateFast): Attestation {
return {...aggFast, signature: aggFast.signature.toBytes()};
}