diff --git a/src/common/prometheus/prometheus.provider.ts b/src/common/prometheus/prometheus.provider.ts index 508e0a12..e28dbc7c 100644 --- a/src/common/prometheus/prometheus.provider.ts +++ b/src/common/prometheus/prometheus.provider.ts @@ -66,25 +66,25 @@ export const PrometheusBuildInfoGaugeProvider = makeCounterProvider({ export const PrometheusValidatedDepositsProvider = makeGaugeProvider({ name: METRIC_VALIDATED_DEPOSITS_TOTAL, help: 'Number of deposits by validation', - labelNames: ['type'] as const, + labelNames: ['type', 'stakingModuleId'] as const, }); export const PrometheusIntersectionsProvider = makeGaugeProvider({ name: METRIC_INTERSECTIONS_TOTAL, help: 'Number of keys intersections', - labelNames: ['type'] as const, + labelNames: ['type', 'stakingModuleId'] as const, }); export const PrometheusDepositedKeysProvider = makeGaugeProvider({ name: METRIC_DEPOSITED_KEYS_TOTAL, help: 'Number of keys in the deposit contract', - labelNames: ['type'] as const, + labelNames: ['type', 'stakingModuleId'] as const, }); export const PrometheusOperatorsKeysProvider = makeGaugeProvider({ name: METRIC_OPERATORS_KEYS_TOTAL, help: 'Number of node operators keys', - labelNames: ['type'] as const, + labelNames: ['type', 'stakingModuleId'] as const, }); export const PrometheusKeysApiRequestsProvider = makeHistogramProvider({ diff --git a/src/contracts/deposit/deposit.service.ts b/src/contracts/deposit/deposit.service.ts index 605809e9..0619ba03 100644 --- a/src/contracts/deposit/deposit.service.ts +++ b/src/contracts/deposit/deposit.service.ts @@ -33,7 +33,7 @@ export class DepositService { ) {} @OneAtTime() - public async handleNewBlock({ blockNumber }: BlockData): Promise { + public async handleNewBlock(blockNumber: number): Promise { if (blockNumber % DEPOSIT_EVENTS_CACHE_UPDATE_BLOCK_RATE !== 0) return; // The event cache is stored with an N block lag to avoid caching data from uncle blocks diff --git a/src/guardian/block-guard/block-guard.module.ts b/src/guardian/block-guard/block-guard.module.ts new file mode 100644 index 00000000..bd7bb4ec --- /dev/null +++ b/src/guardian/block-guard/block-guard.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { DepositModule } from 'contracts/deposit'; +import { SecurityModule } from 'contracts/security'; +import { BlockGuardService } from './block-guard.service'; + +@Module({ + imports: [DepositModule, SecurityModule], + providers: [BlockGuardService], + exports: [BlockGuardService], +}) +export class BlockGuardModule {} diff --git a/src/guardian/block-guard/block-guard.service.ts b/src/guardian/block-guard/block-guard.service.ts new file mode 100644 index 00000000..fafbd72c --- /dev/null +++ b/src/guardian/block-guard/block-guard.service.ts @@ -0,0 +1,93 @@ +import { Inject, Injectable, LoggerService } from '@nestjs/common'; +import { WINSTON_MODULE_NEST_PROVIDER } from 'nest-winston'; + +import { DepositService } from 'contracts/deposit'; +import { SecurityService } from 'contracts/security'; + +import { BlockData } from '../interfaces'; + +import { InjectMetric } from '@willsoto/nestjs-prometheus'; +import { + METRIC_BLOCK_DATA_REQUEST_DURATION, + METRIC_BLOCK_DATA_REQUEST_ERRORS, +} from 'common/prometheus'; +import { Counter, Histogram } from 'prom-client'; + +@Injectable() +export class BlockGuardService { + protected lastProcessedStateMeta?: { blockHash: string; blockNumber: number }; + + constructor( + @Inject(WINSTON_MODULE_NEST_PROVIDER) + private logger: LoggerService, + + @InjectMetric(METRIC_BLOCK_DATA_REQUEST_DURATION) + private blockRequestsHistogram: Histogram, + + @InjectMetric(METRIC_BLOCK_DATA_REQUEST_ERRORS) + private blockErrorsCounter: Counter, + + private depositService: DepositService, + private securityService: SecurityService, + ) {} + + public isNeedToProcessNewState(newMeta: { + blockHash: string; + blockNumber: number; + }) { + const lastMeta = this.lastProcessedStateMeta; + if (!lastMeta) return true; + if (lastMeta.blockNumber > newMeta.blockNumber) { + this.logger.error('Keys-api returns old state', newMeta); + return false; + } + return lastMeta.blockHash !== newMeta.blockHash; + } + + public setLastProcessedStateMeta(newMeta: { + blockHash: string; + blockNumber: number; + }) { + this.lastProcessedStateMeta = newMeta; + } + + /** + * Collects data from contracts in one place and by block hash, + * to reduce the probability of getting data from different blocks + * @returns collected data from the current block + */ + public async getCurrentBlockData({ + blockNumber, + blockHash, + }: { + blockNumber: number; + blockHash: string; + }): Promise { + try { + const endTimer = this.blockRequestsHistogram.startTimer(); + + const guardianAddress = this.securityService.getGuardianAddress(); + + const [depositRoot, depositedEvents, guardianIndex] = await Promise.all([ + this.depositService.getDepositRoot({ blockHash }), + this.depositService.getAllDepositedEvents(blockNumber, blockHash), + this.securityService.getGuardianIndex({ blockHash }), + this.depositService.handleNewBlock(blockNumber), + ]); + + endTimer(); + + return { + blockNumber, + blockHash, + depositRoot, + depositedEvents, + guardianAddress, + guardianIndex, + }; + } catch (error) { + this.blockErrorsCounter.inc(); + throw error; + } + } +} diff --git a/src/guardian/block-guard/index.ts b/src/guardian/block-guard/index.ts new file mode 100644 index 00000000..78e3eaf6 --- /dev/null +++ b/src/guardian/block-guard/index.ts @@ -0,0 +1,2 @@ +export * from './block-guard.module'; +export * from './block-guard.service'; diff --git a/src/guardian/guardian-message/guardian-message.module.ts b/src/guardian/guardian-message/guardian-message.module.ts new file mode 100644 index 00000000..b7a2f2aa --- /dev/null +++ b/src/guardian/guardian-message/guardian-message.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { MessagesModule } from 'messages'; +import { GuardianMessageService } from './guardian-message.service'; + +@Module({ + imports: [MessagesModule], + providers: [GuardianMessageService], + exports: [GuardianMessageService], +}) +export class GuardianMessageModule {} diff --git a/src/guardian/guardian-message/guardian-message.service.ts b/src/guardian/guardian-message/guardian-message.service.ts new file mode 100644 index 00000000..a27caece --- /dev/null +++ b/src/guardian/guardian-message/guardian-message.service.ts @@ -0,0 +1,95 @@ +import { Inject, Injectable, LoggerService } from '@nestjs/common'; +import { WINSTON_MODULE_NEST_PROVIDER } from 'nest-winston'; +import { + MessageDeposit, + MessageMeta, + MessagePause, + MessageRequiredFields, + MessagesService, + MessageType, +} from 'messages'; +import { BlockData, StakingModuleData } from '../interfaces'; +import { APP_NAME, APP_VERSION } from 'app.constants'; + +@Injectable() +export class GuardianMessageService { + constructor( + @Inject(WINSTON_MODULE_NEST_PROVIDER) + private logger: LoggerService, + private messagesService: MessagesService, + ) {} + + /** + * Sends a ping message to the message broker + * @param blockData - collected data from the current block + */ + public async pingMessageBroker( + { stakingModuleId }: StakingModuleData, + blockData: BlockData, + ): Promise { + const { blockNumber, guardianIndex, guardianAddress } = blockData; + + await this.sendMessageFromGuardian({ + type: MessageType.PING, + blockNumber, + guardianIndex, + guardianAddress, + stakingModuleId, + }); + } + + /** + * Sends a deposit message to the message broker + * @param message - MessageDeposit object + */ + public sendDepositMessage(message: Omit) { + return this.sendMessageFromGuardian({ + ...message, + type: MessageType.DEPOSIT, + }); + } + + /** + * Sends a pause message to the message broker + * @param message - MessagePause object + */ + public sendPauseMessage(message: Omit) { + return this.sendMessageFromGuardian({ + ...message, + type: MessageType.PAUSE, + }); + } + + /** + * Adds information about the app to the message + * @param message - message object + * @returns extended message + */ + public addMessageMetaData(message: T): T & MessageMeta { + return { + ...message, + app: { version: APP_VERSION, name: APP_NAME }, + }; + } + + /** + * Sends a message to the message broker from the guardian + * @param messageData - message object + */ + public async sendMessageFromGuardian( + messageData: T, + ): Promise { + if (messageData.guardianIndex == -1) { + this.logger.warn( + 'Your address is not in the Guardian List. The message will not be sent', + ); + + return; + } + + const messageWithMeta = this.addMessageMetaData(messageData); + + this.logger.log('Sending a message to broker', messageData); + await this.messagesService.sendMessage(messageWithMeta); + } +} diff --git a/src/guardian/guardian-message/index.ts b/src/guardian/guardian-message/index.ts new file mode 100644 index 00000000..9ddb582a --- /dev/null +++ b/src/guardian/guardian-message/index.ts @@ -0,0 +1,2 @@ +export * from './guardian-message.module'; +export * from './guardian-message.service'; diff --git a/src/guardian/guardian-metrics/guardian-metrics.module.ts b/src/guardian/guardian-metrics/guardian-metrics.module.ts new file mode 100644 index 00000000..ec941e5d --- /dev/null +++ b/src/guardian/guardian-metrics/guardian-metrics.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { GuardianMetricsService } from './guardian-metrics.service'; + +@Module({ + imports: [], + providers: [GuardianMetricsService], + exports: [GuardianMetricsService], +}) +export class GuardianMetricsModule {} diff --git a/src/guardian/guardian-metrics/guardian-metrics.service.ts b/src/guardian/guardian-metrics/guardian-metrics.service.ts new file mode 100644 index 00000000..c34b5034 --- /dev/null +++ b/src/guardian/guardian-metrics/guardian-metrics.service.ts @@ -0,0 +1,125 @@ +import { Injectable } from '@nestjs/common'; +import { VerifiedDepositEvent } from 'contracts/deposit'; +import { BlockData, StakingModuleData } from '../interfaces'; +import { InjectMetric } from '@willsoto/nestjs-prometheus'; +import { + METRIC_VALIDATED_DEPOSITS_TOTAL, + METRIC_DEPOSITED_KEYS_TOTAL, + METRIC_OPERATORS_KEYS_TOTAL, + METRIC_INTERSECTIONS_TOTAL, +} from 'common/prometheus'; +import { Gauge } from 'prom-client'; + +@Injectable() +export class GuardianMetricsService { + constructor( + @InjectMetric(METRIC_VALIDATED_DEPOSITS_TOTAL) + private validatedDepositsCounter: Gauge, + + @InjectMetric(METRIC_DEPOSITED_KEYS_TOTAL) + private depositedKeysCounter: Gauge, + + @InjectMetric(METRIC_OPERATORS_KEYS_TOTAL) + private operatorsKeysCounter: Gauge, + + @InjectMetric(METRIC_INTERSECTIONS_TOTAL) + private intersectionsCounter: Gauge, + ) {} + + /** + * Collects metrics about keys in the deposit contract and keys of node operators + * @param blockData - collected data from the current block + */ + public collectMetrics( + stakingModuleData: StakingModuleData, + blockData: BlockData, + ): void { + this.collectValidatingMetrics(stakingModuleData, blockData); + this.collectDepositMetrics(stakingModuleData, blockData); + this.collectOperatorMetrics(stakingModuleData); + } + + /** + * Collects metrics about validated deposits + * @param blockData - collected data from the current block + */ + public collectValidatingMetrics( + { stakingModuleId }: StakingModuleData, + blockData: BlockData, + ): void { + const { depositedEvents } = blockData; + const { events } = depositedEvents; + + const valid = events.reduce((sum, { valid }) => sum + (valid ? 1 : 0), 0); + const invalid = events.reduce((sum, { valid }) => sum + (valid ? 0 : 1), 0); + + this.validatedDepositsCounter.set( + { type: 'valid', stakingModuleId }, + valid, + ); + this.validatedDepositsCounter.set( + { type: 'invalid', stakingModuleId }, + invalid, + ); + } + + /** + * Collects metrics about deposited keys + * @param blockData - collected data from the current block + */ + public collectDepositMetrics( + { stakingModuleId }: StakingModuleData, + blockData: BlockData, + ): void { + const { depositedEvents } = blockData; + const { events } = depositedEvents; + + const depositedKeys = events.map(({ pubkey }) => pubkey); + const depositedKeysSet = new Set(depositedKeys); + const depositedDubsTotal = depositedKeys.length - depositedKeysSet.size; + + this.depositedKeysCounter.set( + { type: 'total', stakingModuleId }, + depositedKeys.length, + ); + this.depositedKeysCounter.set( + { type: 'unique', stakingModuleId }, + depositedKeysSet.size, + ); + this.depositedKeysCounter.set( + { type: 'duplicates', stakingModuleId }, + depositedDubsTotal, + ); + } + + /** + * Collects metrics about operators keys + * @param blockData - collected data from the current block + */ + public collectOperatorMetrics(stakingModuleData: StakingModuleData): void { + const { unusedKeys, stakingModuleId } = stakingModuleData; + + const operatorsKeysTotal = unusedKeys.length; + this.operatorsKeysCounter.set( + { type: 'total', stakingModuleId }, + operatorsKeysTotal, + ); + } + + /** + * Collects metrics about keys intersections + * @param all - all intersections + * @param filtered - all intersections + */ + public collectIntersectionsMetrics( + stakingModuleId: number, + all: VerifiedDepositEvent[], + filtered: VerifiedDepositEvent[], + ): void { + this.intersectionsCounter.set({ type: 'all', stakingModuleId }, all.length); + this.intersectionsCounter.set( + { type: 'filtered', stakingModuleId }, + filtered.length, + ); + } +} diff --git a/src/guardian/guardian-metrics/index.ts b/src/guardian/guardian-metrics/index.ts new file mode 100644 index 00000000..c68e09fe --- /dev/null +++ b/src/guardian/guardian-metrics/index.ts @@ -0,0 +1,2 @@ +export * from './guardian-metrics.module'; +export * from './guardian-metrics.service'; diff --git a/src/guardian/guardian.constants.ts b/src/guardian/guardian.constants.ts index 699ebb24..5f741c2d 100644 --- a/src/guardian/guardian.constants.ts +++ b/src/guardian/guardian.constants.ts @@ -1,2 +1,3 @@ export const GUARDIAN_DEPOSIT_RESIGNING_BLOCKS = 10; export const GUARDIAN_DEPOSIT_JOB_NAME = 'guardian-deposit-job'; +export const GUARDIAN_DEPOSIT_JOB_DURATION = 5_000; diff --git a/src/guardian/guardian.module.ts b/src/guardian/guardian.module.ts index fd623a54..15cefcad 100644 --- a/src/guardian/guardian.module.ts +++ b/src/guardian/guardian.module.ts @@ -6,6 +6,10 @@ import { MessagesModule } from 'messages'; import { GuardianService } from './guardian.service'; import { StakingRouterModule } from 'staking-router'; import { ScheduleModule } from 'common/schedule'; +import { BlockGuardModule } from './block-guard/block-guard.module'; +import { StakingModuleGuardModule } from './staking-module-guard'; +import { GuardianMessageModule } from './guardian-message'; +import { GuardianMetricsModule } from './guardian-metrics'; @Module({ imports: [ @@ -15,6 +19,10 @@ import { ScheduleModule } from 'common/schedule'; MessagesModule, StakingRouterModule, ScheduleModule, + BlockGuardModule, + StakingModuleGuardModule, + GuardianMessageModule, + GuardianMetricsModule, ], providers: [GuardianService], exports: [GuardianService], diff --git a/src/guardian/guardian.service.ts b/src/guardian/guardian.service.ts index 0b83a06c..e4a7d0d5 100644 --- a/src/guardian/guardian.service.ts +++ b/src/guardian/guardian.service.ts @@ -7,37 +7,20 @@ import { import { WINSTON_MODULE_NEST_PROVIDER } from 'nest-winston'; import { SchedulerRegistry } from '@nestjs/schedule'; import { CronJob } from 'cron'; -import { DepositService, VerifiedDepositEvent } from 'contracts/deposit'; +import { DepositService } from 'contracts/deposit'; import { SecurityService } from 'contracts/security'; -import { LidoService } from 'contracts/lido'; import { ProviderService } from 'provider'; import { - MessageDeposit, - MessageMeta, - MessagePause, - MessageRequiredFields, - MessagesService, - MessageType, -} from 'messages'; -import { ContractsState, BlockData } from './interfaces'; -import { + GUARDIAN_DEPOSIT_JOB_DURATION, GUARDIAN_DEPOSIT_JOB_NAME, - GUARDIAN_DEPOSIT_RESIGNING_BLOCKS, } from './guardian.constants'; import { OneAtTime } from 'common/decorators'; -import { InjectMetric } from '@willsoto/nestjs-prometheus'; -import { - METRIC_BLOCK_DATA_REQUEST_DURATION, - METRIC_BLOCK_DATA_REQUEST_ERRORS, - METRIC_VALIDATED_DEPOSITS_TOTAL, - METRIC_DEPOSITED_KEYS_TOTAL, - METRIC_OPERATORS_KEYS_TOTAL, - METRIC_INTERSECTIONS_TOTAL, -} from 'common/prometheus'; -import { Counter, Gauge, Histogram } from 'prom-client'; -import { APP_NAME, APP_VERSION } from 'app.constants'; import { StakingRouterService } from 'staking-router'; -import { SRModule } from 'keys-api/interfaces'; + +import { BlockGuardService } from './block-guard'; +import { StakingModuleGuardService } from './staking-module-guard'; +import { GuardianMessageService } from './guardian-message'; +import { GuardianMetricsService } from './guardian-metrics'; @Injectable() export class GuardianService implements OnModuleInit { @@ -47,33 +30,18 @@ export class GuardianService implements OnModuleInit { @Inject(WINSTON_MODULE_NEST_PROVIDER) private logger: LoggerService, - @InjectMetric(METRIC_BLOCK_DATA_REQUEST_DURATION) - private blockRequestsHistogram: Histogram, - - @InjectMetric(METRIC_BLOCK_DATA_REQUEST_ERRORS) - private blockErrorsCounter: Counter, - - @InjectMetric(METRIC_VALIDATED_DEPOSITS_TOTAL) - private validatedDepositsCounter: Gauge, - - @InjectMetric(METRIC_DEPOSITED_KEYS_TOTAL) - private depositedKeysCounter: Gauge, - - @InjectMetric(METRIC_OPERATORS_KEYS_TOTAL) - private operatorsKeysCounter: Gauge, - - @InjectMetric(METRIC_INTERSECTIONS_TOTAL) - private intersectionsCounter: Gauge, - private schedulerRegistry: SchedulerRegistry, private depositService: DepositService, private securityService: SecurityService, private providerService: ProviderService, - private messagesService: MessagesService, - private lidoService: LidoService, private stakingRouterService: StakingRouterService, + + private blockGuardService: BlockGuardService, + private stakingModuleGuardService: StakingModuleGuardService, + private guardianMessageService: GuardianMessageService, + private guardianMetricsService: GuardianMetricsService, ) {} public async onModuleInit(): Promise { @@ -104,7 +72,7 @@ export class GuardianService implements OnModuleInit { * Subscribes to the event of a new block appearance */ public subscribeToModulesUpdates() { - const cron = new CronJob(5_000, () => { + const cron = new CronJob(GUARDIAN_DEPOSIT_JOB_DURATION, () => { this.handleNewBlock().catch((error) => { this.logger.error(error); }); @@ -123,443 +91,56 @@ export class GuardianService implements OnModuleInit { @OneAtTime() public async handleNewBlock(): Promise { this.logger.log('New block cycle start'); + const { elBlockSnapshot: { blockHash, blockNumber }, data: stakingModules, } = await this.stakingRouterService.getStakingModules(); - if (!this.isNeedToProcessNewState({ blockHash, blockNumber })) return; - - await Promise.all( - stakingModules.map(async (stakingRouterModule) => { - const blockData = await this.getCurrentBlockData({ - blockHash, - blockNumber, - stakingRouterModule, - }); - - await Promise.all([ - this.checkKeysIntersections(blockData), - this.depositService.handleNewBlock(blockData), - this.pingMessageBroker(blockData), - ]); - - this.collectMetrics(blockData); - }), - ); - - this.setLastProcessedStateMeta({ blockHash, blockNumber }); - - this.logger.log('New block cycle end'); - } - - public isNeedToProcessNewState(newMeta: { - blockHash: string; - blockNumber: number; - }) { - const lastMeta = this.lastProcessedStateMeta; - if (!lastMeta) return true; - if (lastMeta.blockNumber > newMeta.blockNumber) { - this.logger.error('Keys-api returns old state', newMeta); - return false; - } - return lastMeta.blockHash !== newMeta.blockHash; - } - - public setLastProcessedStateMeta(newMeta: { - blockHash: string; - blockNumber: number; - }) { - this.lastProcessedStateMeta = newMeta; - } - - /** - * Sends a ping message to the message broker - * @param blockData - collected data from the current block - */ - public async pingMessageBroker(blockData: BlockData): Promise { - const { blockNumber, guardianIndex, guardianAddress } = blockData; - - await this.sendMessageFromGuardian({ - type: MessageType.PING, - blockNumber, - guardianIndex, - guardianAddress, - }); - } - - /** - * Collects data from contracts in one place and by block hash, - * to reduce the probability of getting data from different blocks - * @returns collected data from the current block - */ - public async getCurrentBlockData({ - blockNumber, - blockHash, - stakingRouterModule, - }: { - blockNumber: number; - blockHash: string; - stakingRouterModule: SRModule; - }): Promise { - try { - const endTimer = this.blockRequestsHistogram.startTimer(); - - const guardianAddress = this.securityService.getGuardianAddress(); - const { - data: { - keys, - module: { nonce }, - }, - meta: { elBlockSnapshot }, - } = await this.stakingRouterService.getStakingModuleUnusedKeys( - stakingRouterModule, - ); - - if (elBlockSnapshot.blockHash !== blockHash) - throw Error( - 'Blockhash of the received keys does not match the current blockhash', - ); - - const [depositRoot, depositedEvents, guardianIndex, isDepositsPaused] = - await Promise.all([ - this.depositService.getDepositRoot({ blockHash }), - this.depositService.getAllDepositedEvents(blockNumber, blockHash), - this.securityService.getGuardianIndex({ blockHash }), - this.securityService.isDepositsPaused(stakingRouterModule.id, { - blockHash, - }), - ]); - - endTimer(); - - return { - nonce, - unusedKeys: keys.map((srKey) => srKey.key), - blockNumber, - blockHash, - depositRoot, - depositedEvents, - guardianAddress, - guardianIndex, - isDepositsPaused, - srModuleId: stakingRouterModule.id, - }; - } catch (error) { - this.blockErrorsCounter.inc(); - throw error; - } - } - - /** - * Checks keys for intersections with previously deposited keys and handles the situation - * @param blockData - collected data from the current block - */ - public async checkKeysIntersections(blockData: BlockData): Promise { - const { blockHash } = blockData; - - const keysIntersections = this.getKeysIntersections(blockData); - - const filteredIntersections = await this.excludeEligibleIntersections( - keysIntersections, - blockData, - ); - const isFilteredIntersectionsFound = filteredIntersections.length > 0; - - this.collectIntersectionsMetrics(keysIntersections, filteredIntersections); - - if (blockData.isDepositsPaused) { - this.logger.warn('Deposits are paused', { blockHash }); - return; - } - - if (isFilteredIntersectionsFound) { - await this.handleKeysIntersections(blockData); - } else { - await this.handleCorrectKeys(blockData); - } - } - - /** - * Finds the intersection of the next deposit keys in the list of all previously deposited keys - * Quick check that can be done on each block - * @param blockData - collected data from the current block - * @returns list of keys that were deposited earlier - */ - public getKeysIntersections(blockData: BlockData): VerifiedDepositEvent[] { - const { blockHash } = blockData; - const { depositedEvents, unusedKeys } = blockData; - const { depositRoot, nonce } = blockData; - - const unusedKeysSet = new Set(unusedKeys); - const intersections = depositedEvents.events.filter(({ pubkey }) => - unusedKeysSet.has(pubkey), - ); - - if (intersections.length) { - this.logger.warn('Already deposited keys found in the next Lido keys', { + if ( + !this.blockGuardService.isNeedToProcessNewState({ blockHash, - depositRoot, - nonce, - intersections, - }); - } - - return intersections; - } - - /** - * Excludes invalid deposits and deposits with Lido WC from intersections - * @param intersections - list of deposits with keys that were deposited earlier - * @param blockData - collected data from the current block - */ - public async excludeEligibleIntersections( - intersections: VerifiedDepositEvent[], - blockData: BlockData, - ): Promise { - // Exclude deposits with invalid signature over the deposit data - const validIntersections = intersections.filter(({ valid }) => valid); - if (!validIntersections.length) return []; - - // Exclude deposits with Lido withdrawal credentials - const { blockHash } = blockData; - const lidoWC = await this.lidoService.getWithdrawalCredentials({ - blockHash, - }); - const attackIntersections = validIntersections.filter( - (deposit) => deposit.wc !== lidoWC, - ); - - return attackIntersections; - } - - /** - * Handles the situation when keys have previously deposited copies - * @param blockData - collected data from the current block - */ - public async handleKeysIntersections(blockData: BlockData): Promise { - const { - blockNumber, - blockHash, - guardianAddress, - guardianIndex, - isDepositsPaused, - depositRoot, - nonce, - srModuleId, - } = blockData; - - if (isDepositsPaused) { - this.logger.warn('Deposits are already paused', { blockHash }); + blockNumber, + }) + ) return; - } - const signature = await this.securityService.signPauseData( - blockNumber, - srModuleId, - ); - - const pauseMessage: MessagePause = { - type: MessageType.PAUSE, - depositRoot, - nonce, - guardianAddress, - guardianIndex, - blockNumber, + const blockData = await this.blockGuardService.getCurrentBlockData({ blockHash, - signature, - srModuleId, - }; - - this.logger.warn( - 'Suspicious case detected, initialize the protocol pause', - { blockHash }, - ); - - // Call pause without waiting for completion - this.securityService - .pauseDeposits(blockNumber, srModuleId, signature) - .catch((error) => this.logger.error(error)); - - await this.sendMessageFromGuardian(pauseMessage); - } - - /** - * Handles the situation when keys do not have previously deposited copies - * @param blockData - collected data from the current block - */ - public async handleCorrectKeys(blockData: BlockData): Promise { - const { blockNumber, - blockHash, - depositRoot, - nonce, - guardianAddress, - guardianIndex, - srModuleId, - } = blockData; + }); - const currentContractState = { nonce, depositRoot, blockNumber }; - const lastContractsState = this.lastContractsState; + await Promise.all( + stakingModules.map(async (stakingRouterModule) => { + const stakingModuleData = + await this.stakingModuleGuardService.getStakingRouterModuleData( + stakingRouterModule, + blockHash, + ); - this.lastContractsState = currentContractState; + await Promise.all([ + this.stakingModuleGuardService.checkKeysIntersections( + stakingModuleData, + blockData, + ), + this.guardianMessageService.pingMessageBroker( + stakingModuleData, + blockData, + ), + ]); - const isSameContractsState = this.isSameContractsStates( - currentContractState, - lastContractsState, + this.guardianMetricsService.collectMetrics( + stakingModuleData, + blockData, + ); + }), ); - if (isSameContractsState) return; - - const signature = await this.securityService.signDepositData( - depositRoot, - nonce, - blockNumber, + this.blockGuardService.setLastProcessedStateMeta({ blockHash, - srModuleId, - ); - - const depositMessage: MessageDeposit = { - type: MessageType.DEPOSIT, - depositRoot, - nonce, blockNumber, - blockHash, - guardianAddress, - guardianIndex, - signature, - }; - - this.logger.log('No problems found', { - blockHash, - lastState: lastContractsState, - newState: currentContractState, }); - await this.sendMessageFromGuardian(depositMessage); - } - - private lastContractsState: ContractsState | null = null; - - /** - * Compares the states of the contracts to decide if the message needs to be re-signed - * @param firstState - contracts state - * @param secondState - contracts state - * @returns true if state is the same - */ - public isSameContractsStates( - firstState: ContractsState | null, - secondState: ContractsState | null, - ): boolean { - if (!firstState || !secondState) return false; - if (firstState.depositRoot !== secondState.depositRoot) return false; - if (firstState.nonce !== secondState.nonce) return false; - if ( - Math.floor(firstState.blockNumber / GUARDIAN_DEPOSIT_RESIGNING_BLOCKS) !== - Math.floor(secondState.blockNumber / GUARDIAN_DEPOSIT_RESIGNING_BLOCKS) - ) { - return false; - } - - return true; - } - - /** - * Adds information about the app to the message - * @param message - message object - * @returns extended message - */ - public addMessageMetaData(message: T): T & MessageMeta { - return { - ...message, - app: { version: APP_VERSION, name: APP_NAME }, - }; - } - - /** - * Sends a message to the message broker from the guardian - * @param messageData - message object - */ - public async sendMessageFromGuardian( - messageData: T, - ): Promise { - if (messageData.guardianIndex == -1) { - this.logger.warn( - 'Your address is not in the Guardian List. The message will not be sent', - ); - - return; - } - - const messageWithMeta = this.addMessageMetaData(messageData); - - this.logger.log('Sending a message to broker', messageData); - await this.messagesService.sendMessage(messageWithMeta); - } - - /** - * Collects metrics about keys in the deposit contract and keys of node operators - * @param blockData - collected data from the current block - */ - public collectMetrics(blockData: BlockData): void { - this.collectValidatingMetrics(blockData); - this.collectDepositMetrics(blockData); - this.collectOperatorMetrics(blockData); - } - - /** - * Collects metrics about validated deposits - * @param blockData - collected data from the current block - */ - public collectValidatingMetrics(blockData: BlockData): void { - const { depositedEvents } = blockData; - const { events } = depositedEvents; - - const valid = events.reduce((sum, { valid }) => sum + (valid ? 1 : 0), 0); - const invalid = events.reduce((sum, { valid }) => sum + (valid ? 0 : 1), 0); - - this.validatedDepositsCounter.set({ type: 'valid' }, valid); - this.validatedDepositsCounter.set({ type: 'invalid' }, invalid); - } - - /** - * Collects metrics about deposited keys - * @param blockData - collected data from the current block - */ - public collectDepositMetrics(blockData: BlockData): void { - const { depositedEvents } = blockData; - const { events } = depositedEvents; - - const depositedKeys = events.map(({ pubkey }) => pubkey); - const depositedKeysSet = new Set(depositedKeys); - const depositedDubsTotal = depositedKeys.length - depositedKeysSet.size; - - this.depositedKeysCounter.set({ type: 'total' }, depositedKeys.length); - this.depositedKeysCounter.set({ type: 'unique' }, depositedKeysSet.size); - this.depositedKeysCounter.set({ type: 'duplicates' }, depositedDubsTotal); - } - - /** - * Collects metrics about operators keys - * @param blockData - collected data from the current block - */ - public collectOperatorMetrics(blockData: BlockData): void { - const { unusedKeys } = blockData; - - const operatorsKeysTotal = unusedKeys.length; - this.operatorsKeysCounter.set({ type: 'total' }, operatorsKeysTotal); - } - - /** - * Collects metrics about keys intersections - * @param all - all intersections - * @param filtered - all intersections - */ - public collectIntersectionsMetrics( - all: VerifiedDepositEvent[], - filtered: VerifiedDepositEvent[], - ): void { - this.intersectionsCounter.set({ type: 'all' }, all.length); - this.intersectionsCounter.set({ type: 'filtered' }, filtered.length); + this.logger.log('New block cycle end'); } } diff --git a/src/guardian/interfaces/block.interface.ts b/src/guardian/interfaces/block.interface.ts index a59ec491..7e360196 100644 --- a/src/guardian/interfaces/block.interface.ts +++ b/src/guardian/interfaces/block.interface.ts @@ -7,8 +7,4 @@ export interface BlockData { depositedEvents: VerifiedDepositEventGroup; guardianAddress: string; guardianIndex: number; - isDepositsPaused: boolean; - unusedKeys: string[]; - nonce: number; - srModuleId: number; } diff --git a/src/guardian/interfaces/index.ts b/src/guardian/interfaces/index.ts index 44c5cdf0..6c250900 100644 --- a/src/guardian/interfaces/index.ts +++ b/src/guardian/interfaces/index.ts @@ -1,2 +1,3 @@ export * from './block.interface'; export * from './state.interface'; +export * from './staking-module.interface'; diff --git a/src/guardian/interfaces/staking-module.interface.ts b/src/guardian/interfaces/staking-module.interface.ts new file mode 100644 index 00000000..fe08e7bd --- /dev/null +++ b/src/guardian/interfaces/staking-module.interface.ts @@ -0,0 +1,7 @@ +export interface StakingModuleData { + blockHash: string; + isDepositsPaused: boolean; + unusedKeys: string[]; + nonce: number; + stakingModuleId: number; +} diff --git a/src/guardian/staking-module-guard/index.ts b/src/guardian/staking-module-guard/index.ts new file mode 100644 index 00000000..b3af083c --- /dev/null +++ b/src/guardian/staking-module-guard/index.ts @@ -0,0 +1,2 @@ +export * from './staking-module-guard.module'; +export * from './staking-module-guard.service'; diff --git a/src/guardian/staking-module-guard/staking-module-guard.module.ts b/src/guardian/staking-module-guard/staking-module-guard.module.ts new file mode 100644 index 00000000..55e0cb1b --- /dev/null +++ b/src/guardian/staking-module-guard/staking-module-guard.module.ts @@ -0,0 +1,23 @@ +import { Module } from '@nestjs/common'; + +import { SecurityModule } from 'contracts/security'; +import { LidoModule } from 'contracts/lido'; +import { StakingRouterModule } from 'staking-router'; + +import { GuardianMetricsModule } from '../guardian-metrics'; +import { GuardianMessageModule } from '../guardian-message'; + +import { StakingModuleGuardService } from './staking-module-guard.service'; + +@Module({ + imports: [ + SecurityModule, + LidoModule, + StakingRouterModule, + GuardianMetricsModule, + GuardianMessageModule, + ], + providers: [StakingModuleGuardService], + exports: [StakingModuleGuardService], +}) +export class StakingModuleGuardModule {} diff --git a/src/guardian/staking-module-guard/staking-module-guard.service.ts b/src/guardian/staking-module-guard/staking-module-guard.service.ts new file mode 100644 index 00000000..19d09a6e --- /dev/null +++ b/src/guardian/staking-module-guard/staking-module-guard.service.ts @@ -0,0 +1,292 @@ +import { Inject, Injectable, LoggerService } from '@nestjs/common'; +import { WINSTON_MODULE_NEST_PROVIDER } from 'nest-winston'; + +import { VerifiedDepositEvent } from 'contracts/deposit'; +import { SecurityService } from 'contracts/security'; +import { LidoService } from 'contracts/lido'; + +import { ContractsState, BlockData, StakingModuleData } from '../interfaces'; +import { GUARDIAN_DEPOSIT_RESIGNING_BLOCKS } from '../guardian.constants'; +import { GuardianMetricsService } from '../guardian-metrics'; +import { GuardianMessageService } from '../guardian-message'; + +import { StakingRouterService } from 'staking-router'; +import { SRModule } from 'keys-api/interfaces'; + +@Injectable() +export class StakingModuleGuardService { + constructor( + @Inject(WINSTON_MODULE_NEST_PROVIDER) + private logger: LoggerService, + + private securityService: SecurityService, + private lidoService: LidoService, + + private stakingRouterService: StakingRouterService, + private guardianMetricsService: GuardianMetricsService, + private guardianMessageService: GuardianMessageService, + ) {} + + public async getStakingRouterModuleData( + stakingRouterModule: SRModule, + blockHash: string, + ): Promise { + const { + data: { + keys, + module: { nonce }, + }, + meta: { elBlockSnapshot }, + } = await this.stakingRouterService.getStakingModuleUnusedKeys( + stakingRouterModule, + ); + if (elBlockSnapshot.blockHash !== blockHash) + throw Error( + 'Blockhash of the received keys does not match the current blockhash', + ); + + const isDepositsPaused = await this.securityService.isDepositsPaused( + stakingRouterModule.id, + { + blockHash, + }, + ); + + return { + nonce, + unusedKeys: keys.map((srKey) => srKey.key), + isDepositsPaused, + stakingModuleId: stakingRouterModule.id, + blockHash, + }; + } + + /** + * Checks keys for intersections with previously deposited keys and handles the situation + * @param blockData - collected data from the current block + */ + public async checkKeysIntersections( + stakingModuleData: StakingModuleData, + blockData: BlockData, + ): Promise { + const { blockHash } = blockData; + + const keysIntersections = this.getKeysIntersections( + stakingModuleData, + blockData, + ); + + const filteredIntersections = await this.excludeEligibleIntersections( + blockData, + keysIntersections, + ); + const isFilteredIntersectionsFound = filteredIntersections.length > 0; + + this.guardianMetricsService.collectIntersectionsMetrics( + stakingModuleData.stakingModuleId, + keysIntersections, + filteredIntersections, + ); + + if (stakingModuleData.isDepositsPaused) { + this.logger.warn('Deposits are paused', { blockHash }); + return; + } + + if (isFilteredIntersectionsFound) { + await this.handleKeysIntersections(stakingModuleData, blockData); + } else { + await this.handleCorrectKeys(stakingModuleData, blockData); + } + } + + /** + * Finds the intersection of the next deposit keys in the list of all previously deposited keys + * Quick check that can be done on each block + * @param blockData - collected data from the current block + * @returns list of keys that were deposited earlier + */ + public getKeysIntersections( + stakingModuleData: StakingModuleData, + blockData: BlockData, + ): VerifiedDepositEvent[] { + const { blockHash, depositRoot, depositedEvents } = blockData; + const { nonce, unusedKeys } = stakingModuleData; + + const unusedKeysSet = new Set(unusedKeys); + const intersections = depositedEvents.events.filter(({ pubkey }) => + unusedKeysSet.has(pubkey), + ); + + if (intersections.length) { + this.logger.warn('Already deposited keys found in the next Lido keys', { + blockHash, + depositRoot, + nonce, + intersections, + }); + } + + return intersections; + } + + /** + * Excludes invalid deposits and deposits with Lido WC from intersections + * @param intersections - list of deposits with keys that were deposited earlier + * @param blockData - collected data from the current block + */ + public async excludeEligibleIntersections( + blockData: BlockData, + intersections: VerifiedDepositEvent[], + ): Promise { + // Exclude deposits with invalid signature over the deposit data + const validIntersections = intersections.filter(({ valid }) => valid); + if (!validIntersections.length) return []; + + // Exclude deposits with Lido withdrawal credentials + const { blockHash } = blockData; + const lidoWC = await this.lidoService.getWithdrawalCredentials({ + blockHash, + }); + const attackIntersections = validIntersections.filter( + (deposit) => deposit.wc !== lidoWC, + ); + + return attackIntersections; + } + + /** + * Handles the situation when keys have previously deposited copies + * @param blockData - collected data from the current block + */ + public async handleKeysIntersections( + stakingModuleData: StakingModuleData, + blockData: BlockData, + ): Promise { + const { + blockNumber, + blockHash, + guardianAddress, + guardianIndex, + depositRoot, + } = blockData; + + const { isDepositsPaused, nonce, stakingModuleId } = stakingModuleData; + + if (isDepositsPaused) { + this.logger.warn('Deposits are already paused', { blockHash }); + return; + } + + const signature = await this.securityService.signPauseData( + blockNumber, + stakingModuleId, + ); + + const pauseMessage = { + depositRoot, + nonce, + guardianAddress, + guardianIndex, + blockNumber, + blockHash, + signature, + stakingModuleId, + }; + + this.logger.warn( + 'Suspicious case detected, initialize the protocol pause', + { blockHash }, + ); + + // Call pause without waiting for completion + this.securityService + .pauseDeposits(blockNumber, stakingModuleId, signature) + .catch((error) => this.logger.error(error)); + + await this.guardianMessageService.sendPauseMessage(pauseMessage); + } + + /** + * Handles the situation when keys do not have previously deposited copies + * @param blockData - collected data from the current block + */ + public async handleCorrectKeys( + stakingModuleData: StakingModuleData, + blockData: BlockData, + ): Promise { + const { + blockNumber, + blockHash, + depositRoot, + guardianAddress, + guardianIndex, + } = blockData; + + const { nonce, stakingModuleId } = stakingModuleData; + + const currentContractState = { nonce, depositRoot, blockNumber }; + const lastContractsState = this.lastContractsState; + + this.lastContractsState = currentContractState; + + const isSameContractsState = this.isSameContractsStates( + currentContractState, + lastContractsState, + ); + + if (isSameContractsState) return; + + const signature = await this.securityService.signDepositData( + depositRoot, + nonce, + blockNumber, + blockHash, + stakingModuleId, + ); + + const depositMessage = { + depositRoot, + nonce, + blockNumber, + blockHash, + guardianAddress, + guardianIndex, + signature, + stakingModuleId, + }; + + this.logger.log('No problems found', { + blockHash, + lastState: lastContractsState, + newState: currentContractState, + }); + + await this.guardianMessageService.sendDepositMessage(depositMessage); + } + + private lastContractsState: ContractsState | null = null; + + /** + * Compares the states of the contracts to decide if the message needs to be re-signed + * @param firstState - contracts state + * @param secondState - contracts state + * @returns true if state is the same + */ + public isSameContractsStates( + firstState: ContractsState | null, + secondState: ContractsState | null, + ): boolean { + if (!firstState || !secondState) return false; + if (firstState.depositRoot !== secondState.depositRoot) return false; + if (firstState.nonce !== secondState.nonce) return false; + if ( + Math.floor(firstState.blockNumber / GUARDIAN_DEPOSIT_RESIGNING_BLOCKS) !== + Math.floor(secondState.blockNumber / GUARDIAN_DEPOSIT_RESIGNING_BLOCKS) + ) { + return false; + } + + return true; + } +} diff --git a/src/messages/interfaces/message.interface.ts b/src/messages/interfaces/message.interface.ts index ca0e2786..3447fc63 100644 --- a/src/messages/interfaces/message.interface.ts +++ b/src/messages/interfaces/message.interface.ts @@ -4,6 +4,7 @@ export interface MessageRequiredFields { type: MessageType; guardianAddress: string; guardianIndex: number; + stakingModuleId: number; } export enum MessageType { @@ -35,5 +36,4 @@ export interface MessagePause extends MessageRequiredFields { blockNumber: number; blockHash: string; signature: Signature; - srModuleId: number; }