diff --git a/src/cli/cli.module.ts b/src/cli/cli.module.ts index 8a9ce6d..e68db22 100644 --- a/src/cli/cli.module.ts +++ b/src/cli/cli.module.ts @@ -2,12 +2,12 @@ import { Module } from '@nestjs/common'; import { CliService } from './cli.service'; import { ConfigModule } from '../common/config/config.module'; -import { HandlersModule } from '../common/handlers/handlers.module'; import { LoggerModule } from '../common/logger/logger.module'; +import { ProverModule } from '../common/prover/prover.module'; import { ProvidersModule } from '../common/providers/providers.module'; @Module({ - imports: [LoggerModule, ConfigModule, ProvidersModule, HandlersModule], + imports: [LoggerModule, ConfigModule, ProvidersModule, ProverModule], providers: [CliService], }) export class CliModule {} diff --git a/src/common/handlers/handlers.module.ts b/src/common/handlers/handlers.module.ts deleted file mode 100644 index 4c4ca80..0000000 --- a/src/common/handlers/handlers.module.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Module } from '@nestjs/common'; - -import { HandlersService } from './handlers.service'; -import { ProvidersModule } from '../providers/providers.module'; - -@Module({ - imports: [ProvidersModule], - providers: [HandlersService], - exports: [HandlersService], -}) -export class HandlersModule {} diff --git a/src/common/handlers/handlers.service.ts b/src/common/handlers/handlers.service.ts deleted file mode 100644 index 8d2de70..0000000 --- a/src/common/handlers/handlers.service.ts +++ /dev/null @@ -1,185 +0,0 @@ -import { LOGGER_PROVIDER } from '@lido-nestjs/logger'; -import { Inject, Injectable, LoggerService } from '@nestjs/common'; - -import { WithdrawalsProvePayload } from './types'; -import { Consensus } from '../providers/consensus/consensus'; -import { BlockHeaderResponse, BlockInfoResponse, RootHex, Withdrawal } from '../providers/consensus/response.interface'; - -export interface KeyInfo { - operatorId: number; - keyIndex: number; - pubKey: string; - withdrawableEpoch: number; -} - -type KeyInfoFn = (valIndex: number) => KeyInfo | undefined; - -@Injectable() -export class HandlersService { - // according to the research https://hackmd.io/1wM8vqeNTjqt4pC3XoCUKQ?view#Proposed-solution - private readonly FULL_WITHDRAWAL_MIN_AMOUNT = 8 * 10 ** 18; // 8 ETH - - constructor( - @Inject(LOGGER_PROVIDER) protected readonly logger: LoggerService, - protected readonly consensus: Consensus, - ) {} - - public async proveIfNeeded(blockRoot: RootHex, blockInfo: BlockInfoResponse, keyInfoFn: KeyInfoFn): Promise { - const slashings = await this.getUnprovenSlashings(blockRoot, blockInfo, keyInfoFn); - const withdrawals = await this.getUnprovenWithdrawals(blockRoot, blockInfo, keyInfoFn); - if (!slashings.length && !withdrawals.length) return; - const header = await this.consensus.getBeaconHeader(blockRoot); - // TODO: wait until appears next block if doesn't exist - const nextHeaders = await this.consensus.getBeaconHeadersByParentRoot(blockRoot); - const nextHeader = nextHeaders.data[0]; - const stateView = await this.consensus.getStateView(header.header.message.state_root); - if (slashings.length) { - for (const payload of this.buildSlashingsProvePayloads(blockInfo, nextHeader, stateView, slashings)) { - // TODO: ask before sending if CLI or daemon in watch mode - await this.sendSlashingsProve(payload); - } - } - if (withdrawals.length) { - for (const payload of this.buildWithdrawalsProvePayloads(blockInfo, nextHeader, stateView, withdrawals)) { - // TODO: ask before sending if CLI or daemon in watch mode - await this.sendWithdrawalsProve(payload); - } - } - if (!slashings.length || !withdrawals.length) this.logger.log(`🏁 Proves sent. Root [${blockRoot}]`); - } - - private *buildSlashingsProvePayloads( - blockInfo: BlockInfoResponse, - nextHeader: BlockHeaderResponse, - stateView: any, // TODO: type - slashings: string[], - ): Generator { - this.logger.warn(`📦 Building prove payloads | Slashings: [${slashings}]`); - for (const slashing of slashings) { - // const validatorsInfo = stateView.validators.type.elementType.toJson(stateView.validators.get(1337)); - yield slashing; - } - } - - private *buildWithdrawalsProvePayloads( - blockInfo: BlockInfoResponse, - nextHeader: BlockHeaderResponse, - stateView: any, // TODO: type - withdrawals: Withdrawal[], - ): Generator { - this.logger.warn(`📦 Building prove payloads | Withdrawals: [${withdrawals}]`); - for (const withdrawal of withdrawals) { - // const validatorsInfo = stateView.validators.type.elementType.toJson(stateView.validators.get(1337)); - yield withdrawal as WithdrawalsProvePayload; - } - } - - private async sendSlashingsProve(payload: any): Promise { - // TODO: implement - this.logger.log(payload); - this.logger.warn(`📡 Sending slashings prove`); - } - - private async sendWithdrawalsProve(payload: any): Promise { - // TODO: implement - this.logger.log(payload); - this.logger.warn(`📡 Sending withdrawals prove`); - } - - private async getUnprovenSlashings( - blockRoot: RootHex, - blockInfo: BlockInfoResponse, - keyInfoFn: KeyInfoFn, - ): Promise { - const slashings = [ - ...this.getSlashedProposers(blockInfo, keyInfoFn), - ...this.getSlashedAttesters(blockInfo, keyInfoFn), - ]; - if (!slashings.length) return []; - const unproven = []; - for (const slashing of slashings) { - // TODO: implement - // const proved = await this.execution.isSlashingProved(slashing); - const proved = false; - if (!proved) unproven.push(slashing); - } - if (!unproven.length) { - this.logger.log(`No slashings to prove. Root [${blockRoot}]`); - return []; - } - this.logger.warn(`🔍 Unproven slashings: ${unproven}`); - return unproven; - } - - private async getUnprovenWithdrawals( - blockRoot: RootHex, - blockInfo: BlockInfoResponse, - keyInfoFn: KeyInfoFn, - ): Promise { - const withdrawals = this.getFullWithdrawals(blockInfo, keyInfoFn); - if (!withdrawals.length) return []; - const unproven = []; - for (const withdrawal of withdrawals) { - // TODO: implement - // const proved = await this.execution.isSlashingProved(slashing); - const proved = false; - if (!proved) unproven.push(withdrawal); - } - if (!unproven.length) { - this.logger.log(`No full withdrawals to prove. Root [${blockRoot}]`); - return []; - } - this.logger.warn(`🔍 Unproven full withdrawals: ${unproven.length}`); - return unproven; - } - - private getSlashedAttesters( - blockInfo: BlockInfoResponse, - keyInfoFn: (valIndex: number) => KeyInfo | undefined, - ): string[] { - const slashed = []; - for (const att of blockInfo.message.body.attester_slashings) { - const accused = att.attestation_1.attesting_indices.filter((x) => - att.attestation_2.attesting_indices.includes(x), - ); - slashed.push(...accused.filter((item) => keyInfoFn(Number(item)))); - } - return slashed; - } - - private getSlashedProposers( - blockInfo: BlockInfoResponse, - keyInfoFn: (valIndex: number) => KeyInfo | undefined, - ): string[] { - const slashed = []; - for (const prop of blockInfo.message.body.proposer_slashings) { - if (keyInfoFn(Number(prop.signed_header_1.proposer_index))) { - slashed.push(prop.signed_header_1.proposer_index); - } - } - return slashed; - } - - private getFullWithdrawals( - blockInfo: BlockInfoResponse, - keyInfoFn: (valIndex: number) => KeyInfo | undefined, - ): Withdrawal[] { - const fullWithdrawals = []; - const epoch = Number((Number(blockInfo.message.slot) / 32).toFixed()); - const withdrawals = blockInfo.message.body.execution_payload?.withdrawals ?? []; - for (const withdrawal of withdrawals) { - const keyInfo = keyInfoFn(Number(withdrawal.validator_index)); - if (!keyInfo) continue; - if (this.isFullWithdrawal(epoch, keyInfo, withdrawal)) fullWithdrawals.push(withdrawal); - } - return fullWithdrawals; - } - - private isFullWithdrawal(epoch: number, keyInfo: KeyInfo, withdrawal: Withdrawal): boolean { - return ( - keyInfo.withdrawableEpoch != null && - epoch >= keyInfo.withdrawableEpoch && - Number(withdrawal.amount) > this.FULL_WITHDRAWAL_MIN_AMOUNT - ); - } -} diff --git a/src/common/handlers/types.ts b/src/common/handlers/types.ts deleted file mode 100644 index a0680e3..0000000 --- a/src/common/handlers/types.ts +++ /dev/null @@ -1,5 +0,0 @@ -export type WithdrawalsProvePayload = WithdrawalsGeneralProvePayload | WithdrawalsHistoricalProvePayload; - -export type WithdrawalsGeneralProvePayload = any; - -export type WithdrawalsHistoricalProvePayload = any; diff --git a/src/common/prover/duties/slashings.ts b/src/common/prover/duties/slashings.ts new file mode 100644 index 0000000..96e957a --- /dev/null +++ b/src/common/prover/duties/slashings.ts @@ -0,0 +1,128 @@ +import { ContainerTreeViewType } from '@chainsafe/ssz/lib/view/container'; +import { LOGGER_PROVIDER } from '@lido-nestjs/logger'; +import { Inject, Injectable, LoggerService } from '@nestjs/common'; + +import { Consensus } from '../../providers/consensus/consensus'; +import { BlockHeaderResponse, BlockInfoResponse } from '../../providers/consensus/response.interface'; +import { generateValidatorProof, toHex, verifyProof } from '../helpers/proofs'; +import { KeyInfo, KeyInfoFn, SlashingProvePayload } from '../types'; + +let ssz: typeof import('@lodestar/types').ssz; +let anySsz: typeof ssz.phase0 | typeof ssz.altair | typeof ssz.bellatrix | typeof ssz.capella | typeof ssz.deneb; + +type InvolvedKeys = { [valIndex: string]: KeyInfo }; + +@Injectable() +export class SlashingsService { + constructor( + @Inject(LOGGER_PROVIDER) protected readonly logger: LoggerService, + protected readonly consensus: Consensus, + ) {} + + public async getUnprovenSlashings(blockInfo: BlockInfoResponse, keyInfoFn: KeyInfoFn): Promise { + const slashings = { + ...this.getSlashedProposers(blockInfo, keyInfoFn), + ...this.getSlashedAttesters(blockInfo, keyInfoFn), + }; + if (!Object.keys(slashings).length) return {}; + const unproven: InvolvedKeys = {}; + for (const [valIndex, keyInfo] of Object.entries(slashings)) { + // TODO: implement + // const proved = await this.execution.isSlashingProved(slashing); + const proved = false; + if (!proved) unproven[valIndex] = keyInfo; + } + const unprovenCount = Object.keys(unproven).length; + if (!unprovenCount) { + this.logger.log('No slashings to prove'); + return {}; + } + this.logger.warn(`🔍 Unproven slashings: ${unprovenCount}`); + return unproven; + } + + public async sendSlashingProves(finalizedHeader: BlockHeaderResponse, slashings: InvolvedKeys): Promise { + if (!Object.keys(slashings).length) return; + this.logger.log(`Getting state for root [${finalizedHeader.root}]`); + const finalizedState = await this.consensus.getState(finalizedHeader.header.message.state_root); + const nextHeader = (await this.consensus.getBeaconHeadersByParentRoot(finalizedHeader.root)).data[0]; + const nextHeaderTs = this.consensus.slotToTimestamp(Number(nextHeader.header.message.slot)); + const stateView = this.consensus.stateToView(finalizedState.bodyBytes, finalizedState.forkName); + const payloads = this.buildSlashingsProvePayloads(finalizedHeader, nextHeaderTs, stateView, slashings); + for (const payload of payloads) { + this.logger.warn(`📡 Sending slashing prove payload for validator index: ${payload.witness.validatorIndex}`); + // TODO: implement + // TODO: ask before sending if CLI + this.logger.log(payload); + } + } + + private getSlashedAttesters( + blockInfo: BlockInfoResponse, + keyInfoFn: (valIndex: number) => KeyInfo | undefined, + ): InvolvedKeys { + const slashed: InvolvedKeys = {}; + for (const att of blockInfo.message.body.attester_slashings) { + const accused = att.attestation_1.attesting_indices.filter((x) => + att.attestation_2.attesting_indices.includes(x), + ); + for (const valIndex of accused) { + const keyInfo = keyInfoFn(Number(valIndex)); + if (!keyInfo) continue; + slashed[valIndex] = keyInfo; + } + } + return slashed; + } + + private getSlashedProposers( + blockInfo: BlockInfoResponse, + keyInfoFn: (valIndex: number) => KeyInfo | undefined, + ): InvolvedKeys { + const slashed: InvolvedKeys = {}; + for (const prop of blockInfo.message.body.proposer_slashings) { + const keyInfo = keyInfoFn(Number(prop.signed_header_1.proposer_index)); + if (!keyInfo) continue; + slashed[prop.signed_header_1.proposer_index] = keyInfo; + } + return slashed; + } + + private *buildSlashingsProvePayloads( + currentHeader: BlockHeaderResponse, + nextHeaderTimestamp: number, + stateView: ContainerTreeViewType, + slashings: InvolvedKeys, + ): Generator { + for (const [valIndex, keyInfo] of Object.entries(slashings)) { + const validator = stateView.validators.get(Number(valIndex)); + const validatorProof = generateValidatorProof(stateView, Number(valIndex)); + // verify validator proof + verifyProof(stateView.hashTreeRoot(), validatorProof.gindex, validatorProof.witnesses, validator.hashTreeRoot()); + yield { + keyIndex: keyInfo.keyIndex, + nodeOperatorId: keyInfo.operatorId, + beaconBlock: { + header: { + slot: currentHeader.header.message.slot, + proposerIndex: Number(currentHeader.header.message.proposer_index), + parentRoot: currentHeader.header.message.parent_root, + stateRoot: currentHeader.header.message.state_root, + bodyRoot: currentHeader.header.message.body_root, + }, + rootsTimestamp: nextHeaderTimestamp, + }, + witness: { + validatorIndex: Number(valIndex), + withdrawalCredentials: toHex(validator.withdrawalCredentials), + effectiveBalance: validator.effectiveBalance, + activationEligibilityEpoch: validator.activationEligibilityEpoch, + activationEpoch: validator.activationEpoch, + exitEpoch: validator.exitEpoch, + withdrawableEpoch: validator.withdrawableEpoch, + validatorProof: validatorProof.witnesses.map(toHex), + }, + }; + } + } +} diff --git a/src/common/prover/duties/withdrawals.ts b/src/common/prover/duties/withdrawals.ts new file mode 100644 index 0000000..2e9d8a5 --- /dev/null +++ b/src/common/prover/duties/withdrawals.ts @@ -0,0 +1,349 @@ +import { ContainerTreeViewType } from '@chainsafe/ssz/lib/view/container'; +import { LOGGER_PROVIDER } from '@lido-nestjs/logger'; +import { ForkName } from '@lodestar/params'; +import { Inject, Injectable, LoggerService } from '@nestjs/common'; + +import { Consensus } from '../../providers/consensus/consensus'; +import { + BlockHeaderResponse, + BlockInfoResponse, + RootHex, + Withdrawal, +} from '../../providers/consensus/response.interface'; +import { + generateHistoricalStateProof, + generateValidatorProof, + generateWithdrawalProof, + toHex, + verifyProof, +} from '../helpers/proofs'; +import { KeyInfo, KeyInfoFn, WithdrawalsGeneralProvePayload, WithdrawalsHistoricalProvePayload } from '../types'; + +let ssz: typeof import('@lodestar/types').ssz; +let anySsz: typeof ssz.phase0 | typeof ssz.altair | typeof ssz.bellatrix | typeof ssz.capella | typeof ssz.deneb; + +// according to the research https://hackmd.io/1wM8vqeNTjqt4pC3XoCUKQ?view#Proposed-solution +const FULL_WITHDRAWAL_MIN_AMOUNT = 8 * 10 ** 18; // 8 ETH + +type WithdrawalWithOffset = Withdrawal & { offset: number }; +type InvolvedKeysWithWithdrawal = { [valIndex: string]: KeyInfo & { withdrawal: WithdrawalWithOffset } }; + +@Injectable() +export class WithdrawalsService { + constructor( + @Inject(LOGGER_PROVIDER) protected readonly logger: LoggerService, + protected readonly consensus: Consensus, + ) {} + + public async getUnprovenWithdrawals( + blockInfo: BlockInfoResponse, + keyInfoFn: KeyInfoFn, + ): Promise { + const withdrawals = this.getFullWithdrawals(blockInfo, keyInfoFn); + if (!Object.keys(withdrawals).length) return {}; + const unproven: InvolvedKeysWithWithdrawal = {}; + for (const [valIndex, keyWithWithdrawalInfo] of Object.entries(withdrawals)) { + // TODO: implement + // const proved = await this.execution.isSlashingProved(slashing); + const proved = false; + if (!proved) unproven[valIndex] = keyWithWithdrawalInfo; + } + const unprovenCount = Object.keys(unproven).length; + if (!unprovenCount) { + this.logger.log('No full withdrawals to prove'); + return {}; + } + this.logger.warn(`🔍 Unproven full withdrawals: ${unprovenCount}`); + return unproven; + } + + public async sendWithdrawalProves( + blockRoot: RootHex, + blockInfo: BlockInfoResponse, + finalizedHeader: BlockHeaderResponse, + withdrawals: InvolvedKeysWithWithdrawal, + ): Promise { + if (!Object.keys(withdrawals).length) return; + const blockHeader = await this.consensus.getBeaconHeader(blockRoot); + this.logger.log(`Getting state for root [${blockRoot}]`); + const state = await this.consensus.getState(blockHeader.header.message.state_root); + // There is a case when the block is not historical regarding the finalized block, but it is historical + // regarding the transaction execution time. This is possible when long finalization time + // The transaction will be reverted and the application will try to handle that block again + if (this.isHistoricalBlock(blockInfo, finalizedHeader)) { + this.logger.warn('It is historical withdrawal. Processing will take longer than usual'); + await this.sendHistoricalWithdrawalProves(blockHeader, blockInfo, state, finalizedHeader, withdrawals); + } else { + await this.sendGeneralWithdrawalProves(blockHeader, blockInfo, state, withdrawals); + } + } + + private async sendGeneralWithdrawalProves( + blockHeader: BlockHeaderResponse, + blockInfo: BlockInfoResponse, + state: { bodyBytes: Uint8Array; forkName: keyof typeof ForkName }, + withdrawals: InvolvedKeysWithWithdrawal, + ): Promise { + // create proof against the state with withdrawals + const nextBlockHeader = (await this.consensus.getBeaconHeadersByParentRoot(blockHeader.root)).data[0]; + const nextBlockTs = this.consensus.slotToTimestamp(Number(nextBlockHeader.header.message.slot)); + const payloads = this.buildWithdrawalsProveGeneralPayloads( + blockHeader, + nextBlockTs, + this.consensus.stateToView(state.bodyBytes, state.forkName), + this.consensus.blockToView(blockInfo, state.forkName), + withdrawals, + ); + for (const payload of payloads) { + this.logger.warn(`📡 Sending withdrawal prove payload for validator index: ${payload.witness.validatorIndex}`); + // TODO: implement + // TODO: ask before sending if CLI + this.logger.log(payload); + } + } + + private async sendHistoricalWithdrawalProves( + blockHeader: BlockHeaderResponse, + blockInfo: BlockInfoResponse, + state: { bodyBytes: Uint8Array; forkName: keyof typeof ForkName }, + finalizedHeader: BlockHeaderResponse, + withdrawals: InvolvedKeysWithWithdrawal, + ): Promise { + // create proof against the historical state with withdrawals + const nextBlockHeader = (await this.consensus.getBeaconHeadersByParentRoot(finalizedHeader.root)).data[0]; + const nextBlockTs = this.consensus.slotToTimestamp(Number(nextBlockHeader.header.message.slot)); + this.logger.log(`Getting state for root [${finalizedHeader.root}]`); + const finalizedState = await this.consensus.getState(finalizedHeader.header.message.state_root); + const summaryIndex = this.calcSummaryIndex(blockInfo); + const summarySlot = this.calcSlotOfSummary(summaryIndex); + this.logger.log(`Getting state for slot [${summarySlot}]`); + const summaryState = await this.consensus.getState(summarySlot); + const payloads = this.buildWithdrawalsProveHistoricalPayloads( + blockHeader, + finalizedHeader, + nextBlockTs, + this.consensus.stateToView(finalizedState.bodyBytes, finalizedState.forkName), + this.consensus.stateToView(summaryState.bodyBytes, summaryState.forkName), + this.consensus.stateToView(state.bodyBytes, state.forkName), + this.consensus.blockToView(blockInfo, state.forkName), + summaryIndex, + this.calcRootIndexInSummary(blockInfo), + withdrawals, + ); + for (const payload of payloads) { + this.logger.warn( + `📡 Sending historical withdrawal prove payload for validator index: ${payload.witness.validatorIndex}`, + ); + // TODO: implement + // TODO: ask before sending if CLI + this.logger.log(payload); + } + } + + private getFullWithdrawals( + blockInfo: BlockInfoResponse, + keyInfoFn: (valIndex: number) => KeyInfo | undefined, + ): InvolvedKeysWithWithdrawal { + const fullWithdrawals: InvolvedKeysWithWithdrawal = {}; + const withdrawals = blockInfo.message.body.execution_payload?.withdrawals ?? []; + for (let i = 0; i < withdrawals.length; i++) { + const keyInfo = keyInfoFn(Number(withdrawals[i].validator_index)); + if (!keyInfo) continue; + if (Number(withdrawals[i].amount) < FULL_WITHDRAWAL_MIN_AMOUNT) continue; + fullWithdrawals[withdrawals[i].validator_index] = { ...keyInfo, withdrawal: { ...withdrawals[i], offset: i } }; + } + return fullWithdrawals; + } + + private *buildWithdrawalsProveGeneralPayloads( + currentHeader: BlockHeaderResponse, + nextHeaderTimestamp: number, + stateView: ContainerTreeViewType, + currentBlockView: ContainerTreeViewType, + withdrawals: InvolvedKeysWithWithdrawal, + ): Generator { + const epoch = this.consensus.slotToEpoch(Number(currentHeader.header.message.slot)); + for (const [valIndex, keyWithWithdrawalInfo] of Object.entries(withdrawals)) { + const validator = stateView.validators.get(Number(valIndex)); + if (epoch < validator.withdrawableEpoch) { + this.logger.warn(`Validator ${valIndex} is not full withdrawn. Just huge amount of ETH. Skipped`); + continue; + } + const validatorProof = generateValidatorProof(stateView, Number(valIndex)); + const withdrawalProof = generateWithdrawalProof( + stateView, + currentBlockView, + keyWithWithdrawalInfo.withdrawal.offset, + ); + // verify validator proof + verifyProof(stateView.hashTreeRoot(), validatorProof.gindex, validatorProof.witnesses, validator.hashTreeRoot()); + // verify withdrawal proof + verifyProof( + stateView.hashTreeRoot(), + withdrawalProof.gindex, + withdrawalProof.witnesses, + ( + currentBlockView as ContainerTreeViewType + ).body.executionPayload.withdrawals + .get(keyWithWithdrawalInfo.withdrawal.offset) + .hashTreeRoot(), + ); + yield { + keyIndex: keyWithWithdrawalInfo.keyIndex, + nodeOperatorId: keyWithWithdrawalInfo.operatorId, + beaconBlock: { + header: { + slot: currentHeader.header.message.slot, + proposerIndex: Number(currentHeader.header.message.proposer_index), + parentRoot: currentHeader.header.message.parent_root, + stateRoot: currentHeader.header.message.state_root, + bodyRoot: currentHeader.header.message.body_root, + }, + rootsTimestamp: nextHeaderTimestamp, + }, + witness: { + withdrawalOffset: Number(keyWithWithdrawalInfo.withdrawal.offset), + withdrawalIndex: Number(keyWithWithdrawalInfo.withdrawal.index), + validatorIndex: Number(keyWithWithdrawalInfo.withdrawal.validator_index), + amount: Number(keyWithWithdrawalInfo.withdrawal.amount), + withdrawalCredentials: toHex(validator.withdrawalCredentials), + effectiveBalance: validator.effectiveBalance, + slashed: Boolean(validator.slashed), + activationEligibilityEpoch: validator.activationEligibilityEpoch, + activationEpoch: validator.activationEpoch, + exitEpoch: validator.exitEpoch, + withdrawableEpoch: validator.withdrawableEpoch, + withdrawalProof: withdrawalProof.witnesses.map(toHex), + validatorProof: validatorProof.witnesses.map(toHex), + }, + }; + } + } + + private *buildWithdrawalsProveHistoricalPayloads( + headerWithWds: BlockHeaderResponse, + finalHeader: BlockHeaderResponse, + nextToFinalizedHeaderTimestamp: number, + finalizedStateView: ContainerTreeViewType, + summaryStateView: ContainerTreeViewType, + stateWithWdsView: ContainerTreeViewType, + blockWithWdsView: ContainerTreeViewType, + summaryIndex: number, + rootIndexInSummary: number, + withdrawals: InvolvedKeysWithWithdrawal, + ): Generator { + const epoch = this.consensus.slotToEpoch(Number(headerWithWds.header.message.slot)); + for (const [valIndex, keyWithWithdrawalInfo] of Object.entries(withdrawals)) { + const validator = stateWithWdsView.validators.get(Number(valIndex)); + if (epoch < validator.withdrawableEpoch) { + this.logger.warn(`Validator ${valIndex} is not full withdrawn. Just huge amount of ETH. Skipped`); + continue; + } + const validatorProof = generateValidatorProof(stateWithWdsView, Number(valIndex)); + const withdrawalProof = generateWithdrawalProof( + stateWithWdsView, + blockWithWdsView, + keyWithWithdrawalInfo.withdrawal.offset, + ); + const historicalStateProof = generateHistoricalStateProof( + finalizedStateView, + summaryStateView, + summaryIndex, + rootIndexInSummary, + ); + // verify validator proof + verifyProof( + stateWithWdsView.hashTreeRoot(), + validatorProof.gindex, + validatorProof.witnesses, + validator.hashTreeRoot(), + ); + // verify withdrawal proof + verifyProof( + stateWithWdsView.hashTreeRoot(), + withdrawalProof.gindex, + withdrawalProof.witnesses, + ( + blockWithWdsView as ContainerTreeViewType + ).body.executionPayload.withdrawals + .get(keyWithWithdrawalInfo.withdrawal.offset) + .hashTreeRoot(), + ); + // verify historical state proof + verifyProof( + finalizedStateView.hashTreeRoot(), + historicalStateProof.gindex, + historicalStateProof.witnesses, + (summaryStateView as ContainerTreeViewType).blockRoots.get( + rootIndexInSummary, + ), + ); + yield { + keyIndex: keyWithWithdrawalInfo.keyIndex, + nodeOperatorId: keyWithWithdrawalInfo.operatorId, + beaconBlock: { + header: { + slot: finalHeader.header.message.slot, + proposerIndex: Number(finalHeader.header.message.proposer_index), + parentRoot: finalHeader.header.message.parent_root, + stateRoot: finalHeader.header.message.state_root, + bodyRoot: finalHeader.header.message.body_root, + }, + rootsTimestamp: nextToFinalizedHeaderTimestamp, + }, + oldBlock: { + header: { + slot: headerWithWds.header.message.slot, + proposerIndex: Number(headerWithWds.header.message.proposer_index), + parentRoot: headerWithWds.header.message.parent_root, + stateRoot: headerWithWds.header.message.state_root, + bodyRoot: headerWithWds.header.message.body_root, + }, + rootGIndex: Number(historicalStateProof.gindex), + proof: historicalStateProof.witnesses.map(toHex), + }, + witness: { + withdrawalOffset: Number(keyWithWithdrawalInfo.withdrawal.offset), + withdrawalIndex: Number(keyWithWithdrawalInfo.withdrawal.index), + validatorIndex: Number(keyWithWithdrawalInfo.withdrawal.validator_index), + amount: Number(keyWithWithdrawalInfo.withdrawal.amount), + withdrawalCredentials: toHex(validator.withdrawalCredentials), + effectiveBalance: validator.effectiveBalance, + slashed: Boolean(validator.slashed), + activationEligibilityEpoch: validator.activationEligibilityEpoch, + activationEpoch: validator.activationEpoch, + exitEpoch: validator.exitEpoch, + withdrawableEpoch: validator.withdrawableEpoch, + withdrawalProof: withdrawalProof.witnesses.map(toHex), + validatorProof: validatorProof.witnesses.map(toHex), + }, + }; + } + } + + private isHistoricalBlock(blockInfo: BlockInfoResponse, finalizedHeader: BlockHeaderResponse): boolean { + const finalizationBufferEpochs = 2; + const finalizationBufferSlots = this.consensus.epochToSlot(finalizationBufferEpochs); + return ( + Number(finalizedHeader.header.message.slot) - Number(blockInfo.message.slot) > + Number(this.consensus.beaconConfig.SLOTS_PER_HISTORICAL_ROOT) - finalizationBufferSlots + ); + } + + private calcSummaryIndex(blockInfo: BlockInfoResponse): number { + const capellaForkSlot = this.consensus.epochToSlot(Number(this.consensus.beaconConfig.CAPELLA_FORK_EPOCH)); + const slotsPerHistoricalRoot = Number(this.consensus.beaconConfig.SLOTS_PER_HISTORICAL_ROOT); + return Math.floor((Number(blockInfo.message.slot) - capellaForkSlot) / slotsPerHistoricalRoot); + } + + private calcSlotOfSummary(summaryIndex: number): number { + const capellaForkSlot = this.consensus.epochToSlot(Number(this.consensus.beaconConfig.CAPELLA_FORK_EPOCH)); + const slotsPerHistoricalRoot = Number(this.consensus.beaconConfig.SLOTS_PER_HISTORICAL_ROOT); + return capellaForkSlot + (summaryIndex + 1) * slotsPerHistoricalRoot; + } + + private calcRootIndexInSummary(blockInfo: BlockInfoResponse): number { + const slotsPerHistoricalRoot = Number(this.consensus.beaconConfig.SLOTS_PER_HISTORICAL_ROOT); + return Number(blockInfo.message.slot) % slotsPerHistoricalRoot; + } +} diff --git a/src/common/prover/helpers/proofs.ts b/src/common/prover/helpers/proofs.ts new file mode 100644 index 0000000..65c93f3 --- /dev/null +++ b/src/common/prover/helpers/proofs.ts @@ -0,0 +1,92 @@ +import { createHash } from 'node:crypto'; + +import { ProofType, SingleProof, concatGindices, createProof } from '@chainsafe/persistent-merkle-tree'; +import { ContainerTreeViewType } from '@chainsafe/ssz/lib/view/container'; + +let ssz: typeof import('@lodestar/types').ssz; +let anySsz: typeof ssz.phase0 | typeof ssz.altair | typeof ssz.bellatrix | typeof ssz.capella | typeof ssz.deneb; + +export function generateValidatorProof( + stateView: ContainerTreeViewType, + valIndex: number, +): SingleProof { + const gI = stateView.type.getPathInfo(['validators', Number(valIndex)]).gindex; + return createProof(stateView.node, { type: ProofType.single, gindex: gI }) as SingleProof; +} + +export function generateWithdrawalProof( + stateView: ContainerTreeViewType, + blockView: ContainerTreeViewType, + withdrawalOffset: number, +): SingleProof { + // NOTE: ugly hack to replace root with the value to make a proof + const patchedTree = (stateView as any).tree.clone(); + const stateWdGindex = stateView.type.getPathInfo(['latestExecutionPayloadHeader', 'withdrawalsRoot']).gindex; + patchedTree.setNode( + stateWdGindex, + (blockView as ContainerTreeViewType).body.executionPayload.withdrawals.node, + ); + const withdrawalGI = ( + blockView as ContainerTreeViewType + ).body.executionPayload.withdrawals.type.getPropertyGindex(withdrawalOffset) as bigint; + const gI = concatGindices([stateWdGindex, withdrawalGI]); + return createProof(patchedTree.rootNode, { + type: ProofType.single, + gindex: gI, + }) as SingleProof; +} + +export function generateHistoricalStateProof( + finalizedStateView: ContainerTreeViewType, + summaryStateView: ContainerTreeViewType, + summaryIndex: number, + rootIndex: number, +): SingleProof { + // NOTE: ugly hack to replace root with the value to make a proof + const patchedTree = (finalizedStateView as any).tree.clone(); + const blockSummaryRootGI = finalizedStateView.type.getPathInfo([ + 'historicalSummaries', + summaryIndex, + 'blockSummaryRoot', + ]).gindex; + patchedTree.setNode(blockSummaryRootGI, summaryStateView.blockRoots.node); + const blockRootsGI = summaryStateView.blockRoots.type.getPropertyGindex(rootIndex) as bigint; + const gI = concatGindices([blockSummaryRootGI, blockRootsGI]); + return createProof(patchedTree.rootNode, { + type: ProofType.single, + gindex: gI, + }) as SingleProof; +} + +// port of https://github.com/ethereum/go-ethereum/blob/master/beacon/merkle/merkle.go +export function verifyProof(root: Uint8Array, gI: bigint, proof: Uint8Array[], value: Uint8Array) { + let buf = value; + + proof.forEach((p) => { + const hasher = createHash('sha256'); + if (gI % 2n == 0n) { + hasher.update(buf); + hasher.update(p); + } else { + hasher.update(p); + hasher.update(buf); + } + buf = hasher.digest(); + gI >>= 1n; + if (gI == 0n) { + throw new Error('Branch has extra item'); + } + }); + + if (gI != 1n) { + throw new Error('Branch is missing items'); + } + + if (toHex(root) != toHex(buf)) { + throw new Error('Proof is not valid'); + } +} + +export function toHex(value: Uint8Array) { + return '0x' + Buffer.from(value).toString('hex'); +} diff --git a/src/common/prover/prover.module.ts b/src/common/prover/prover.module.ts new file mode 100644 index 0000000..6f3f711 --- /dev/null +++ b/src/common/prover/prover.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; + +import { SlashingsService } from './duties/slashings'; +import { WithdrawalsService } from './duties/withdrawals'; +import { ProverService } from './prover.service'; +import { ProvidersModule } from '../providers/providers.module'; + +@Module({ + imports: [ProvidersModule], + providers: [ProverService, SlashingsService, WithdrawalsService], + exports: [ProverService], +}) +export class ProverModule {} diff --git a/src/common/handlers/handlers.service.spec.ts b/src/common/prover/prover.service.spec.ts similarity index 61% rename from src/common/handlers/handlers.service.spec.ts rename to src/common/prover/prover.service.spec.ts index 5f1b4a5..ef6a756 100644 --- a/src/common/handlers/handlers.service.spec.ts +++ b/src/common/prover/prover.service.spec.ts @@ -1,16 +1,16 @@ import { Test, TestingModule } from '@nestjs/testing'; -import { HandlersService } from './handlers.service'; +import { ProverService } from './prover.service'; describe('HandlersService', () => { - let service: HandlersService; + let service: ProverService; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ - providers: [HandlersService], + providers: [ProverService], }).compile(); - service = module.get(HandlersService); + service = module.get(ProverService); }); it('should be defined', () => { diff --git a/src/common/prover/prover.service.ts b/src/common/prover/prover.service.ts new file mode 100644 index 0000000..ffd040e --- /dev/null +++ b/src/common/prover/prover.service.ts @@ -0,0 +1,37 @@ +import { LOGGER_PROVIDER } from '@lido-nestjs/logger'; +import { Inject, Injectable, LoggerService } from '@nestjs/common'; + +import { SlashingsService } from './duties/slashings'; +import { WithdrawalsService } from './duties/withdrawals'; +import { KeyInfoFn } from './types'; +import { Consensus } from '../providers/consensus/consensus'; +import { BlockInfoResponse, RootHex } from '../providers/consensus/response.interface'; + +@Injectable() +export class ProverService { + constructor( + @Inject(LOGGER_PROVIDER) protected readonly logger: LoggerService, + protected readonly consensus: Consensus, + protected readonly withdrawals: WithdrawalsService, + protected readonly slashings: SlashingsService, + ) {} + + public async handleBlock( + blockRoot: RootHex, + blockInfo: BlockInfoResponse, + finalizedBlockRoot: RootHex, + keyInfoFn: KeyInfoFn, + ): Promise { + const slashings = await this.slashings.getUnprovenSlashings(blockInfo, keyInfoFn); + const withdrawals = await this.withdrawals.getUnprovenWithdrawals(blockInfo, keyInfoFn); + if (!Object.keys(slashings).length && !Object.keys(withdrawals).length) { + this.logger.log('Nothing to prove'); + return; + } + const finalizedHeader = await this.consensus.getBeaconHeader(finalizedBlockRoot); + // do it consistently because of the high resource usage (both the app and CL node) + await this.slashings.sendSlashingProves(finalizedHeader, slashings); + await this.withdrawals.sendWithdrawalProves(blockRoot, blockInfo, finalizedHeader, withdrawals); + this.logger.log('🏁 Prove(s) sent'); + } +} diff --git a/src/common/prover/types.ts b/src/common/prover/types.ts new file mode 100644 index 0000000..6e34936 --- /dev/null +++ b/src/common/prover/types.ts @@ -0,0 +1,75 @@ +export interface KeyInfo { + operatorId: number; + keyIndex: number; + pubKey: string; +} + +export type KeyInfoFn = (valIndex: number) => KeyInfo | undefined; + +export type WithdrawalsGeneralProvePayload = { + beaconBlock: ProvableBeaconBlockHeader; + witness: WithdrawalWitness; + nodeOperatorId: number; + keyIndex: number; +}; + +export type WithdrawalsHistoricalProvePayload = { + beaconBlock: ProvableBeaconBlockHeader; + oldBlock: HistoricalHeaderWitness; + witness: WithdrawalWitness; + nodeOperatorId: number; + keyIndex: number; +}; + +export type SlashingProvePayload = { + beaconBlock: ProvableBeaconBlockHeader; + witness: SlashingWitness; + nodeOperatorId: number; + keyIndex: number; +}; + +export type ProvableBeaconBlockHeader = { + header: BeaconBlockHeader; + rootsTimestamp: number; +}; + +export type HistoricalHeaderWitness = { + header: BeaconBlockHeader; + rootGIndex: number; + proof: string[]; // bytes32[] +}; + +export type BeaconBlockHeader = { + slot: number; + proposerIndex: number; + parentRoot: string; // bytes32 + stateRoot: string; // bytes32 + bodyRoot: string; // bytes32 +}; + +export type SlashingWitness = { + validatorIndex: number; + withdrawalCredentials: string; // bytes32 + effectiveBalance: number; + activationEligibilityEpoch: number; + activationEpoch: number; + exitEpoch: number; + withdrawableEpoch: number; + validatorProof: string[]; // bytes32[] +}; + +export type WithdrawalWitness = { + withdrawalOffset: number; + withdrawalIndex: number; + validatorIndex: number; + amount: number; + withdrawalCredentials: string; // bytes32 + effectiveBalance: number; + slashed: boolean; + activationEligibilityEpoch: number; + activationEpoch: number; + exitEpoch: number; + withdrawableEpoch: number; + withdrawalProof: string[]; // bytes32[] + validatorProof: string[]; // bytes32[] +}; diff --git a/src/common/providers/base/rest-provider.ts b/src/common/providers/base/rest-provider.ts index 1f46fb5..e6611ac 100644 --- a/src/common/providers/base/rest-provider.ts +++ b/src/common/providers/base/rest-provider.ts @@ -12,7 +12,6 @@ export interface RequestPolicy { } export interface RequestOptions { - streamed?: boolean; requestPolicy?: RequestPolicy; signal?: AbortSignal; headers?: Record; @@ -42,49 +41,12 @@ export abstract class BaseRestProvider { // 2. retries // 3. fallbacks - protected async baseJsonGet(base: string, endpoint: string, options?: RequestOptions): Promise { - return (await this.baseGet(base, endpoint, { ...options, streamed: false })) as T; - } - - protected async baseStreamedGet( + protected async baseGet( base: string, endpoint: string, options?: RequestOptions, ): Promise<{ body: BodyReadable; headers: IncomingHttpHeaders }> { - return (await this.baseGet(base, endpoint, { ...options, streamed: true })) as { - body: BodyReadable; - headers: IncomingHttpHeaders; - }; - } - - protected async baseJsonPost( - base: string, - endpoint: string, - requestBody: any, - options?: RequestOptions, - ): Promise { - return (await this.basePost(base, endpoint, requestBody, { ...options, streamed: false })) as T; - } - - protected async baseStreamedPost( - base: string, - endpoint: string, - requestBody: any, - options?: RequestOptions, - ): Promise<{ body: BodyReadable; headers: IncomingHttpHeaders }> { - return (await this.basePost(base, endpoint, requestBody, { ...options, streamed: true })) as { - body: BodyReadable; - headers: IncomingHttpHeaders; - }; - } - - private async baseGet( - base: string, - endpoint: string, - options?: RequestOptions, - ): Promise { options = { - streamed: false, requestPolicy: this.requestPolicy, ...options, } as RequestOptions; @@ -98,17 +60,16 @@ export abstract class BaseRestProvider { const hostname = new URL(base).hostname; throw new Error(`Request failed with status code [${statusCode}] on host [${hostname}]: ${endpoint}`); } - return options.streamed ? { body: body, headers: headers } : ((await body.json()) as T); + return { body: body, headers: headers }; } - private async basePost( + protected async basePost( base: string, endpoint: string, requestBody: any, options?: RequestOptions, - ): Promise { + ): Promise<{ body: BodyReadable; headers: IncomingHttpHeaders }> { options = { - streamed: false, requestPolicy: this.requestPolicy, ...options, } as RequestOptions; @@ -125,6 +86,6 @@ export abstract class BaseRestProvider { const hostname = new URL(base).hostname; throw new Error(`Request failed with status code [${statusCode}] on host [${hostname}]: ${endpoint}`); } - return options.streamed ? { body: body, headers: headers } : ((await body.json()) as T); + return { body: body, headers: headers }; } } diff --git a/src/common/providers/consensus/consensus.ts b/src/common/providers/consensus/consensus.ts index a2ab138..ae09157 100644 --- a/src/common/providers/consensus/consensus.ts +++ b/src/common/providers/consensus/consensus.ts @@ -1,7 +1,9 @@ +import { ContainerTreeViewType } from '@chainsafe/ssz/lib/view/container'; import { LOGGER_PROVIDER } from '@lido-nestjs/logger'; import { Inject, Injectable, LoggerService, OnModuleInit, Optional } from '@nestjs/common'; import { + BeaconConfig, BlockHeaderResponse, BlockId, BlockInfoResponse, @@ -15,11 +17,13 @@ import { DownloadProgress } from '../../utils/download-progress/download-progres import { BaseRestProvider } from '../base/rest-provider'; let ssz: typeof import('@lodestar/types').ssz; +let anySsz: typeof ssz.phase0 | typeof ssz.altair | typeof ssz.bellatrix | typeof ssz.capella | typeof ssz.deneb; let ForkName: typeof import('@lodestar/params').ForkName; @Injectable() export class Consensus extends BaseRestProvider implements OnModuleInit { private readonly endpoints = { + config: 'eth/v1/config/spec', version: 'eth/v1/node/version', genesis: 'eth/v1/beacon/genesis', blockInfo: (blockId: BlockId): string => `eth/v2/beacon/blocks/${blockId}`, @@ -30,9 +34,7 @@ export class Consensus extends BaseRestProvider implements OnModuleInit { }; public genesisTimestamp: number; - // TODO: configurable - public SLOTS_PER_EPOCH: number = 32; - public SECONDS_PER_SLOT: number = 12; + public beaconConfig: BeaconConfig; constructor( @Inject(LOGGER_PROVIDER) protected readonly logger: LoggerService, @@ -53,53 +55,85 @@ export class Consensus extends BaseRestProvider implements OnModuleInit { // ugly hack to import ESModule to CommonJS project ssz = await eval(`import('@lodestar/types').then((m) => m.ssz)`); this.logger.log(`Getting genesis timestamp`); - const resp = await this.getGenesis(); - this.genesisTimestamp = Number(resp.genesis_time); + const genesis = await this.getGenesis(); + this.genesisTimestamp = Number(genesis.genesis_time); + this.beaconConfig = await this.getConfig(); } public slotToTimestamp(slot: number): number { - return this.genesisTimestamp + slot * this.SECONDS_PER_SLOT; + return this.genesisTimestamp + slot * Number(this.beaconConfig.SECONDS_PER_SLOT); + } + + public epochToSlot(epoch: number): number { + return epoch * Number(this.beaconConfig.SLOTS_PER_EPOCH); + } + + public slotToEpoch(slot: number): number { + return Math.floor(slot / Number(this.beaconConfig.SLOTS_PER_EPOCH)); + } + + public async getConfig(): Promise { + const { body } = await this.baseGet(this.mainUrl, this.endpoints.config); + const jsonBody = (await body.json()) as { data: BeaconConfig }; + return jsonBody.data; } public async getGenesis(): Promise { - const resp = await this.baseJsonGet<{ data: GenesisResponse }>(this.mainUrl, this.endpoints.genesis); - return resp.data; + const { body } = await this.baseGet(this.mainUrl, this.endpoints.genesis); + const jsonBody = (await body.json()) as { data: GenesisResponse }; + return jsonBody.data; } public async getBlockInfo(blockId: BlockId): Promise { - const resp = await this.baseJsonGet<{ data: BlockInfoResponse }>(this.mainUrl, this.endpoints.blockInfo(blockId)); - return resp.data; + const { body } = await this.baseGet(this.mainUrl, this.endpoints.blockInfo(blockId)); + const jsonBody = (await body.json()) as { data: BlockInfoResponse }; + return jsonBody.data; } public async getBeaconHeader(blockId: BlockId): Promise { - const resp = await this.baseJsonGet<{ data: BlockHeaderResponse }>( - this.mainUrl, - this.endpoints.beaconHeader(blockId), - ); - return resp.data; + const { body } = await this.baseGet(this.mainUrl, this.endpoints.beaconHeader(blockId)); + const jsonBody = (await body.json()) as { data: BlockHeaderResponse }; + return jsonBody.data; } public async getBeaconHeadersByParentRoot( parentRoot: RootHex, ): Promise<{ finalized: boolean; data: BlockHeaderResponse[] }> { - return await this.baseJsonGet<{ finalized: boolean; data: BlockHeaderResponse[] }>( - this.mainUrl, - this.endpoints.beaconHeadersByParentRoot(parentRoot), - ); + const { body } = await this.baseGet(this.mainUrl, this.endpoints.beaconHeadersByParentRoot(parentRoot)); + return (await body.json()) as { finalized: boolean; data: BlockHeaderResponse[] }; } - public async getStateView(stateId: StateId, signal?: AbortSignal) { - const { body, headers } = await this.baseStreamedGet(this.mainUrl, this.endpoints.state(stateId), { + public async getState( + stateId: StateId, + signal?: AbortSignal, + ): Promise<{ bodyBytes: Uint8Array; forkName: keyof typeof ForkName }> { + const { body, headers } = await this.baseGet(this.mainUrl, this.endpoints.state(stateId), { signal, headers: { accept: 'application/octet-stream' }, }); - const version = headers['eth-consensus-version'] as keyof typeof ForkName; + const forkName = headers['eth-consensus-version'] as keyof typeof ForkName; // Progress bar // TODO: Enable for CLI only //this.progress.show(`State [${stateId}]`, resp); - // Data processing - const bodyBites = new Uint8Array(await body.arrayBuffer()); - // TODO: high memory usage - return ssz[version].BeaconState.deserializeToView(bodyBites); + const bodyBytes = new Uint8Array(await body.arrayBuffer()); + return { bodyBytes, forkName }; + } + + public stateToView( + bodyBytes: Uint8Array, + forkName: keyof typeof ForkName, + ): ContainerTreeViewType { + return ssz[forkName].BeaconState.deserializeToView(bodyBytes) as ContainerTreeViewType< + typeof anySsz.BeaconState.fields + >; + } + + public blockToView( + body: BlockInfoResponse, + forkName: keyof typeof ForkName, + ): ContainerTreeViewType { + return ssz[forkName].BeaconBlock.toView(anySsz.BeaconBlock.fromJson(body.message) as any) as ContainerTreeViewType< + typeof anySsz.BeaconBlock.fields + >; } } diff --git a/src/common/providers/consensus/response.interface.ts b/src/common/providers/consensus/response.interface.ts index 31e7667..8ae3d25 100644 --- a/src/common/providers/consensus/response.interface.ts +++ b/src/common/providers/consensus/response.interface.ts @@ -90,6 +90,16 @@ export interface GenesisResponse { genesis_fork_version: string; } +export interface BeaconConfig { + SLOTS_PER_EPOCH: string; + SECONDS_PER_SLOT: string; + CAPELLA_FORK_EPOCH: string; + ETH1_FOLLOW_DISTANCE: string; + EPOCHS_PER_ETH1_VOTING_PERIOD: string; + SLOTS_PER_HISTORICAL_ROOT: string; + MIN_VALIDATOR_WITHDRAWABILITY_DELAY: string; +} + export interface BeaconBlockAttestation { aggregation_bits: string; data: { diff --git a/src/common/providers/keysapi/keysapi.ts b/src/common/providers/keysapi/keysapi.ts index 4c0bd66..be279d9 100644 --- a/src/common/providers/keysapi/keysapi.ts +++ b/src/common/providers/keysapi/keysapi.ts @@ -42,15 +42,17 @@ export class Keysapi extends BaseRestProvider { } public async getStatus(): Promise { - return await this.baseJsonGet(this.mainUrl, this.endpoints.status); + const { body } = await this.baseGet(this.mainUrl, this.endpoints.status); + return (await body.json()) as Status; } public async getModules(): Promise { - return await this.baseJsonGet(this.mainUrl, this.endpoints.modules); + const { body } = await this.baseGet(this.mainUrl, this.endpoints.modules); + return (await body.json()) as Modules; } public async getModuleKeys(module_id: string | number, signal?: AbortSignal): Promise { - const resp = await this.baseStreamedGet(this.mainUrl, this.endpoints.moduleKeys(module_id), { + const resp = await this.baseGet(this.mainUrl, this.endpoints.moduleKeys(module_id), { signal, }); // TODO: ignore depositSignature ? @@ -65,9 +67,10 @@ export class Keysapi extends BaseRestProvider { keysToFind: string[], signal?: AbortSignal, ): Promise { - return await this.baseJsonPost(this.mainUrl, this.endpoints.findModuleKeys(module_id), { + const { body } = await this.basePost(this.mainUrl, this.endpoints.findModuleKeys(module_id), { pubkeys: keysToFind, signal, }); + return (await body.json()) as ModuleKeysFind; } } diff --git a/src/daemon/daemon.module.ts b/src/daemon/daemon.module.ts index eddb16a..0fa0947 100644 --- a/src/daemon/daemon.module.ts +++ b/src/daemon/daemon.module.ts @@ -6,13 +6,13 @@ import { RootsProcessor } from './services/roots-processor'; import { RootsProvider } from './services/roots-provider'; import { RootsStack } from './services/roots-stack'; import { ConfigModule } from '../common/config/config.module'; -import { HandlersModule } from '../common/handlers/handlers.module'; import { LoggerModule } from '../common/logger/logger.module'; import { PrometheusModule } from '../common/prometheus/prometheus.module'; +import { ProverModule } from '../common/prover/prover.module'; import { ProvidersModule } from '../common/providers/providers.module'; @Module({ - imports: [LoggerModule, ConfigModule, PrometheusModule, ProvidersModule, HandlersModule], + imports: [LoggerModule, ConfigModule, PrometheusModule, ProvidersModule, ProverModule], providers: [DaemonService, KeysIndexer, RootsProvider, RootsProcessor, RootsStack], exports: [DaemonService], }) diff --git a/src/daemon/daemon.service.ts b/src/daemon/daemon.service.ts index eb58ff9..d08e252 100644 --- a/src/daemon/daemon.service.ts +++ b/src/daemon/daemon.service.ts @@ -41,7 +41,7 @@ export class DaemonService implements OnApplicationBootstrap { this.keysIndexer.update(header); const nextRoot = await this.rootsProvider.getNext(header); if (nextRoot) { - await this.rootsProcessor.process(nextRoot); + await this.rootsProcessor.process(nextRoot, header.root); return; } this.logger.log(`💤 Wait for the next finalized root`); diff --git a/src/daemon/services/keys-indexer.ts b/src/daemon/services/keys-indexer.ts index 52ae4ec..c45baed 100644 --- a/src/daemon/services/keys-indexer.ts +++ b/src/daemon/services/keys-indexer.ts @@ -7,7 +7,8 @@ import { LOGGER_PROVIDER } from '@lido-nestjs/logger'; import { Inject, Injectable, LoggerService, OnModuleInit } from '@nestjs/common'; import { ConfigService } from '../../common/config/config.service'; -import { KeyInfo } from '../../common/handlers/handlers.service'; +import { toHex } from '../../common/prover/helpers/proofs'; +import { KeyInfo } from '../../common/prover/types'; import { Consensus } from '../../common/providers/consensus/consensus'; import { BlockHeaderResponse, RootHex, Slot } from '../../common/providers/consensus/response.interface'; import { Keysapi } from '../../common/providers/keysapi/keysapi'; @@ -96,7 +97,8 @@ export class KeysIndexer implements OnModuleInit { ): Promise { this.logger.log(`🔑 Keys indexer is running`); this.logger.log(`Get validators. State root [${stateRoot}]`); - const stateView = await this.consensus.getStateView(stateRoot); + const state = await this.consensus.getState(stateRoot); + const stateView = this.consensus.stateToView(state.bodyBytes, state.forkName); this.logger.log(`Total validators count: ${stateView.validators.length}`); // TODO: do we need to store already full withdrawn keys ? await stateDataProcessingCallback(stateView.validators, finalizedSlot); @@ -138,10 +140,9 @@ export class KeysIndexer implements OnModuleInit { private isTrustedForSlashings(slotNumber: Slot): boolean { // We are ok with outdated indexer for detection slashing // because of a bunch of delays between deposit and validator appearing - // TODO: get constants from node - const ETH1_FOLLOW_DISTANCE = 2048; // ~8 hours - const EPOCHS_PER_ETH1_VOTING_PERIOD = 64; // ~6.8 hours - const safeDelay = ETH1_FOLLOW_DISTANCE + EPOCHS_PER_ETH1_VOTING_PERIOD * 32; + const ETH1_FOLLOW_DISTANCE = Number(this.consensus.beaconConfig.ETH1_FOLLOW_DISTANCE); // ~8 hours + const EPOCHS_PER_ETH1_VOTING_PERIOD = Number(this.consensus.beaconConfig.EPOCHS_PER_ETH1_VOTING_PERIOD); // ~6.8 hours + const safeDelay = ETH1_FOLLOW_DISTANCE + this.consensus.epochToSlot(EPOCHS_PER_ETH1_VOTING_PERIOD); if (this.info.data.storageStateSlot >= slotNumber) return true; return slotNumber - this.info.data.storageStateSlot <= safeDelay; // ~14.8 hours } @@ -149,9 +150,8 @@ export class KeysIndexer implements OnModuleInit { private isTrustedForFullWithdrawals(slotNumber: Slot): boolean { // We are ok with outdated indexer for detection withdrawal // because of MIN_VALIDATOR_WITHDRAWABILITY_DELAY - // TODO: get constants from node - const MIN_VALIDATOR_WITHDRAWABILITY_DELAY = 256; - const safeDelay = MIN_VALIDATOR_WITHDRAWABILITY_DELAY * 32; + const MIN_VALIDATOR_WITHDRAWABILITY_DELAY = Number(this.consensus.beaconConfig.MIN_VALIDATOR_WITHDRAWABILITY_DELAY); + const safeDelay = this.consensus.epochToSlot(MIN_VALIDATOR_WITHDRAWABILITY_DELAY); if (this.info.data.storageStateSlot >= slotNumber) return true; return slotNumber - this.info.data.storageStateSlot <= safeDelay; // ~27 hours } @@ -209,15 +209,13 @@ export class KeysIndexer implements OnModuleInit { for (let i = 0; i < validators.length; i++) { const node = iterator.next().value; const v = node.value; - const pubKey = '0x'.concat(Buffer.from(v.pubkey).toString('hex')); + const pubKey = toHex(v.pubkey); const keyInfo = keysMap.get(pubKey); if (!keyInfo) continue; this.storage.data[i] = { operatorId: keyInfo.operatorIndex, keyIndex: keyInfo.index, pubKey: pubKey, - // TODO: bigint? - withdrawableEpoch: v.withdrawableEpoch, }; } }; @@ -238,12 +236,10 @@ export class KeysIndexer implements OnModuleInit { validators.length, ); const valKeys = []; - const valWithdrawableEpochs = []; for (let i = this.info.data.lastValidatorsCount - 1; i < validators.length; i++) { const node = iterator.next().value; const v = validators.type.elementType.tree_toValue(node); - valKeys.push('0x'.concat(Buffer.from(v.pubkey).toString('hex'))); - valWithdrawableEpochs.push(v.withdrawableEpoch); + valKeys.push(toHex(v.pubkey)); } // TODO: can be better const csmKeys = await this.keysapi.findModuleKeys(this.info.data.moduleId, valKeys); @@ -257,8 +253,6 @@ export class KeysIndexer implements OnModuleInit { operatorId: csmKey.operatorIndex, keyIndex: csmKey.index, pubKey: csmKey.key, - // TODO: bigint? - withdrawableEpoch: valWithdrawableEpochs[i], }; } } diff --git a/src/daemon/services/roots-processor.ts b/src/daemon/services/roots-processor.ts index 32991d0..2a9ebc2 100644 --- a/src/daemon/services/roots-processor.ts +++ b/src/daemon/services/roots-processor.ts @@ -3,7 +3,7 @@ import { Inject, Injectable, LoggerService } from '@nestjs/common'; import { KeysIndexer } from './keys-indexer'; import { RootSlot, RootsStack } from './roots-stack'; -import { HandlersService } from '../../common/handlers/handlers.service'; +import { ProverService } from '../../common/prover/prover.service'; import { Consensus } from '../../common/providers/consensus/consensus'; import { RootHex } from '../../common/providers/consensus/response.interface'; @@ -14,19 +14,20 @@ export class RootsProcessor { protected readonly consensus: Consensus, protected readonly keysIndexer: KeysIndexer, protected readonly rootsStack: RootsStack, - protected readonly handlers: HandlersService, + protected readonly prover: ProverService, ) {} - public async process(blockRoot: RootHex): Promise { - this.logger.log(`🛃 Root in processing [${blockRoot}]`); - const blockInfo = await this.consensus.getBlockInfo(blockRoot); + public async process(blockRootToProcess: RootHex, finalizedRoot: RootHex): Promise { + this.logger.log(`🛃 Root in processing [${blockRootToProcess}]`); + const blockInfoToProcess = await this.consensus.getBlockInfo(blockRootToProcess); const rootSlot: RootSlot = { - blockRoot, - slotNumber: Number(blockInfo.message.slot), + blockRoot: blockRootToProcess, + slotNumber: Number(blockInfoToProcess.message.slot), }; const indexerIsTrusted = this.keysIndexer.isTrustedForEveryDuty(rootSlot.slotNumber); if (!indexerIsTrusted) await this.rootsStack.push(rootSlot); // only new will be pushed - await this.handlers.proveIfNeeded(blockRoot, blockInfo, this.keysIndexer.getKey); + // prove slashings or withdrawals if needed + await this.prover.handleBlock(blockRootToProcess, blockInfoToProcess, finalizedRoot, this.keysIndexer.getKey); if (indexerIsTrusted) await this.rootsStack.purge(rootSlot); await this.rootsStack.setLastProcessed(rootSlot); }