Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: sanity checker for network config #279

Open
wants to merge 15 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 1 addition & 21 deletions src/app/app.service.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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<void> {
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<void> {
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 });
}
}
3 changes: 2 additions & 1 deletion src/jobs/jobs.module.ts
Original file line number Diff line number Diff line change
@@ -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 {}
6 changes: 6 additions & 0 deletions src/jobs/jobs.service.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -9,13 +10,18 @@ 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,
protected readonly prometheusService: PrometheusService,
) {}

public async onModuleInit(): Promise<void> {
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));
}
Expand Down
16 changes: 16 additions & 0 deletions src/migrations/Migration20240427195401.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { Migration } from '@mikro-orm/migrations';

export class Migration20240427195401 extends Migration {
async up(): Promise<void> {
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<void> {
this.addSql('drop table if exists "app_info_entity" cascade;');
}
}
2 changes: 2 additions & 0 deletions src/mikro-orm.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -126,6 +127,7 @@ const config: Options = {
ConsensusMetaEntity,
SrModuleEntity,
ElMetaEntity,
AppInfoEntity,
],
migrations: getMigrationOptions(path.join(__dirname, 'migrations'), ['@lido-nestjs/validators-registry']),
};
Expand Down
2 changes: 2 additions & 0 deletions src/network-validation/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './network-validation.module';
export * from './network-validation.service';
4 changes: 4 additions & 0 deletions src/network-validation/network-validation.constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export const MODULE_ADDRESSES_FOR_CHAINS = {
Copy link
Contributor

@Amuhar Amuhar Jun 6, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not clear which module is it . also we use contract addresses from lido-nestjs-modules

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agree. Will rename it to CURATED_MODULE_ADDRESSES_FOR_CHAINS.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I decided to get rid of this custom app-level constant and use the REGISTRY_CONTRACT_ADDRESSES constant from the "@lido-nestjs/contracts".

1: '0x55032650b14df07b85bf18a3a3ec8e0af2e028d5',
17000: '0x595f64ddc3856a3b5ff4f4cc1d1fb4b46cfd2bac',
};
8 changes: 8 additions & 0 deletions src/network-validation/network-validation.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { NetworkValidationService } from './network-validation.service';

@Module({
providers: [NetworkValidationService],
exports: [NetworkValidationService],
})
export class NetworkValidationModule {}
84 changes: 84 additions & 0 deletions src/network-validation/network-validation.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { MikroORM, UseRequestContext } from '@mikro-orm/core';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lets write tests on this service ?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK, good idea. Will do.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I implemented unit tests for the NetworkValidation service.

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 { 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 { MODULE_ADDRESSES_FOR_CHAINS } from './network-validation.constants';

@Injectable()
export class NetworkValidationService {
constructor(
protected readonly orm: MikroORM,
@Inject(LIDO_LOCATOR_CONTRACT_TOKEN) protected readonly contract: LidoLocator,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lets rename contract to locatorContract ?
ALso we have LidoLocatorService , maybe we should get address from there

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I renamed the contract to locatorContract.

As far as using the LidoLocatorService instead of the LidoLocator from "@lido-nestjs/contracts". It has only one getStakingRouter method to get the staking router address. And this method internally logs the contract.address from LidoLocator as the locator address.

I'm not sure, do you think that it is better to use the getStakingRouter method to get the locator address instead of the contract.address? If yes, why?

@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<void> {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

let's break the method into smaller methods

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I moved the logic of checking that chin ID from the env variables, EL chain ID, and CL chain ID match each other to the separate private method. Despite this change, I feel that it doesn't make much sense to split the logic of the validate method into smaller methods and functions. I think that the logic of this method is holistic and it would be harder to understand it if it is artificially split into separate methods and functions.
If you have any particular ideas about what parts of this code should be extracted to a separate method, let's discuss.

const configChainId = this.configService.get('CHAIN_ID');
const elChainId = await this.executionProviderService.getChainId();

if (this.configService.get('VALIDATOR_REGISTRY_ENABLE')) {
const depositContract = await this.consensusProviderService.getDepositContract();
const clChainId = Number(depositContract.data?.chain_id);

if (configChainId !== elChainId || elChainId !== clChainId) {
throw new Error('Execution and consensus chain ids do not match');
}
}

const [dbKey, dbCuratedModule, dbOperator] = await Promise.all([
Copy link
Contributor

@Amuhar Amuhar Jun 6, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

dbKeys, dbOperators?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Renamed, thansk.

await this.keyStorageService.find({}, { limit: 1 }),
await this.moduleStorageService.findOneById(1),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it is better to use constant

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As we agreed, I renamed the findOneById method from the SRModuleStorageService to findOneByModuleId. With the new method name, I don't see any problems with passing just "1" as the argument of this method. I think now it should be obvious that it is a module ID.

await this.operatorStorageService.find({}, { limit: 1 }),
]);

if (dbKey.length === 0 && dbCuratedModule == null && dbOperator.length === 0) {
this.logger.log('DB is empty, write chain info into DB');
return await this.appInfoStorageService.update({
chainId: configChainId,
locatorAddress: this.contract.address,
});
}

const appInfo = await this.appInfoStorageService.get();

if (appInfo != null) {
if (appInfo.chainId !== configChainId || appInfo.locatorAddress !== this.contract.address) {
throw new Error(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lets customize this error ?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I agree. I created two new error types: ChainMismatchError and InconsistentDataInDBErrorTypes. The first one is thrown if a chain ID from config, EL chain ID, and CL chain ID don't match each other. The second one is thrown if the chain ID for which the app is run doesn't match the info stored in the DB or data in the DB are corrupted.

`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.contract.address}`,
);
}

return;
}

if (dbCuratedModule == null) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

didn't understand why we check only one table

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As we didn't get into this if
if (dbKey.length === 0 && dbCuratedModule == null && dbOperator.length === 0)
it means the DB has non-empty keys, modules, or operators table. If the dbCuratedModule turns out null, the later code where we get dbCuratedModule.stakingModuleAddress will fail with an error. To prevent this we check that the dbCuratedModule is not null before getting the stakingModuleAddress key of this object.

I agree that having only the check for the dbCuratedModule emptiness and not checking dbKey and dbOperator emptiness may be confusing. Will add checkings of non-emptiness for these variables as well and throw the specific error in each of these cases.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've added two extra checks that keys and operators tables are not empty.

throw new Error('Inconsistent data in database. Some DB tables are empty, but some are not.');
}

if (dbCuratedModule.stakingModuleAddress !== MODULE_ADDRESSES_FOR_CHAINS[configChainId]) {
throw new Error(
`Chain configuration mismatch. Service is trying to start for chain ${configChainId}, but DB contains data for another chain.`,
);
}

this.logger.log('DB is not empty and chain info is not found in DB, write chain info into DB');
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's write a comment explaining in what cases this situation is possible

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK, I agree. I've added more explanation notes to each important case of the validate method.

await this.appInfoStorageService.update({
chainId: configChainId,
locatorAddress: this.contract.address,
});
}
}
22 changes: 22 additions & 0 deletions src/storage/app-info.entity.ts
Original file line number Diff line number Diff line change
@@ -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;
}
4 changes: 4 additions & 0 deletions src/storage/app-info.repository.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { EntityRepository } from '@mikro-orm/knex';
import { AppInfoEntity } from './app-info.entity';

export class AppInfoRepository extends EntityRepository<AppInfoEntity> {}
28 changes: 28 additions & 0 deletions src/storage/app-info.storage.ts
Original file line number Diff line number Diff line change
@@ -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<AppInfoEntity | null> {
const result = await this.repository.find({}, { limit: 1 });
return result[0] ?? null;
}

async update(appInfo: AppInfoEntity): Promise<void> {
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({});
}
}
8 changes: 5 additions & 3 deletions src/storage/storage.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}
Loading