@lodestar/beacon-node
Version:
A Typescript implementation of the beacon chain
217 lines (191 loc) • 7.85 kB
text/typescript
import EventEmitter from "node:events";
import type {StrictEventEmitter} from "strict-event-emitter-types";
import {ChainForkConfig} from "@lodestar/config";
import {computeEpochAtSlot, computeTimeAtSlot, getCurrentSlot} from "@lodestar/state-transition";
import type {Epoch, Slot} from "@lodestar/types";
import {ErrorAborted} from "@lodestar/utils";
export enum ClockEvent {
/**
* This event signals the start of a new slot, and that subsequent calls to `clock.currentSlot` will equal `slot`.
* This event is guaranteed to be emitted every `SLOT_DURATION_MS` milliseconds.
*/
slot = "clock:slot",
/**
* This event signals the start of a new epoch, and that subsequent calls to `clock.currentEpoch` will return `epoch`.
* This event is guaranteed to be emitted every `SLOT_DURATION_MS * SLOTS_PER_EPOCH` milliseconds.
*/
epoch = "clock:epoch",
}
export type ClockEvents = {
[ClockEvent.slot]: (slot: Slot) => void;
[ClockEvent.epoch]: (epoch: Epoch) => void;
};
/**
* Tracks the current chain time, measured in `Slot`s and `Epoch`s
*
* The time is dependent on:
* - `state.genesisTime` - the genesis time
* - `SLOT_DURATION_MS` - # of milliseconds per slot
* - `SLOTS_PER_EPOCH` - # of slots per epoch
*/
export type IClock = StrictEventEmitter<EventEmitter, ClockEvents> & {
readonly genesisTime: Slot;
readonly currentSlot: Slot;
/**
* If it's too close to next slot, maxCurrentSlot = currentSlot + 1
*/
readonly currentSlotWithGossipDisparity: Slot;
readonly currentEpoch: Epoch;
/** Returns the slot if the internal clock were advanced by `toleranceSec`. */
slotWithFutureTolerance(toleranceSec: number): Slot;
/** Returns the slot if the internal clock were reversed by `toleranceSec`. */
slotWithPastTolerance(toleranceSec: number): Slot;
/**
* Check if a slot is current slot given MAXIMUM_GOSSIP_CLOCK_DISPARITY.
*/
isCurrentSlotGivenGossipDisparity(slot: Slot): boolean;
/**
* Returns a promise that waits until at least `slot` is reached
* Resolves when the current slot >= `slot`
* Rejects if the clock is aborted
*/
waitForSlot(slot: Slot): Promise<void>;
/**
* Return second from a slot to either toSec or now.
*/
secFromSlot(slot: Slot, toSec?: number): number;
/**
* Return milliseconds from a slot to either toMs or now.
*/
msFromSlot(slot: Slot, toMs?: number): number;
};
/**
* A local clock, the clock time is assumed to be trusted
*/
export class Clock extends EventEmitter implements IClock {
readonly genesisTime: number;
private readonly config: ChainForkConfig;
private timeoutId: number | NodeJS.Timeout;
private readonly signal: AbortSignal;
private _currentSlot: number;
constructor({config, genesisTime, signal}: {config: ChainForkConfig; genesisTime: number; signal: AbortSignal}) {
super();
this.config = config;
this.genesisTime = genesisTime;
this.timeoutId = setTimeout(this.onNextSlot, this.msUntilNextSlot());
this.signal = signal;
this._currentSlot = getCurrentSlot(this.config, this.genesisTime);
this.signal.addEventListener("abort", () => clearTimeout(this.timeoutId), {once: true});
}
get currentSlot(): Slot {
const slot = getCurrentSlot(this.config, this.genesisTime);
if (slot > this._currentSlot) {
clearTimeout(this.timeoutId);
this.onNextSlot(slot);
}
return slot;
}
/**
* If it's too close to next slot given MAXIMUM_GOSSIP_CLOCK_DISPARITY, return currentSlot + 1.
* Otherwise return currentSlot
*
* Spec: phase0/p2p-interface.md - gossip validation uses `current_time + MAXIMUM_GOSSIP_CLOCK_DISPARITY < message_time`
* to reject future messages (strict `<`), so the boundary (exactly equal) is accepted, hence `<=` here.
*/
get currentSlotWithGossipDisparity(): Slot {
const currentSlot = this.currentSlot;
const nextSlotTime = computeTimeAtSlot(this.config, currentSlot + 1, this.genesisTime) * 1000;
return nextSlotTime - Date.now() <= this.config.MAXIMUM_GOSSIP_CLOCK_DISPARITY ? currentSlot + 1 : currentSlot;
}
get currentEpoch(): Epoch {
return computeEpochAtSlot(this.currentSlot);
}
/** Returns the slot if the internal clock were advanced by `toleranceSec`. */
slotWithFutureTolerance(toleranceSec: number): Slot {
// this is the same to getting slot at now + toleranceSec
return getCurrentSlot(this.config, this.genesisTime - toleranceSec);
}
/** Returns the slot if the internal clock were reversed by `toleranceSec`. */
slotWithPastTolerance(toleranceSec: number): Slot {
// this is the same to getting slot at now - toleranceSec
return getCurrentSlot(this.config, this.genesisTime + toleranceSec);
}
/**
* Check if a slot is current slot given MAXIMUM_GOSSIP_CLOCK_DISPARITY.
*
* Uses `<=` for disparity checks because the spec rejects with strict `<`
* (phase0/p2p-interface.md), meaning the boundary (exactly equal) is accepted.
*/
isCurrentSlotGivenGossipDisparity(slot: Slot): boolean {
const currentSlot = this.currentSlot;
if (currentSlot === slot) {
return true;
}
const nextSlotTime = computeTimeAtSlot(this.config, currentSlot + 1, this.genesisTime) * 1000;
// we're too close to next slot, accept next slot
if (nextSlotTime - Date.now() <= this.config.MAXIMUM_GOSSIP_CLOCK_DISPARITY) {
return slot === currentSlot + 1;
}
const currentSlotTime = computeTimeAtSlot(this.config, currentSlot, this.genesisTime) * 1000;
// we've just passed the current slot, accept previous slot
if (Date.now() - currentSlotTime <= this.config.MAXIMUM_GOSSIP_CLOCK_DISPARITY) {
return slot === currentSlot - 1;
}
return false;
}
async waitForSlot(slot: Slot): Promise<void> {
if (this.signal.aborted) {
throw new ErrorAborted();
}
if (this.currentSlot >= slot) {
return;
}
return new Promise((resolve, reject) => {
const onSlot = (clockSlot: Slot): void => {
if (clockSlot >= slot) {
onDone();
}
};
const onDone = (): void => {
this.off(ClockEvent.slot, onSlot);
this.signal.removeEventListener("abort", onAbort);
resolve();
};
const onAbort = (): void => {
this.off(ClockEvent.slot, onSlot);
reject(new ErrorAborted());
};
this.on(ClockEvent.slot, onSlot);
this.signal.addEventListener("abort", onAbort, {once: true});
});
}
secFromSlot(slot: Slot, toSec = Date.now() / 1000): number {
return toSec - computeTimeAtSlot(this.config, slot, this.genesisTime);
}
msFromSlot(slot: Slot, toMs = Date.now()): number {
return toMs - computeTimeAtSlot(this.config, slot, this.genesisTime) * 1000;
}
private onNextSlot = (slot?: Slot): void => {
const clockSlot = slot ?? getCurrentSlot(this.config, this.genesisTime);
// process multiple clock slots in the case the main thread has been saturated for > SLOT_DURATION_MS
while (this._currentSlot < clockSlot && !this.signal.aborted) {
const previousSlot = this._currentSlot;
this._currentSlot++;
this.emit(ClockEvent.slot, this._currentSlot);
const previousEpoch = computeEpochAtSlot(previousSlot);
const currentEpoch = computeEpochAtSlot(this._currentSlot);
if (previousEpoch < currentEpoch) {
this.emit(ClockEvent.epoch, currentEpoch);
}
}
if (!this.signal.aborted) {
//recursively invoke onNextSlot
this.timeoutId = setTimeout(this.onNextSlot, this.msUntilNextSlot());
}
};
private msUntilNextSlot(): number {
const milliSecondsPerSlot = this.config.SLOT_DURATION_MS;
const diffInMilliSeconds = Date.now() - this.genesisTime * 1000;
return milliSecondsPerSlot - (diffInMilliSeconds % milliSecondsPerSlot);
}
}