398 lines
11 KiB
TypeScript
398 lines
11 KiB
TypeScript
import {
|
|
Bytes32,
|
|
ClientConfig,
|
|
ExecutionInfo,
|
|
LightClientUpdate,
|
|
OptimisticUpdate,
|
|
VerifyWithReason,
|
|
} from "./types.js";
|
|
import { getDefaultClientConfig, handleGETRequest } from "./utils.js";
|
|
import { IProver, IStore } from "./interfaces.js";
|
|
import {
|
|
BEACON_SYNC_SUPER_MAJORITY,
|
|
DEFAULT_BATCH_SIZE,
|
|
POLLING_DELAY,
|
|
} from "./constants.js";
|
|
import {
|
|
computeSyncPeriodAtSlot,
|
|
getCurrentSlot,
|
|
isValidMerkleBranch,
|
|
} from "@lodestar/light-client/utils";
|
|
import {
|
|
assertValidLightClientUpdate,
|
|
assertValidSignedHeader,
|
|
} from "@lodestar/light-client/validation";
|
|
import { SyncCommitteeFast } from "@lodestar/light-client";
|
|
import bls, { init } from "@chainsafe/bls/switchable";
|
|
// @ts-ignore
|
|
import type { PublicKey } from "@chainsafe/bls/types.js";
|
|
import { fromHexString, toHexString } from "@chainsafe/ssz";
|
|
import * as phase0 from "@lodestar/types/phase0";
|
|
import * as capella from "@lodestar/types/capella";
|
|
import NodeCache from "node-cache";
|
|
import { Mutex } from "async-mutex";
|
|
import { VerifyingProvider } from "./rpc/index.js";
|
|
import { ChainForkConfig } from "@lodestar/config";
|
|
import { allForks } from "@lodestar/types";
|
|
import {
|
|
BLOCK_BODY_EXECUTION_PAYLOAD_DEPTH as EXECUTION_PAYLOAD_DEPTH,
|
|
BLOCK_BODY_EXECUTION_PAYLOAD_INDEX as EXECUTION_PAYLOAD_INDEX,
|
|
} from "@lodestar/params";
|
|
|
|
export default class Client {
|
|
latestCommittee?: Uint8Array[];
|
|
latestBlockHash?: string;
|
|
private config: ClientConfig = getDefaultClientConfig();
|
|
private genesisCommittee: Uint8Array[] = this.config.genesis.committee.map(
|
|
(pk) => fromHexString(pk),
|
|
);
|
|
private genesisPeriod = computeSyncPeriodAtSlot(this.config.genesis.slot);
|
|
private genesisTime = this.config.genesis.time;
|
|
private prover: IProver;
|
|
private boot = false;
|
|
private beaconChainAPIURL: string;
|
|
private store: IStore;
|
|
private syncMutex = new Mutex();
|
|
private rpcUrl: string;
|
|
|
|
constructor(
|
|
prover: IProver,
|
|
store: IStore,
|
|
beaconUrl: string,
|
|
rpcUrl: string,
|
|
) {
|
|
this.prover = prover;
|
|
this.store = store;
|
|
this.beaconChainAPIURL = beaconUrl;
|
|
this.rpcUrl = rpcUrl;
|
|
}
|
|
|
|
private _provider?: VerifyingProvider;
|
|
|
|
get provider(): VerifyingProvider {
|
|
return this._provider as VerifyingProvider;
|
|
}
|
|
|
|
private _latestPeriod: number = -1;
|
|
|
|
get latestPeriod(): number {
|
|
return this._latestPeriod;
|
|
}
|
|
|
|
private _blockCache = new NodeCache({ stdTTL: 60 * 60 * 12 });
|
|
|
|
get blockCache(): NodeCache {
|
|
return this._blockCache;
|
|
}
|
|
|
|
private _blockHashCache = new NodeCache();
|
|
|
|
get blockHashCache(): NodeCache {
|
|
return this._blockHashCache;
|
|
}
|
|
|
|
public get isSynced() {
|
|
return this._latestPeriod === this.getCurrentPeriod();
|
|
}
|
|
|
|
public async sync(): Promise<void> {
|
|
await init("herumi");
|
|
|
|
await this._sync();
|
|
|
|
if (!this._provider) {
|
|
const { blockhash, blockNumber } = await this.getNextValidExecutionInfo();
|
|
const provider = new VerifyingProvider(
|
|
this.rpcUrl,
|
|
blockNumber,
|
|
blockhash,
|
|
);
|
|
this._provider = provider;
|
|
}
|
|
|
|
const ei = await this.getLatestExecution();
|
|
if (ei && ei.blockhash !== this.latestBlockHash) {
|
|
this.latestBlockHash = ei.blockhash;
|
|
}
|
|
this._provider.update(ei?.blockhash as string, ei?.blockNumber as bigint);
|
|
}
|
|
|
|
async syncProver(
|
|
startPeriod: number,
|
|
currentPeriod: number,
|
|
startCommittee: Uint8Array[],
|
|
): Promise<{ syncCommittee: Uint8Array[]; period: number }> {
|
|
for (let period = startPeriod; period < currentPeriod; period += 1) {
|
|
try {
|
|
const update = await this.prover.getSyncUpdate(
|
|
period,
|
|
currentPeriod,
|
|
DEFAULT_BATCH_SIZE,
|
|
);
|
|
const validOrCommittee = await this.syncUpdateVerifyGetCommittee(
|
|
startCommittee,
|
|
period,
|
|
update,
|
|
);
|
|
|
|
if (!(validOrCommittee as boolean)) {
|
|
console.log(`Found invalid update at period(${period})`);
|
|
return {
|
|
syncCommittee: startCommittee,
|
|
period,
|
|
};
|
|
}
|
|
|
|
await this.store.addUpdate(period, update);
|
|
startCommittee = validOrCommittee as Uint8Array[];
|
|
} catch (e) {
|
|
console.error(`failed to fetch sync update for period(${period})`);
|
|
return {
|
|
syncCommittee: startCommittee,
|
|
period,
|
|
};
|
|
}
|
|
}
|
|
return {
|
|
syncCommittee: startCommittee,
|
|
period: currentPeriod,
|
|
};
|
|
}
|
|
|
|
// returns the prover info containing the current sync
|
|
|
|
public getCurrentPeriod(): number {
|
|
return computeSyncPeriodAtSlot(
|
|
getCurrentSlot(this.config.chainConfig, this.genesisTime),
|
|
);
|
|
}
|
|
|
|
optimisticUpdateFromJSON(update: any): OptimisticUpdate {
|
|
return capella.ssz.LightClientOptimisticUpdate.fromJson(update);
|
|
}
|
|
|
|
async optimisticUpdateVerify(
|
|
committee: Uint8Array[],
|
|
update: OptimisticUpdate,
|
|
): Promise<VerifyWithReason> {
|
|
try {
|
|
const { attestedHeader: header, syncAggregate } = update;
|
|
const headerBlockRoot = phase0.ssz.BeaconBlockHeader.hashTreeRoot(
|
|
header.beacon,
|
|
);
|
|
const committeeFast = this.deserializeSyncCommittee(committee);
|
|
try {
|
|
assertValidSignedHeader(
|
|
this.config.chainConfig,
|
|
committeeFast,
|
|
syncAggregate,
|
|
headerBlockRoot,
|
|
header.beacon.slot,
|
|
);
|
|
} catch (e) {
|
|
return { correct: false, reason: "invalid signatures" };
|
|
}
|
|
|
|
const participation =
|
|
syncAggregate.syncCommitteeBits.getTrueBitIndexes().length;
|
|
if (participation < BEACON_SYNC_SUPER_MAJORITY) {
|
|
return { correct: false, reason: "insufficient signatures" };
|
|
}
|
|
|
|
if (!this.isValidLightClientHeader(this.config.chainConfig, header)) {
|
|
return { correct: false, reason: "invalid header" };
|
|
}
|
|
|
|
return { correct: true };
|
|
} catch (e) {
|
|
console.error(e);
|
|
return { correct: false, reason: (e as Error).message };
|
|
}
|
|
}
|
|
|
|
private isValidLightClientHeader(
|
|
config: ChainForkConfig,
|
|
header: allForks.LightClientHeader,
|
|
): boolean {
|
|
return isValidMerkleBranch(
|
|
config
|
|
.getExecutionForkTypes(header.beacon.slot)
|
|
.ExecutionPayloadHeader.hashTreeRoot(
|
|
(header as capella.LightClientHeader).execution,
|
|
),
|
|
(header as capella.LightClientHeader).executionBranch,
|
|
EXECUTION_PAYLOAD_DEPTH,
|
|
EXECUTION_PAYLOAD_INDEX,
|
|
header.beacon.bodyRoot,
|
|
);
|
|
}
|
|
|
|
public async getNextValidExecutionInfo(
|
|
retry: number = 10,
|
|
): Promise<ExecutionInfo> {
|
|
if (retry === 0)
|
|
throw new Error(
|
|
"no valid execution payload found in the given retry limit",
|
|
);
|
|
const ei = await this.getLatestExecution();
|
|
if (ei) return ei;
|
|
// delay for the next slot
|
|
await new Promise((resolve) => setTimeout(resolve, POLLING_DELAY));
|
|
return this.getNextValidExecutionInfo(retry - 1);
|
|
}
|
|
|
|
public async getExecutionFromBlockRoot(
|
|
slot: bigint,
|
|
expectedBlockRoot: Bytes32,
|
|
): Promise<ExecutionInfo> {
|
|
const res = await handleGETRequest(
|
|
`${this.beaconChainAPIURL}/eth/v2/beacon/blocks/${slot}`,
|
|
);
|
|
if (!res) {
|
|
throw Error(`fetching block failed`);
|
|
}
|
|
|
|
const blockJSON = res.data.message.body;
|
|
const block = capella.ssz.BeaconBlockBody.fromJson(blockJSON);
|
|
const blockRoot = toHexString(
|
|
capella.ssz.BeaconBlockBody.hashTreeRoot(block),
|
|
);
|
|
if (blockRoot !== expectedBlockRoot) {
|
|
throw Error(
|
|
`block provided by the beacon chain api doesn't match the expected block root`,
|
|
);
|
|
}
|
|
|
|
this._blockCache.set(slot as any, res);
|
|
this._blockHashCache.set(slot as any, expectedBlockRoot);
|
|
|
|
return {
|
|
blockhash: blockJSON.execution_payload.block_hash,
|
|
blockNumber: blockJSON.execution_payload.block_number,
|
|
};
|
|
}
|
|
|
|
protected async _sync() {
|
|
await this.syncMutex.acquire();
|
|
|
|
const currentPeriod = this.getCurrentPeriod();
|
|
if (currentPeriod > this._latestPeriod) {
|
|
if (!this.boot) {
|
|
this.latestCommittee = await this.syncFromGenesis();
|
|
} else {
|
|
this.latestCommittee = await this.syncFromLastUpdate();
|
|
}
|
|
this._latestPeriod = currentPeriod;
|
|
}
|
|
|
|
this.syncMutex.release();
|
|
}
|
|
|
|
// committee and prover index of the first honest prover
|
|
protected async syncFromGenesis(): Promise<Uint8Array[]> {
|
|
// get the tree size by currentPeriod - genesisPeriod
|
|
const currentPeriod = this.getCurrentPeriod();
|
|
let startPeriod = this.genesisPeriod;
|
|
let startCommittee = this.genesisCommittee;
|
|
console.log(
|
|
`Sync started from period(${startPeriod}) to period(${currentPeriod})`,
|
|
);
|
|
|
|
const { syncCommittee, period } = await this.syncProver(
|
|
startPeriod,
|
|
currentPeriod,
|
|
startCommittee,
|
|
);
|
|
if (period === currentPeriod) {
|
|
return syncCommittee;
|
|
}
|
|
throw new Error("no honest prover found");
|
|
}
|
|
|
|
private async syncFromLastUpdate() {
|
|
// get the tree size by currentPeriod - genesisPeriod
|
|
const currentPeriod = this.getCurrentPeriod();
|
|
let startPeriod = this.latestPeriod;
|
|
|
|
let startCommittee = this.latestCommittee;
|
|
|
|
const { syncCommittee, period } = await this.syncProver(
|
|
startPeriod,
|
|
currentPeriod,
|
|
startCommittee as Uint8Array[],
|
|
);
|
|
if (period === currentPeriod) {
|
|
return syncCommittee;
|
|
}
|
|
throw new Error("no honest prover found");
|
|
}
|
|
|
|
protected async syncUpdateVerifyGetCommittee(
|
|
prevCommittee: Uint8Array[],
|
|
period: number,
|
|
update: LightClientUpdate,
|
|
): Promise<false | Uint8Array[]> {
|
|
const updatePeriod = computeSyncPeriodAtSlot(
|
|
update.attestedHeader.beacon.slot,
|
|
);
|
|
if (period !== updatePeriod) {
|
|
console.error(
|
|
`Expected update with period ${period}, but recieved ${updatePeriod}`,
|
|
);
|
|
return false;
|
|
}
|
|
|
|
const prevCommitteeFast = this.deserializeSyncCommittee(prevCommittee);
|
|
try {
|
|
// check if the update has valid signatures
|
|
await assertValidLightClientUpdate(
|
|
this.config.chainConfig,
|
|
prevCommitteeFast,
|
|
update,
|
|
);
|
|
return update.nextSyncCommittee.pubkeys;
|
|
} catch (e) {
|
|
console.error(e);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
protected async getLatestExecution(): Promise<ExecutionInfo | null> {
|
|
const updateJSON = await handleGETRequest(
|
|
`${this.beaconChainAPIURL}/eth/v1/beacon/light_client/optimistic_update`,
|
|
);
|
|
if (!updateJSON) {
|
|
throw Error(`fetching optimistic update failed`);
|
|
}
|
|
const update = this.optimisticUpdateFromJSON(updateJSON.data);
|
|
const verify = await this.optimisticUpdateVerify(
|
|
this.latestCommittee as Uint8Array[],
|
|
update,
|
|
);
|
|
|
|
if (!verify.correct) {
|
|
// @ts-ignore
|
|
console.error(`Invalid Optimistic Update: ${verify?.reason}`);
|
|
return null;
|
|
}
|
|
return this.getExecutionFromBlockRoot(
|
|
updateJSON.data.attested_header.beacon.slot,
|
|
updateJSON.data.attested_header.beacon.body_root,
|
|
);
|
|
}
|
|
|
|
private deserializeSyncCommittee(
|
|
syncCommittee: Uint8Array[],
|
|
): SyncCommitteeFast {
|
|
const pubkeys = this.deserializePubkeys(syncCommittee);
|
|
return {
|
|
pubkeys,
|
|
aggregatePubkey: bls.PublicKey.aggregate(pubkeys),
|
|
};
|
|
}
|
|
|
|
private deserializePubkeys(pubkeys: Uint8Array[]): PublicKey[] {
|
|
return pubkeys.map((pk) => bls.PublicKey.fromBytes(pk));
|
|
}
|
|
}
|