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 { 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 { 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 { 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 { 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 { // 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 { 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 { 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)); } }