relay-plugin-eth/src/client/client.ts

395 lines
11 KiB
TypeScript

import {
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,
} 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";
import { PublicKey } from "@chainsafe/bls/types.js";
import { fromHexString } 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;
}
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, ei.blockNumber);
}
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 = bellatrix.ssz.BeaconBlockBody.fromJson(blockJSON);
const blockRoot = toHexString(
bellatrix.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
);
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,
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));
}
}