303 lines
9.0 KiB
TypeScript
303 lines
9.0 KiB
TypeScript
import {
|
|
ClientConfig,
|
|
ExecutionInfo,
|
|
OptimisticUpdate,
|
|
VerifyWithReason,
|
|
} from "./types.js";
|
|
import {
|
|
concatUint8Array,
|
|
getDefaultClientConfig,
|
|
isUint8ArrayEq,
|
|
} from "./utils.js";
|
|
import { IProver } 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 { 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 capella from "@lodestar/types/capella";
|
|
import * as phase0 from "@lodestar/types/phase0";
|
|
import { VerifyingProvider } from "./rpc/provider.js";
|
|
import { digest } from "@chainsafe/as-sha256";
|
|
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[];
|
|
latestPeriod: number = -1;
|
|
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 rpcCallback: Function;
|
|
|
|
private boot = false;
|
|
|
|
constructor(prover: IProver, rpcCallback: Function) {
|
|
this.prover = prover;
|
|
this.rpcCallback = rpcCallback;
|
|
}
|
|
|
|
private _provider?: VerifyingProvider;
|
|
|
|
get provider(): VerifyingProvider {
|
|
return this._provider as VerifyingProvider;
|
|
}
|
|
|
|
public get isSynced() {
|
|
return this.latestPeriod === this.getCurrentPeriod();
|
|
}
|
|
|
|
public async sync(): Promise<VerifyingProvider> {
|
|
await init("herumi");
|
|
|
|
await this._sync();
|
|
|
|
if (!this._provider) {
|
|
const { blockhash, blockNumber } = await this.getNextValidExecutionInfo();
|
|
const provider = new VerifyingProvider(
|
|
this.rpcCallback,
|
|
blockNumber,
|
|
blockhash
|
|
);
|
|
this.subscribe((ei) => {
|
|
console.log(
|
|
`Recieved a new blockheader: ${ei.blockNumber} ${ei.blockhash}`
|
|
);
|
|
provider.update(ei.blockhash, ei.blockNumber);
|
|
});
|
|
|
|
this._provider = provider;
|
|
this.boot = true;
|
|
}
|
|
|
|
return this._provider;
|
|
}
|
|
|
|
public getCurrentPeriod(): number {
|
|
return computeSyncPeriodAtSlot(
|
|
getCurrentSlot(this.config.chainConfig, this.genesisTime)
|
|
);
|
|
}
|
|
|
|
public async subscribe(callback: (ei: ExecutionInfo) => void) {
|
|
setInterval(async () => {
|
|
try {
|
|
await this._sync();
|
|
const ei = await this.getLatestExecution();
|
|
if (ei && ei.blockhash !== this.latestBlockHash) {
|
|
this.latestBlockHash = ei.blockhash;
|
|
return await callback(ei);
|
|
}
|
|
} catch (e) {
|
|
console.error(e);
|
|
}
|
|
}, POLLING_DELAY);
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
private async _sync() {
|
|
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;
|
|
}
|
|
}
|
|
|
|
// committee and prover index of the first honest prover
|
|
private async syncFromGenesis(): Promise<Uint8Array[]> {
|
|
const currentPeriod = this.getCurrentPeriod();
|
|
let startPeriod = this.genesisPeriod;
|
|
|
|
let lastCommitteeHash: Uint8Array = this.getCommitteeHash(
|
|
this.genesisCommittee
|
|
);
|
|
|
|
for (let period = startPeriod + 1; period <= currentPeriod; period++) {
|
|
try {
|
|
lastCommitteeHash = await this.prover.getCommitteeHash(
|
|
period,
|
|
currentPeriod,
|
|
DEFAULT_BATCH_SIZE
|
|
);
|
|
} catch (e: any) {
|
|
throw new Error(
|
|
`failed to fetch committee hash for prover at period(${period}): ${e.meessage}`
|
|
);
|
|
}
|
|
}
|
|
return this.getCommittee(currentPeriod, lastCommitteeHash);
|
|
}
|
|
|
|
private async syncFromLastUpdate() {
|
|
const currentPeriod = this.getCurrentPeriod();
|
|
let startPeriod = this.latestPeriod;
|
|
|
|
let lastCommitteeHash: Uint8Array = this.getCommitteeHash(
|
|
this.latestCommittee as Uint8Array[]
|
|
);
|
|
|
|
for (let period = startPeriod + 1; period <= currentPeriod; period++) {
|
|
try {
|
|
lastCommitteeHash = await this.prover.getCommitteeHash(
|
|
period,
|
|
currentPeriod,
|
|
DEFAULT_BATCH_SIZE
|
|
);
|
|
} catch (e: any) {
|
|
throw new Error(
|
|
`failed to fetch committee hash for prover at period(${period}): ${e.meessage}`
|
|
);
|
|
}
|
|
}
|
|
return this.getCommittee(currentPeriod, lastCommitteeHash);
|
|
}
|
|
|
|
async getCommittee(
|
|
period: number,
|
|
expectedCommitteeHash: Uint8Array | null
|
|
): Promise<Uint8Array[]> {
|
|
if (period === this.genesisPeriod) return this.genesisCommittee;
|
|
if (!expectedCommitteeHash)
|
|
throw new Error("expectedCommitteeHash required");
|
|
const committee = await this.prover.getCommittee(period);
|
|
const committeeHash = this.getCommitteeHash(committee);
|
|
if (!isUint8ArrayEq(committeeHash, expectedCommitteeHash as Uint8Array))
|
|
throw new Error("prover responded with an incorrect committee");
|
|
return committee;
|
|
}
|
|
|
|
private async getLatestExecution(): Promise<ExecutionInfo | null> {
|
|
const updateJSON = await this.prover.callback(
|
|
"consensus_optimistic_update"
|
|
);
|
|
const update = this.optimisticUpdateFromJSON(updateJSON);
|
|
const verify = await this.optimisticUpdateVerify(
|
|
this.latestCommittee as Uint8Array[],
|
|
update
|
|
);
|
|
if (!verify.correct) {
|
|
console.error(`Invalid Optimistic Update: ${verify.reason}`);
|
|
return null;
|
|
}
|
|
console.log(
|
|
`Optimistic update verified for slot ${updateJSON.attested_header.beacon.slot}`
|
|
);
|
|
return {
|
|
blockhash: updateJSON.attested_header.execution.block_hash,
|
|
blockNumber: updateJSON.attested_header.execution.block_number,
|
|
};
|
|
}
|
|
|
|
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));
|
|
}
|
|
private getCommitteeHash(committee: Uint8Array[]): Uint8Array {
|
|
return digest(concatUint8Array(committee));
|
|
}
|
|
private optimisticUpdateFromJSON(update: any): OptimisticUpdate {
|
|
return capella.ssz.LightClientOptimisticUpdate.fromJson(update);
|
|
}
|
|
}
|