diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2415cf18..9fe004f4 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -35,6 +35,12 @@ jobs: run: yarn lint - name: Run tests run: yarn test + env: + DB_NAME: node_operator_keys_service_db + DB_PORT: 5432 + DB_HOST: localhost + DB_USER: postgres + DB_PASSWORD: postgres - name: Run E2E tests run: yarn test:e2e env: diff --git a/src/app/app.service.ts b/src/app/app.service.ts index 04b541f6..77f93e49 100644 --- a/src/app/app.service.ts +++ b/src/app/app.service.ts @@ -1,7 +1,6 @@ import { Inject, Injectable, LoggerService, OnModuleInit } from '@nestjs/common'; import { LOGGER_PROVIDER } from '@lido-nestjs/logger'; import { ExecutionProviderService } from '../common/execution-provider'; -import { ConsensusProviderService } from '../common/consensus-provider'; import { ConfigService } from '../common/config'; import { PrometheusService } from '../common/prometheus'; import { APP_NAME, APP_VERSION } from './app.constants'; @@ -11,35 +10,16 @@ export class AppService implements OnModuleInit { constructor( @Inject(LOGGER_PROVIDER) protected readonly logger: LoggerService, protected readonly executionProviderService: ExecutionProviderService, - protected readonly consensusProviderService: ConsensusProviderService, protected readonly configService: ConfigService, protected readonly prometheusService: PrometheusService, ) {} public async onModuleInit(): Promise { - if (this.configService.get('VALIDATOR_REGISTRY_ENABLE')) { - await this.validateNetwork(); - } - const env = this.configService.get('NODE_ENV'); const version = APP_VERSION; const name = APP_NAME; const network = await this.executionProviderService.getNetworkName(); this.prometheusService.buildInfo.labels({ env, name, version, network }).inc(); - this.logger.log('Init app', { env, name, version }); - } - - /** - * Validates the CL and EL chains match - */ - protected async validateNetwork(): Promise { - const chainId = this.configService.get('CHAIN_ID'); - const depositContract = await this.consensusProviderService.getDepositContract(); - const elChainId = await this.executionProviderService.getChainId(); - const clChainId = Number(depositContract.data?.chain_id); - - if (chainId !== elChainId || elChainId !== clChainId) { - throw new Error('Execution and consensus chain ids do not match'); - } + this.logger.log('Init app', { env, name, version, network }); } } diff --git a/src/jobs/jobs.module.ts b/src/jobs/jobs.module.ts index 2f78e054..d2ed3c99 100644 --- a/src/jobs/jobs.module.ts +++ b/src/jobs/jobs.module.ts @@ -1,10 +1,11 @@ import { Module } from '@nestjs/common'; import { JobsService } from './jobs.service'; +import { NetworkValidationModule } from '../network-validation'; import { KeysUpdateModule } from './keys-update/keys-update.module'; import { ValidatorsUpdateModule } from './validators-update/validators-update.module'; @Module({ - imports: [KeysUpdateModule, ValidatorsUpdateModule], + imports: [NetworkValidationModule, KeysUpdateModule, ValidatorsUpdateModule], providers: [JobsService], }) export class JobsModule {} diff --git a/src/jobs/jobs.service.ts b/src/jobs/jobs.service.ts index c2c60ad8..e7feb466 100644 --- a/src/jobs/jobs.service.ts +++ b/src/jobs/jobs.service.ts @@ -1,5 +1,6 @@ import { Inject, Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; import { LOGGER_PROVIDER, LoggerService } from 'common/logger'; +import { NetworkValidationService } from '../network-validation'; import { ValidatorsUpdateService } from './validators-update/validators-update.service'; import { KeysUpdateService } from './keys-update'; import { SchedulerRegistry } from '@nestjs/schedule'; @@ -9,6 +10,7 @@ import { PrometheusService } from 'common/prometheus'; export class JobsService implements OnModuleInit, OnModuleDestroy { constructor( @Inject(LOGGER_PROVIDER) protected readonly logger: LoggerService, + protected readonly networkValidationService: NetworkValidationService, protected readonly keysUpdateService: KeysUpdateService, protected readonly validatorUpdateService: ValidatorsUpdateService, protected readonly schedulerRegistry: SchedulerRegistry, @@ -16,6 +18,10 @@ export class JobsService implements OnModuleInit, OnModuleDestroy { ) {} public async onModuleInit(): Promise { + this.logger.log('Started network config and DB validation'); + await this.networkValidationService.validate(); + this.logger.log('Finished network config and DB validation'); + // Do not wait for initialization to avoid blocking the main process this.initialize().catch((err) => this.logger.error(err)); } diff --git a/src/jobs/keys-update/staking-module-updater.service.ts b/src/jobs/keys-update/staking-module-updater.service.ts index a1232b02..83260947 100644 --- a/src/jobs/keys-update/staking-module-updater.service.ts +++ b/src/jobs/keys-update/staking-module-updater.service.ts @@ -39,7 +39,7 @@ export class StakingModuleUpdaterService { // Read current nonce from contract const currNonce = await moduleInstance.getCurrentNonce(stakingModuleAddress, currentBlockHash); // Read module in storage - const moduleInStorage = await this.srModulesStorage.findOneById(contractModule.moduleId); + const moduleInStorage = await this.srModulesStorage.findOneByModuleId(contractModule.moduleId); const prevNonce = moduleInStorage?.nonce; this.logger.log(`Nonce previous value: ${prevNonce}, nonce current value: ${currNonce}`); diff --git a/src/jobs/keys-update/test/detect-reorg.spec.ts b/src/jobs/keys-update/test/detect-reorg.spec.ts index 1260713c..c91572c6 100644 --- a/src/jobs/keys-update/test/detect-reorg.spec.ts +++ b/src/jobs/keys-update/test/detect-reorg.spec.ts @@ -45,7 +45,7 @@ describe('detect reorg', () => { { provide: SRModuleStorageService, useValue: { - findOneById: jest.fn(), + findOneByModuleId: jest.fn(), upsert: jest.fn(), }, }, diff --git a/src/jobs/keys-update/test/update-cases.spec.ts b/src/jobs/keys-update/test/update-cases.spec.ts index 81de727f..245cac29 100644 --- a/src/jobs/keys-update/test/update-cases.spec.ts +++ b/src/jobs/keys-update/test/update-cases.spec.ts @@ -11,7 +11,7 @@ import { UpdaterState } from '../keys-update.interfaces'; describe('update cases', () => { let updaterService: StakingModuleUpdaterService; let stakingRouterService: StakingRouterService; - let sRModuleStorageService: SRModuleStorageService; + let srModuleStorageService: SRModuleStorageService; let elMetaStorageService: ElMetaStorageService; let loggerService: { log: jest.Mock }; let executionProviderService: ExecutionProviderService; @@ -50,7 +50,7 @@ describe('update cases', () => { { provide: SRModuleStorageService, useValue: { - findOneById: jest.fn(), + findOneByModuleId: jest.fn(), upsert: jest.fn(), }, }, @@ -60,7 +60,7 @@ describe('update cases', () => { updaterService = module.get(StakingModuleUpdaterService); stakingRouterService = module.get(StakingRouterService); - sRModuleStorageService = module.get(SRModuleStorageService); + srModuleStorageService = module.get(SRModuleStorageService); executionProviderService = module.get(ExecutionProviderService); elMetaStorageService = module.get(ElMetaStorageService); loggerService = module.get(LOGGER_PROVIDER); @@ -126,7 +126,7 @@ describe('update cases', () => { } as any), ); - jest.spyOn(sRModuleStorageService, 'findOneById').mockImplementation( + jest.spyOn(srModuleStorageService, 'findOneByModuleId').mockImplementation( () => ({ nonce: 0, @@ -153,7 +153,7 @@ describe('update cases', () => { updaterState.lastChangedBlockHash = currBh; }); const mockElUpdate = jest.spyOn(elMetaStorageService, 'update').mockImplementation(); - jest.spyOn(sRModuleStorageService, 'findOneById').mockImplementation( + jest.spyOn(srModuleStorageService, 'findOneByModuleId').mockImplementation( () => ({ nonce: 1, @@ -179,7 +179,7 @@ describe('update cases', () => { updaterState.lastChangedBlockHash = currBh; }); const mockElUpdate = jest.spyOn(elMetaStorageService, 'update').mockImplementation(); - jest.spyOn(sRModuleStorageService, 'findOneById').mockImplementation( + jest.spyOn(srModuleStorageService, 'findOneByModuleId').mockImplementation( () => ({ nonce: 1, diff --git a/src/migrations/Migration20240427195401.ts b/src/migrations/Migration20240427195401.ts new file mode 100644 index 00000000..06d82095 --- /dev/null +++ b/src/migrations/Migration20240427195401.ts @@ -0,0 +1,16 @@ +import { Migration } from '@mikro-orm/migrations'; + +export class Migration20240427195401 extends Migration { + async up(): Promise { + this.addSql( + 'create table "app_info_entity" ("chain_id" serial primary key, "locator_address" varchar(42) not null);', + ); + this.addSql( + 'alter table "app_info_entity" add constraint "app_info_entity_locator_address_unique" unique ("locator_address");', + ); + } + + async down(): Promise { + this.addSql('drop table if exists "app_info_entity" cascade;'); + } +} diff --git a/src/mikro-orm.config.ts b/src/mikro-orm.config.ts index fcd70d5b..87e4151c 100644 --- a/src/mikro-orm.config.ts +++ b/src/mikro-orm.config.ts @@ -11,6 +11,7 @@ import { ConsensusValidatorEntity } from '@lido-nestjs/validators-registry'; import { readFileSync } from 'fs'; import { SrModuleEntity } from './storage/sr-module.entity'; import { ElMetaEntity } from './storage/el-meta.entity'; +import { AppInfoEntity } from './storage/app-info.entity'; import { Logger } from '@nestjs/common'; dotenv.config(); @@ -126,6 +127,7 @@ const config: Options = { ConsensusMetaEntity, SrModuleEntity, ElMetaEntity, + AppInfoEntity, ], migrations: getMigrationOptions(path.join(__dirname, 'migrations'), ['@lido-nestjs/validators-registry']), }; diff --git a/src/network-validation/index.ts b/src/network-validation/index.ts new file mode 100644 index 00000000..ce675d4d --- /dev/null +++ b/src/network-validation/index.ts @@ -0,0 +1,2 @@ +export * from './network-validation.module'; +export * from './network-validation.service'; diff --git a/src/network-validation/network-validation.module.ts b/src/network-validation/network-validation.module.ts new file mode 100644 index 00000000..cb5f7a26 --- /dev/null +++ b/src/network-validation/network-validation.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { NetworkValidationService } from './network-validation.service'; + +@Module({ + providers: [NetworkValidationService], + exports: [NetworkValidationService], +}) +export class NetworkValidationModule {} diff --git a/src/network-validation/network-validation.service.spec.ts b/src/network-validation/network-validation.service.spec.ts new file mode 100644 index 00000000..0112742e --- /dev/null +++ b/src/network-validation/network-validation.service.spec.ts @@ -0,0 +1,503 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { LOGGER_PROVIDER } from '@lido-nestjs/logger'; +import { REGISTRY_CONTRACT_ADDRESSES, LIDO_LOCATOR_CONTRACT_TOKEN } from '@lido-nestjs/contracts'; +import { ConfigModule, ConfigService } from 'common/config'; +import { ConsensusProviderService } from '../common/consensus-provider'; +import { ExecutionProviderService } from '../common/execution-provider'; +import { RegistryKeyStorageService, RegistryOperatorStorageService } from '../common/registry'; +import { SRModuleStorageService } from '../storage/sr-module.storage'; +import { AppInfoStorageService } from '../storage/app-info.storage'; +import { NetworkValidationService } from './network-validation.service'; +import { key } from 'common/registry/test/fixtures/key.fixture'; +import { curatedModule } from '../http/db.fixtures'; +import { operator } from 'common/registry/test/fixtures/operator.fixture'; +import { DatabaseE2ETestingModule } from '../app'; +import { + InconsistentDataInDBErrorTypes, + ChainMismatchError, + InconsistentDataInDBError, +} from './network-validation.service'; + +describe('network configuration correctness sanity checker', () => { + let configService: ConfigService; + let locatorContract: { address: string }; + let executionProviderService: ExecutionProviderService; + let registryKeyStorageService: RegistryKeyStorageService; + let moduleStorageService: SRModuleStorageService; + let operatorStorageService: RegistryOperatorStorageService; + let networkValidationService: NetworkValidationService; + let appInfoStorageService: AppInfoStorageService; + + const keyFixture = { + ...key, + index: 1, + operatorIndex: 0, + moduleAddress: REGISTRY_CONTRACT_ADDRESSES[17000].toLowerCase(), + }; + + const holeskyCuratedModuleFixture = { + ...curatedModule, + id: 1, + stakingModuleAddress: REGISTRY_CONTRACT_ADDRESSES[17000].toLowerCase(), + nonce: 14100, + lastChangedBlockHash: '0x662e3e713207240b25d01324b6eccdc91493249a5048881544254994694530a5', + }; + + const operatorFixture = { + ...operator, + index: 0, + moduleAddress: REGISTRY_CONTRACT_ADDRESSES[17000].toLowerCase(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + imports: [ConfigModule, DatabaseE2ETestingModule.forRoot()], + providers: [ + { + provide: LIDO_LOCATOR_CONTRACT_TOKEN, + useValue: { + address: '0x17000', + }, + }, + { + provide: LOGGER_PROVIDER, + useValue: { + log: jest.fn(), + }, + }, + { + provide: ConsensusProviderService, + useValue: { + getDepositContract: jest.fn(async () => ({ + data: { + chain_id: '1', + }, + })), + }, + }, + { + provide: ExecutionProviderService, + useValue: { + getChainId: jest.fn(async () => 17000), + }, + }, + { + provide: RegistryKeyStorageService, + useValue: { + find: jest.fn(async () => []), + }, + }, + { + provide: SRModuleStorageService, + useValue: { + findOneByModuleId: jest.fn(async () => null), + }, + }, + { + provide: RegistryOperatorStorageService, + useValue: { + find: jest.fn(async () => []), + }, + }, + { + provide: AppInfoStorageService, + useValue: { + update: jest.fn(), + get: jest.fn(async () => ({ + chainId: 17000, + locatorAddress: '0x17000', + })), + }, + }, + NetworkValidationService, + ], + }).compile(); + + configService = module.get(ConfigService); + locatorContract = module.get(LIDO_LOCATOR_CONTRACT_TOKEN); + executionProviderService = module.get(ExecutionProviderService); + registryKeyStorageService = module.get(RegistryKeyStorageService); + moduleStorageService = module.get(SRModuleStorageService); + operatorStorageService = module.get(RegistryOperatorStorageService); + appInfoStorageService = module.get(AppInfoStorageService); + networkValidationService = module.get(NetworkValidationService); + + jest.spyOn(configService, 'get').mockImplementation((path) => { + if (path === 'VALIDATOR_REGISTRY_ENABLE') { + return false; + } + + if (path === 'CHAIN_ID') { + return 17000; + } + + return undefined; + }); + }); + + it('should be defined', () => { + expect(networkValidationService).toBeDefined(); + }); + + it("should throw error if the chain ID defined in env variables doesn't match the chain ID returned by EL node", async () => { + jest.spyOn(configService, 'get').mockImplementation((path) => { + if (path === 'CHAIN_ID') { + return 1; + } + + return undefined; + }); + + const updateAppInfoMock = jest.spyOn(appInfoStorageService, 'update'); + + return networkValidationService.validate().catch((error: Error) => { + expect(error).toBeInstanceOf(ChainMismatchError); + expect(error).toHaveProperty('configChainId', 1); + expect(error).toHaveProperty('elChainId', 17000); + + expect(updateAppInfoMock).not.toHaveBeenCalled(); + }); + }); + + it("should throw error if the chain ID returned by EL node doesn't match the chain ID returned by CL node if the validator registry is enabled in config", async () => { + jest.spyOn(configService, 'get').mockImplementation((path) => { + if (path === 'VALIDATOR_REGISTRY_ENABLE') { + return true; + } + + if (path === 'CHAIN_ID') { + return 17000; + } + + return undefined; + }); + + const updateAppInfoMock = jest.spyOn(appInfoStorageService, 'update'); + + return networkValidationService.validate().catch((error: Error) => { + expect(error).toBeInstanceOf(ChainMismatchError); + expect(error).toHaveProperty('configChainId', 17000); + expect(error).toHaveProperty('elChainId', 17000); + expect(error).toHaveProperty('clChainId', 1); + + expect(updateAppInfoMock).not.toHaveBeenCalled(); + }); + }); + + it("should throw error if the chain ID defined in env variables doesn't match the chain ID returned by CL node if the validator registry is enabled in config", async () => { + jest.spyOn(configService, 'get').mockImplementation((path) => { + if (path === 'VALIDATOR_REGISTRY_ENABLE') { + return true; + } + + if (path === 'CHAIN_ID') { + return 17000; + } + + return undefined; + }); + + const updateAppInfoMock = jest.spyOn(appInfoStorageService, 'update'); + + return networkValidationService.validate().catch((error: Error) => { + expect(error).toBeInstanceOf(ChainMismatchError); + expect(error).toHaveProperty('configChainId', 17000); + expect(error).toHaveProperty('elChainId', 17000); + expect(error).toHaveProperty('clChainId', 1); + + expect(updateAppInfoMock).not.toHaveBeenCalled(); + }); + }); + + it('should save information about the chain and locator to the DB if there are no keys, modules, and operators in the DB', async () => { + const updateAppInfoMock = jest.spyOn(appInfoStorageService, 'update'); + + await expect(networkValidationService.validate()).resolves.not.toThrow(); + expect(updateAppInfoMock).toBeCalledTimes(1); + expect(updateAppInfoMock).toBeCalledWith({ + chainId: 17000, + locatorAddress: '0x17000', + }); + }); + + it("should throw error if info about chain ID stored in the DB doesn't match chain ID configured in env variables and keys, modules or operators tables have some info in the DB", async () => { + jest.spyOn(configService, 'get').mockImplementation((path) => { + if (path === 'VALIDATOR_REGISTRY_ENABLE') { + return false; + } + + if (path === 'CHAIN_ID') { + return 1; + } + + return undefined; + }); + + jest.spyOn(executionProviderService, 'getChainId').mockImplementationOnce(() => Promise.resolve(1)); + + jest.spyOn(registryKeyStorageService, 'find').mockImplementationOnce(() => Promise.resolve([keyFixture])); + + jest + .spyOn(moduleStorageService, 'findOneByModuleId') + .mockImplementationOnce(() => Promise.resolve(holeskyCuratedModuleFixture)); + + jest.spyOn(operatorStorageService, 'find').mockImplementationOnce(() => Promise.resolve([operatorFixture])); + + const updateAppInfoMock = jest.spyOn(appInfoStorageService, 'update'); + + return networkValidationService.validate().catch((error: Error) => { + expect(error).toBeInstanceOf(InconsistentDataInDBError); + expect(error).toHaveProperty('type', InconsistentDataInDBErrorTypes.appInfoMismatch); + + expect(updateAppInfoMock).not.toHaveBeenCalled(); + }); + }); + + it("should throw error if info about locator address stored in the DB doesn't match locator address returned by locator service and keys, modules or operators tables have some info in the DB", async () => { + jest.spyOn(registryKeyStorageService, 'find').mockImplementationOnce(() => Promise.resolve([keyFixture])); + + jest + .spyOn(moduleStorageService, 'findOneByModuleId') + .mockImplementationOnce(() => Promise.resolve(holeskyCuratedModuleFixture)); + + jest.spyOn(operatorStorageService, 'find').mockImplementationOnce(() => Promise.resolve([operatorFixture])); + + locatorContract.address = '0x1'; + + const updateAppInfoMock = jest.spyOn(appInfoStorageService, 'update'); + + return networkValidationService.validate().catch((error: Error) => { + expect(error).toBeInstanceOf(InconsistentDataInDBError); + expect(error).toHaveProperty('type', InconsistentDataInDBErrorTypes.appInfoMismatch); + + expect(updateAppInfoMock).not.toHaveBeenCalled(); + }); + }); + + it('should execute successfully if info about chain ID matches the chain ID configured in env variables and locator address stored in the DB matches the locator address returned by locator service, and keys, modules, or operators tables have some info in the DB', async () => { + jest.spyOn(registryKeyStorageService, 'find').mockImplementationOnce(() => Promise.resolve([keyFixture])); + + jest + .spyOn(moduleStorageService, 'findOneByModuleId') + .mockImplementationOnce(() => Promise.resolve(holeskyCuratedModuleFixture)); + + jest.spyOn(operatorStorageService, 'find').mockImplementationOnce(() => Promise.resolve([operatorFixture])); + + const updateAppInfoMock = jest.spyOn(appInfoStorageService, 'update'); + + await expect(networkValidationService.validate()).resolves.not.toThrow(); + expect(updateAppInfoMock).not.toHaveBeenCalled(); + }); + + it("should throw error if modules table is not empty in the DB, but the keys table is empty, and DB doesn't have information about chain ID and locator", async () => { + jest + .spyOn(moduleStorageService, 'findOneByModuleId') + .mockImplementationOnce(() => Promise.resolve(holeskyCuratedModuleFixture)); + + jest.spyOn(appInfoStorageService, 'get').mockImplementationOnce(() => Promise.resolve(null)); + + const updateAppInfoMock = jest.spyOn(appInfoStorageService, 'update'); + + return networkValidationService.validate().catch((error: Error) => { + expect(error).toBeInstanceOf(InconsistentDataInDBError); + expect(error).toHaveProperty('type'); + + const dbDataError = error as InconsistentDataInDBError; + expect([InconsistentDataInDBErrorTypes.emptyKeys, InconsistentDataInDBErrorTypes.emptyOperators]).toContain( + dbDataError.type, + ); + expect(dbDataError.type).not.toEqual(InconsistentDataInDBErrorTypes.emptyModules); + + expect(updateAppInfoMock).not.toHaveBeenCalled(); + }); + }); + + it("should throw error if operators table is not empty in the DB, but the keys table is empty, and DB doesn't have information about chain ID and locator", async () => { + jest.spyOn(operatorStorageService, 'find').mockImplementationOnce(() => Promise.resolve([operatorFixture])); + + jest.spyOn(appInfoStorageService, 'get').mockImplementationOnce(() => Promise.resolve(null)); + + const updateAppInfoMock = jest.spyOn(appInfoStorageService, 'update'); + + return networkValidationService.validate().catch((error: Error) => { + expect(error).toBeInstanceOf(InconsistentDataInDBError); + expect(error).toHaveProperty('type'); + + const dbDataError = error as InconsistentDataInDBError; + expect([InconsistentDataInDBErrorTypes.emptyKeys, InconsistentDataInDBErrorTypes.emptyModules]).toContain( + dbDataError.type, + ); + expect(dbDataError.type).not.toEqual(InconsistentDataInDBErrorTypes.emptyOperators); + + expect(updateAppInfoMock).not.toHaveBeenCalled(); + }); + }); + + it("should throw error if keys table is not empty in the DB, but the module table is empty, and DB doesn't have information about chain ID and locator", async () => { + jest.spyOn(registryKeyStorageService, 'find').mockImplementationOnce(() => Promise.resolve([keyFixture])); + + jest.spyOn(appInfoStorageService, 'get').mockImplementationOnce(() => Promise.resolve(null)); + + const updateAppInfoMock = jest.spyOn(appInfoStorageService, 'update'); + + return networkValidationService.validate().catch((error: Error) => { + expect(error).toBeInstanceOf(InconsistentDataInDBError); + expect(error).toHaveProperty('type'); + + const dbDataError = error as InconsistentDataInDBError; + expect([InconsistentDataInDBErrorTypes.emptyModules, InconsistentDataInDBErrorTypes.emptyOperators]).toContain( + dbDataError.type, + ); + expect(dbDataError.type).not.toEqual(InconsistentDataInDBErrorTypes.emptyKeys); + + expect(updateAppInfoMock).not.toHaveBeenCalled(); + }); + }); + + it("should throw error if operators table is not empty in the DB, but the module table is empty, and DB doesn't have information about chain ID and locator", async () => { + jest.spyOn(operatorStorageService, 'find').mockImplementationOnce(() => Promise.resolve([operatorFixture])); + + jest.spyOn(appInfoStorageService, 'get').mockImplementationOnce(() => Promise.resolve(null)); + + const updateAppInfoMock = jest.spyOn(appInfoStorageService, 'update'); + + return networkValidationService.validate().catch((error: Error) => { + expect(error).toBeInstanceOf(InconsistentDataInDBError); + expect(error).toHaveProperty('type'); + + const dbDataError = error as InconsistentDataInDBError; + expect([InconsistentDataInDBErrorTypes.emptyKeys, InconsistentDataInDBErrorTypes.emptyModules]).toContain( + dbDataError.type, + ); + expect(dbDataError.type).not.toEqual(InconsistentDataInDBErrorTypes.emptyOperators); + + expect(updateAppInfoMock).not.toHaveBeenCalled(); + }); + }); + + it("should throw error if keys table is not empty in the DB, but the operators table is empty, and DB doesn't have information about chain ID and locator", async () => { + jest.spyOn(registryKeyStorageService, 'find').mockImplementationOnce(() => Promise.resolve([keyFixture])); + + jest.spyOn(appInfoStorageService, 'get').mockImplementationOnce(() => Promise.resolve(null)); + + const updateAppInfoMock = jest.spyOn(appInfoStorageService, 'update'); + + return networkValidationService.validate().catch((error: Error) => { + expect(error).toBeInstanceOf(InconsistentDataInDBError); + expect(error).toHaveProperty('type'); + + const dbDataError = error as InconsistentDataInDBError; + expect([InconsistentDataInDBErrorTypes.emptyModules, InconsistentDataInDBErrorTypes.emptyOperators]).toContain( + dbDataError.type, + ); + expect(dbDataError.type).not.toEqual(InconsistentDataInDBErrorTypes.emptyKeys); + + expect(updateAppInfoMock).not.toHaveBeenCalled(); + }); + }); + + it("should throw error if modules table is not empty in the DB, but the operators table is empty, and DB doesn't have information about chain ID and locator", async () => { + jest + .spyOn(moduleStorageService, 'findOneByModuleId') + .mockImplementationOnce(() => Promise.resolve(holeskyCuratedModuleFixture)); + + jest.spyOn(appInfoStorageService, 'get').mockImplementationOnce(() => Promise.resolve(null)); + + const updateAppInfoMock = jest.spyOn(appInfoStorageService, 'update'); + + return networkValidationService.validate().catch((error: Error) => { + expect(error).toBeInstanceOf(InconsistentDataInDBError); + expect(error).toHaveProperty('type'); + + const dbDataError = error as InconsistentDataInDBError; + expect([InconsistentDataInDBErrorTypes.emptyKeys, InconsistentDataInDBErrorTypes.emptyOperators]).toContain( + dbDataError.type, + ); + expect(dbDataError.type).not.toEqual(InconsistentDataInDBErrorTypes.emptyModules); + + expect(updateAppInfoMock).not.toHaveBeenCalled(); + }); + }); + + it("should throw error if DB has information about keys, modules and operators, but doesn't have information about chain and locator, and address of the curated module stored in the DB doesn't match the correct address of the curated module for the chain for which the app was started", async () => { + jest.spyOn(registryKeyStorageService, 'find').mockImplementationOnce(() => Promise.resolve([keyFixture])); + + const mainnetCuratedModuleFixture = { + ...holeskyCuratedModuleFixture, + stakingModuleAddress: REGISTRY_CONTRACT_ADDRESSES[1].toLowerCase(), + }; + + jest + .spyOn(moduleStorageService, 'findOneByModuleId') + .mockImplementationOnce(() => Promise.resolve(mainnetCuratedModuleFixture)); + + jest.spyOn(operatorStorageService, 'find').mockImplementationOnce(() => Promise.resolve([operatorFixture])); + + jest.spyOn(appInfoStorageService, 'get').mockImplementationOnce(() => Promise.resolve(null)); + + const updateAppInfoMock = jest.spyOn(appInfoStorageService, 'update'); + + return networkValidationService.validate().catch((error: Error) => { + expect(error).toBeInstanceOf(InconsistentDataInDBError); + expect(error).toHaveProperty('type', InconsistentDataInDBErrorTypes.curatedModuleAddressMismatch); + + expect(updateAppInfoMock).not.toHaveBeenCalled(); + }); + }); + + it("should throw error if DB has information about keys, modules and operators, but doesn't have information about chain and locator, and address of the curated module stored in the DB doesn't match the correct address of the curated module for the chain for which the app was started", async () => { + jest.spyOn(configService, 'get').mockImplementation((path) => { + if (path === 'VALIDATOR_REGISTRY_ENABLE') { + return false; + } + + if (path === 'CHAIN_ID') { + return 1; + } + + return configService.get(path); + }); + + jest.spyOn(executionProviderService, 'getChainId').mockImplementationOnce(() => Promise.resolve(1)); + + jest.spyOn(registryKeyStorageService, 'find').mockImplementationOnce(() => Promise.resolve([keyFixture])); + + jest + .spyOn(moduleStorageService, 'findOneByModuleId') + .mockImplementationOnce(() => Promise.resolve(holeskyCuratedModuleFixture)); + + jest.spyOn(operatorStorageService, 'find').mockImplementationOnce(() => Promise.resolve([operatorFixture])); + + jest.spyOn(appInfoStorageService, 'get').mockImplementationOnce(() => Promise.resolve(null)); + + const updateAppInfoMock = jest.spyOn(appInfoStorageService, 'update'); + + return networkValidationService.validate().catch((error: Error) => { + expect(error).toBeInstanceOf(InconsistentDataInDBError); + expect(error).toHaveProperty('type', InconsistentDataInDBErrorTypes.curatedModuleAddressMismatch); + + expect(updateAppInfoMock).not.toHaveBeenCalled(); + }); + }); + + it("should execute successfully if DB has information about keys, modules, and operators, doesn't have information about chain and locator, and address of the curated module stored in the DB matches the address of the curated module for the chain for which the app was started", async () => { + jest.spyOn(registryKeyStorageService, 'find').mockImplementationOnce(() => Promise.resolve([keyFixture])); + + jest + .spyOn(moduleStorageService, 'findOneByModuleId') + .mockImplementationOnce(() => Promise.resolve(holeskyCuratedModuleFixture)); + + jest.spyOn(operatorStorageService, 'find').mockImplementationOnce(() => Promise.resolve([operatorFixture])); + + jest.spyOn(appInfoStorageService, 'get').mockImplementationOnce(() => Promise.resolve(null)); + + const updateAppInfoMock = jest.spyOn(appInfoStorageService, 'update'); + + await expect(networkValidationService.validate()).resolves.not.toThrow(); + expect(updateAppInfoMock).toBeCalledTimes(1); + expect(updateAppInfoMock).toBeCalledWith({ + chainId: 17000, + locatorAddress: '0x17000', + }); + }); +}); diff --git a/src/network-validation/network-validation.service.ts b/src/network-validation/network-validation.service.ts new file mode 100644 index 00000000..adb916e1 --- /dev/null +++ b/src/network-validation/network-validation.service.ts @@ -0,0 +1,170 @@ +import { MikroORM, UseRequestContext } from '@mikro-orm/core'; +import { Inject, Injectable, LoggerService } from '@nestjs/common'; +import { LidoLocator, LIDO_LOCATOR_CONTRACT_TOKEN } from '@lido-nestjs/contracts'; +import { LOGGER_PROVIDER } from '@lido-nestjs/logger'; +import { REGISTRY_CONTRACT_ADDRESSES } from '@lido-nestjs/contracts'; +import { ConfigService } from '../common/config'; +import { ConsensusProviderService } from '../common/consensus-provider'; +import { ExecutionProviderService } from '../common/execution-provider'; +import { RegistryKeyStorageService, RegistryOperatorStorageService } from '../common/registry'; +import { SRModuleStorageService } from '../storage/sr-module.storage'; +import { AppInfoStorageService } from '../storage/app-info.storage'; + +export enum InconsistentDataInDBErrorTypes { + appInfoMismatch = 'APP_INFO_TABLE_DATA_MISMATCH_ERROR', + emptyKeys = 'EMPTY_KEYS_TABLE_ERROR', + emptyModules = 'EMPTY_MODULES_TABLE_ERROR', + emptyOperators = 'EMPTY_OPERATORS_TABLE_ERROR', + curatedModuleAddressMismatch = 'CURATED_MODULE_ADDRESS_MISMATCH', +} + +export class ChainMismatchError extends Error { + configChainId: number; + elChainId: number; + clChainId: number | undefined; + + constructor(message: string, configChainId: number, elChainId: number, clChainId?: number | undefined) { + super(message); + this.configChainId = configChainId; + this.elChainId = elChainId; + this.clChainId = clChainId; + } +} + +export class InconsistentDataInDBError extends Error { + type: InconsistentDataInDBErrorTypes; + + constructor(message: string, type: InconsistentDataInDBErrorTypes) { + super(message); + this.type = type; + } +} + +@Injectable() +export class NetworkValidationService { + constructor( + protected readonly orm: MikroORM, + @Inject(LIDO_LOCATOR_CONTRACT_TOKEN) protected readonly locatorContract: LidoLocator, + @Inject(LOGGER_PROVIDER) protected readonly logger: LoggerService, + protected readonly configService: ConfigService, + protected readonly consensusProviderService: ConsensusProviderService, + protected readonly executionProviderService: ExecutionProviderService, + protected readonly keyStorageService: RegistryKeyStorageService, + protected readonly moduleStorageService: SRModuleStorageService, + protected readonly operatorStorageService: RegistryOperatorStorageService, + protected readonly appInfoStorageService: AppInfoStorageService, + ) {} + + @UseRequestContext() + public async validate(): Promise { + const configChainId = this.configService.get('CHAIN_ID'); + const elChainId = await this.executionProviderService.getChainId(); + + await this.checkChainIdMismatch(configChainId, elChainId); + + const [dbKeys, dbCuratedModule, dbOperators] = await Promise.all([ + await this.keyStorageService.find({}, { limit: 1 }), + await this.moduleStorageService.findOneByModuleId(1), + await this.operatorStorageService.find({}, { limit: 1 }), + ]); + + /** + * @note If all these 3 tables are empty, it is assumed that the service is run on the clean DB and it is safe to + * store info about the chain ID for which the service is run to tie information about the chain ID and locator to + * keys, modules, and operators that will be downloaded and stored into the DB. + */ + if (dbKeys.length === 0 && dbCuratedModule == null && dbOperators.length === 0) { + this.logger.log('DB is empty, write chain info into DB'); + return await this.appInfoStorageService.update({ + chainId: configChainId, + locatorAddress: this.locatorContract.address, + }); + } + + const appInfo = await this.appInfoStorageService.get(); + + if (appInfo != null) { + /** + * @note If the app info table has information about the chain and locator, and this information doesn't match the + * chain specified in the env variables, it indicates that the service was already run and saved to the DB info + * for one chain, and now it is going to run for another chain. This case is detected here to prevent corruption + * of data in the DB. + */ + if (appInfo.chainId !== configChainId || appInfo.locatorAddress !== this.locatorContract.address) { + throw new InconsistentDataInDBError( + `Chain configuration mismatch. Database is not empty and contains information for chain ${appInfo.chainId} and locator address ${appInfo.locatorAddress}, but the service is trying to start for chain ${configChainId} and locator address ${this.locatorContract.address}`, + InconsistentDataInDBErrorTypes.appInfoMismatch, + ); + } + + return; + } + + if (dbKeys.length === 0) { + throw new InconsistentDataInDBError( + 'Inconsistent data in database. Keys table is empty, but other tables are not empty.', + InconsistentDataInDBErrorTypes.emptyKeys, + ); + } + + if (dbCuratedModule == null) { + throw new InconsistentDataInDBError( + 'Inconsistent data in database. Modules table is empty, but other tables are not empty.', + InconsistentDataInDBErrorTypes.emptyModules, + ); + } + + if (dbOperators.length === 0) { + throw new InconsistentDataInDBError( + 'Inconsistent data in database. Operators table is empty, but other tables are not empty.', + InconsistentDataInDBErrorTypes.emptyOperators, + ); + } + + /** + * @note If the service is upgraded to the new version (so that in the previous version there was no "app_info" + * table and this sanity checker service, but in the new version it appears), it has information in the DB. If the + * service starts after the version upgrade with an incorrect chain ID specified in the env variables, it will lead + * to data corruption. To prevent this case, this code checks that the curated module stored in the DB has the + * correct address for the chain specified in the env variables. + */ + if (dbCuratedModule.stakingModuleAddress !== REGISTRY_CONTRACT_ADDRESSES[configChainId].toLowerCase()) { + throw new InconsistentDataInDBError( + `Chain configuration mismatch. Service is trying to start for chain ${configChainId}, but DB contains data for another chain.`, + InconsistentDataInDBErrorTypes.curatedModuleAddressMismatch, + ); + } + + /** + * @note If the service is upgraded to the new version, it doesn't have the "app_info" table yet, but the curated + * module stored in the DB has the correct address for the chain specified in env variables, it's pretty safe to + * assume that the service is run with the correct config. In this case, the service just stores the information + * about chain ID and locator for which it is run into the DB. + */ + this.logger.log('DB is not empty and chain info is not found in DB, write chain info into DB'); + await this.appInfoStorageService.update({ + chainId: configChainId, + locatorAddress: this.locatorContract.address, + }); + } + + private async checkChainIdMismatch(configChainId: number, elChainId: number): Promise { + if (configChainId !== elChainId) { + throw new ChainMismatchError("Chain ID in the config doesn't match EL chain ID", configChainId, elChainId); + } + + if (this.configService.get('VALIDATOR_REGISTRY_ENABLE')) { + const depositContract = await this.consensusProviderService.getDepositContract(); + const clChainId = Number(depositContract.data?.chain_id); + + if (elChainId !== clChainId) { + throw new ChainMismatchError( + 'Execution and consensus chain IDs do not match', + configChainId, + elChainId, + clChainId, + ); + } + } + } +} diff --git a/src/staking-router-modules/staking-router.service.ts b/src/staking-router-modules/staking-router.service.ts index 02eb93f0..6a8066b9 100644 --- a/src/staking-router-modules/staking-router.service.ts +++ b/src/staking-router-modules/staking-router.service.ts @@ -43,7 +43,7 @@ export class StakingRouterService { return await this.srModulesStorage.findOneByContractAddress(moduleId); } - return await this.srModulesStorage.findOneById(moduleId); + return await this.srModulesStorage.findOneByModuleId(moduleId); } public getStakingRouterModuleImpl(moduleType: string): StakingModuleInterface { diff --git a/src/storage/app-info.entity.ts b/src/storage/app-info.entity.ts new file mode 100644 index 00000000..bf33973f --- /dev/null +++ b/src/storage/app-info.entity.ts @@ -0,0 +1,22 @@ +import { Entity, EntityRepositoryType, PrimaryKey, Property, Unique } from '@mikro-orm/core'; +import { ADDRESS_LEN } from '../common/registry/storage/constants'; +import { AppInfoRepository } from './app-info.repository'; + +@Entity({ customRepository: () => AppInfoRepository }) +export class AppInfoEntity { + [EntityRepositoryType]?: AppInfoRepository; + + constructor(info: AppInfoEntity) { + this.chainId = info.chainId; + this.locatorAddress = info.locatorAddress; + } + + @PrimaryKey() + chainId: number; + + @Unique() + @Property({ + length: ADDRESS_LEN, + }) + locatorAddress: string; +} diff --git a/src/storage/app-info.repository.ts b/src/storage/app-info.repository.ts new file mode 100644 index 00000000..9ccc26c5 --- /dev/null +++ b/src/storage/app-info.repository.ts @@ -0,0 +1,4 @@ +import { EntityRepository } from '@mikro-orm/knex'; +import { AppInfoEntity } from './app-info.entity'; + +export class AppInfoRepository extends EntityRepository {} diff --git a/src/storage/app-info.storage.ts b/src/storage/app-info.storage.ts new file mode 100644 index 00000000..e25fb77d --- /dev/null +++ b/src/storage/app-info.storage.ts @@ -0,0 +1,28 @@ +import { Injectable } from '@nestjs/common'; +import { AppInfoEntity } from './app-info.entity'; +import { AppInfoRepository } from './app-info.repository'; + +@Injectable() +export class AppInfoStorageService { + constructor(private readonly repository: AppInfoRepository) {} + + async get(): Promise { + const result = await this.repository.find({}, { limit: 1 }); + return result[0] ?? null; + } + + async update(appInfo: AppInfoEntity): Promise { + await this.repository.nativeDelete({}); + await this.repository.persist( + new AppInfoEntity({ + chainId: appInfo.chainId, + locatorAddress: appInfo.locatorAddress, + }), + ); + await this.repository.flush(); + } + + async removeAll() { + return await this.repository.nativeDelete({}); + } +} diff --git a/src/storage/sr-module.storage.ts b/src/storage/sr-module.storage.ts index 85bc1a87..f9602b81 100644 --- a/src/storage/sr-module.storage.ts +++ b/src/storage/sr-module.storage.ts @@ -7,8 +7,7 @@ import { SRModuleRepository } from './sr-module.repository'; export class SRModuleStorageService { constructor(private readonly repository: SRModuleRepository) {} - /** find key by index */ - async findOneById(moduleId: number): Promise { + async findOneByModuleId(moduleId: number): Promise { return await this.repository.findOne({ moduleId }); } diff --git a/src/storage/storage.module.ts b/src/storage/storage.module.ts index e03baa86..13502d1e 100644 --- a/src/storage/storage.module.ts +++ b/src/storage/storage.module.ts @@ -4,15 +4,17 @@ import { ElMetaEntity } from './el-meta.entity'; import { ElMetaStorageService } from './el-meta.storage'; import { SrModuleEntity } from './sr-module.entity'; import { SRModuleStorageService } from './sr-module.storage'; +import { AppInfoEntity } from './app-info.entity'; +import { AppInfoStorageService } from './app-info.storage'; @Global() @Module({ imports: [ MikroOrmModule.forFeature({ - entities: [SrModuleEntity, ElMetaEntity], + entities: [SrModuleEntity, ElMetaEntity, AppInfoEntity], }), ], - providers: [SRModuleStorageService, ElMetaStorageService], - exports: [SRModuleStorageService, ElMetaStorageService], + providers: [SRModuleStorageService, ElMetaStorageService, AppInfoStorageService], + exports: [SRModuleStorageService, ElMetaStorageService, AppInfoStorageService], }) export class StorageModule {}