Skip to content

Commit

Permalink
fix: review
Browse files Browse the repository at this point in the history
  • Loading branch information
vgorkavenko committed Mar 4, 2024
1 parent 7a57121 commit 35a294f
Show file tree
Hide file tree
Showing 9 changed files with 81 additions and 70 deletions.
19 changes: 9 additions & 10 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
# CLI working mode
#EL_RPC_URLS=https://mainnet.infura.io/v3/...
#CL_API_URLS=https://quiknode.pro/...
#TX_SENDER_PRIVATE_KEY=...
ETH_NETWORK=1
EL_RPC_URLS=https://mainnet.infura.io/v3/...
CL_API_URLS=https://quiknode.pro/...
LIDO_STAKING_MODULE_ADDRESS=0x9D4AF1Ee19Dad8857db3a45B0374c81c8A1C6320

# Daemon working mode
#ETH_NETWORK=1
#EL_RPC_URLS=https://mainnet.infura.io/v3/...
#CL_API_URLS=https://quiknode.pro/...
#KEYSAPI_API_URLS=https://keys-api.lido.fi/
#LIDO_STAKING_MODULE_ADDRESS="0x9D4AF1Ee19Dad8857db3a45B0374c81c8A1C6320"
#TX_SENDER_PRIVATE_KEY=...

ETH_NETWORK=1
EL_RPC_URLS=https://mainnet.infura.io/v3/...
CL_API_URLS=https://quiknode.pro/...
KEYSAPI_API_URLS=https://keys-api.lido.fi/
LIDO_STAKING_MODULE_ADDRESS=0x9D4AF1Ee19Dad8857db3a45B0374c81c8A1C6320
20 changes: 10 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,23 +13,23 @@ The tool is a daemon that listens to the CL and reports any slashings and withdr

The algorithm is as follows:
0. Get the current CL finalized head.
1. Run `KeysIndexer` service to get the current validator set of the CS Module.
> It is necessary at the first startup. All subsequent runs of the indexer will be performed when necessary and independently of the main processing*
2. Choose the next block service to process from `RootsProvider`.
1. Get the current validator set of the CS Module.
> It is necessary at the first startup. All subsequent runs of the indexer will be performed when necessary and independently of the main processing
2. Choose the next block service to process.
> The provider chooses the next root with the following priority:
> - Return the root from `RootsStack` service if exists and `KeyIndexer` is helthy enougth to be trusted completely to process this root
> - *When no any processed roots yet* Return a configured root (from `.env` file) or the last finalized root
> - Return the root from roots stack if exists and keys indexer is healthy enough to be trusted completely to process this root
> - *When no any processed roots yet* Return `START_ROOT` or the last finalized root if `START_ROOT` is not set
> - Return a finalized child root of the last processed root
> - Sleep 12s if nothing to process and **return to the step 0**
3. Run `RootsProcessor` service to process the root.
3. Process the root.
> The processor does the following:
> - Get the block info from CL by the root
> - If the current state of `KeysIndexer` is outdated (~15-27h behind from the block) to be trusted completely, add the block root to `RootsStack`
> - If the current state of keys indexer is outdated (~15-27h behind from the block) to be trusted completely, add the block root to roots stack
> - If the block has a slashing or withdrawal, report it to the CS Module
> - If the current state of `KeysIndexer` is helthy enougth to be trusted completely, remove the root from `RootsStack`
> - If the current state of keys indexer is healthy enough to be trusted completely, remove the root from roots stack
So, according to the algorithm, there are these statements:
1. We always go sequentially by the finalized routs of blocks, taking the next one by the root of the previous one. In this way we avoid missing some blocks.
So, according to the algorithm, there are the following statements:
1. We always go sequentially by the finalized roots of blocks, taking the next one by the root of the previous one. In this way, we avoid missing any blocks.
2. If for some reason the daemon crashes, it will start from the last root running before the crash when it is launched
3. If for some reason KeysAPI crashed or CL node stopped giving validators, we can use the previously successfully received data to guarantee that our slashings will report for another ~15h and withdrawals for ~27h (because of the new validators appearing time and `MIN_VALIDATOR_WITHDRAWABILITY_DELAY`)
If any of these time thresholds are breached, we can't be sure that if there was a slashing or a full withdrawal there was definitely not our validator there. That's why we put the root block in the stack just in case, to process it again later when KeysAPI and CL node are well.
Expand Down
20 changes: 14 additions & 6 deletions src/common/config/env.validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import {
IsInt,
IsNotEmpty,
IsNumber,
IsOptional,
IsString,
Max,
Min,
validateSync,
Expand All @@ -25,27 +27,33 @@ export enum WorkingMode {
CLI = 'cli',
}

const MINUTE = 60 * 1000;
const HOUR = 60 * MINUTE;

export class EnvironmentVariables {
@IsEnum(Environment)
NODE_ENV: Environment = Environment.Development;

@IsEnum(WorkingMode)
public WORKING_MODE = WorkingMode.Daemon;

@IsOptional()
@IsString()
public START_ROOT?: string;

@IsNotEmpty()
@IsString()
public LIDO_STAKING_MODULE_ADDRESS: string;

@IsNumber()
@Min(30 * 60 * 1000)
@Min(30 * MINUTE)
@Transform(({ value }) => parseInt(value, 10), { toClassOnly: true })
public KEYS_INDEXER_RUNNING_PERIOD: number = 3 * 60 * 60 * 1000;
public KEYS_INDEXER_RUNNING_PERIOD_MS: number = 3 * HOUR;

@IsNumber()
@Min(384000) // epoch time in ms
@Transform(({ value }) => parseInt(value, 10), { toClassOnly: true })
public KEYS_INDEXER_KEYAPI_FRESHNESS_PERIOD: number = 8 * 60 * 60 * 1000;
public KEYS_INDEXER_KEYAPI_FRESHNESS_PERIOD_MS: number = 8 * HOUR;

@IsNumber()
@Min(1025)
Expand Down Expand Up @@ -81,7 +89,7 @@ export class EnvironmentVariables {
@IsNumber()
@Min(1000)
@Transform(({ value }) => parseInt(value, 10), { toClassOnly: true })
public EL_RPC_RESPONSE_TIMEOUT = 60000;
public EL_RPC_RESPONSE_TIMEOUT_MS = MINUTE;

@IsNumber()
@Transform(({ value }) => parseInt(value, 10), { toClassOnly: true })
Expand All @@ -99,7 +107,7 @@ export class EnvironmentVariables {
@IsNumber()
@Min(1000)
@Transform(({ value }) => parseInt(value, 10), { toClassOnly: true })
public CL_API_RESPONSE_TIMEOUT = 60000;
public CL_API_RESPONSE_TIMEOUT_MS = MINUTE;

@IsNumber()
@Transform(({ value }) => parseInt(value, 10), { toClassOnly: true })
Expand All @@ -117,7 +125,7 @@ export class EnvironmentVariables {
@IsNumber()
@Min(1000)
@Transform(({ value }) => parseInt(value, 10), { toClassOnly: true })
public KEYSAPI_API_RESPONSE_TIMEOUT = 60000;
public KEYSAPI_API_RESPONSE_TIMEOUT_MS = MINUTE;

@IsNumber()
@Transform(({ value }) => parseInt(value, 10), { toClassOnly: true })
Expand Down
4 changes: 2 additions & 2 deletions src/common/providers/consensus/consensus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ export class Consensus extends BaseRestProvider implements OnModuleInit {
) {
super(
config.get('CL_API_URLS') as Array<string>,
config.get('CL_API_RESPONSE_TIMEOUT'),
config.get('CL_API_RESPONSE_TIMEOUT_MS'),
config.get('CL_API_MAX_RETRIES'),
logger,
prometheus,
Expand Down Expand Up @@ -100,6 +100,6 @@ export class Consensus extends BaseRestProvider implements OnModuleInit {
// Data processing
const bodyBites = new Uint8Array(await body.arrayBuffer());
// TODO: high memory usage
return ssz.allForks[version].BeaconState.deserializeToView(bodyBites);
return ssz[version].BeaconState.deserializeToView(bodyBites);
}
}
6 changes: 2 additions & 4 deletions src/common/providers/keysapi/keysapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,16 +18,14 @@ export class Keysapi extends BaseRestProvider {
findModuleKeys: (module_id: string | number): string => `v1/modules/${module_id}/keys/find`,
};

// TODO: types

constructor(
@Inject(LOGGER_PROVIDER) protected readonly logger: LoggerService,
@Optional() protected readonly prometheus: PrometheusService,
protected readonly config: ConfigService,
) {
super(
config.get('KEYSAPI_API_URLS') as Array<string>,
config.get('KEYSAPI_API_RESPONSE_TIMEOUT'),
config.get('KEYSAPI_API_RESPONSE_TIMEOUT_MS'),
config.get('KEYSAPI_API_MAX_RETRIES'),
logger,
prometheus,
Expand All @@ -37,7 +35,7 @@ export class Keysapi extends BaseRestProvider {
public healthCheck(finalizedTimestamp: number, keysApiMetadata: { elBlockSnapshot: ELBlockSnapshot }): void {
if (
finalizedTimestamp - keysApiMetadata.elBlockSnapshot.timestamp >
this.config.get('KEYS_INDEXER_KEYAPI_FRESHNESS_PERIOD')
this.config.get('KEYS_INDEXER_KEYAPI_FRESHNESS_PERIOD_MS')
) {
throw new Error('KeysApi is outdated');
}
Expand Down
8 changes: 4 additions & 4 deletions src/daemon/daemon.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,13 +38,13 @@ export class DaemonService implements OnApplicationBootstrap {
this.logger.log('🗿 Get finalized header');
const header = await this.consensus.getBeaconHeader('finalized');
this.logger.log(`💎 Finalized slot [${header.header.message.slot}]. Root [${header.root}]`);
await this.keysIndexer.update(header);
this.keysIndexer.update(header);
const nextRoot = await this.rootsProvider.getNext(header);
if (nextRoot) {
await this.rootsProcessor.process(nextRoot);
} else {
this.logger.log(`💤 Wait for the next finalized root`);
await sleep(12000);
return;
}
this.logger.log(`💤 Wait for the next finalized root`);
await sleep(12000);
}
}
50 changes: 28 additions & 22 deletions src/daemon/services/keys-indexer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,14 @@ import { BlockHeaderResponse, RootHex, Slot } from '../../common/providers/conse
import { Keysapi } from '../../common/providers/keysapi/keysapi';
import { Key, Module } from '../../common/providers/keysapi/response.interface';

type Info = {
type KeysIndexerServiceInfo = {
moduleAddress: string;
moduleId: number;
storageStateSlot: number;
lastValidatorsCount: number;
};

type Storage = {
type KeysIndexerServiceStorage = {
[valIndex: number]: KeyInfo;
};

Expand Down Expand Up @@ -54,8 +54,8 @@ function Single(target: any, propertyKey: string, descriptor: PropertyDescriptor
export class KeysIndexer implements OnModuleInit {
private startedAt: number = 0;

private info: Low<Info>;
private storage: Low<Storage>;
private info: Low<KeysIndexerServiceInfo>;
private storage: Low<KeysIndexerServiceStorage>;

constructor(
@Inject(LOGGER_PROVIDER) protected readonly logger: LoggerService,
Expand All @@ -73,7 +73,7 @@ export class KeysIndexer implements OnModuleInit {
};

@Single
public async update(finalizedHeader: BlockHeaderResponse): Promise<void> {
public update(finalizedHeader: BlockHeaderResponse): void {
// TODO: do we have to check integrity of data here? when `this.info` says one thing and `this.storage` another
const slot = Number(finalizedHeader.header.message.slot);
if (this.isNotTimeToRun(slot)) {
Expand Down Expand Up @@ -111,32 +111,32 @@ export class KeysIndexer implements OnModuleInit {
const storageTimestamp = this.consensus.slotToTimestamp(this.info.data.storageStateSlot) * 1000;
return (
this.info.data.storageStateSlot == finalizedSlot ||
this.config.get('KEYS_INDEXER_RUNNING_PERIOD') >= Date.now() - storageTimestamp
this.config.get('KEYS_INDEXER_RUNNING_PERIOD_MS') >= Date.now() - storageTimestamp
);
}

public eligibleForAnyDuty(slotNumber: Slot): boolean {
return this.eligibleForSlashings(slotNumber) || this.eligibleForFullWithdrawals(slotNumber);
public isTrustedForAnyDuty(slotNumber: Slot): boolean {
return this.isTrustedForSlashings(slotNumber) || this.isTrustedForFullWithdrawals(slotNumber);
}

public eligibleForEveryDuty(slotNumber: Slot): boolean {
const eligibleForSlashings = this.eligibleForSlashings(slotNumber);
const eligibleForFullWithdrawals = this.eligibleForFullWithdrawals(slotNumber);
if (!eligibleForSlashings)
public isTrustedForEveryDuty(slotNumber: Slot): boolean {
const trustedForSlashings = this.isTrustedForSlashings(slotNumber);
const trustedForFullWithdrawals = this.isTrustedForFullWithdrawals(slotNumber);
if (!trustedForSlashings)
this.logger.warn(
'🚨 Current keys indexer data might not be ready to detect slashing. ' +
'The root will be processed later again',
);
if (!eligibleForFullWithdrawals)
if (!trustedForFullWithdrawals)
this.logger.warn(
'⚠️ Current keys indexer data might not be ready to detect full withdrawal. ' +
'The root will be processed later again',
);
return eligibleForSlashings && eligibleForFullWithdrawals;
return trustedForSlashings && trustedForFullWithdrawals;
}

private eligibleForSlashings(slotNumber: Slot): boolean {
// We are ok with oudated indexer for detection slasing
private isTrustedForSlashings(slotNumber: Slot): boolean {
// We are ok with outdated indexer for detection slashing
// because of a bunch of delays between deposit and validator appearing
// TODO: get constants from node
const ETH1_FOLLOW_DISTANCE = 2048; // ~8 hours
Expand All @@ -146,8 +146,8 @@ export class KeysIndexer implements OnModuleInit {
return slotNumber - this.info.data.storageStateSlot <= safeDelay; // ~14.8 hours
}

private eligibleForFullWithdrawals(slotNumber: Slot): boolean {
// We are ok with oudated indexer for detection withdrawal
private isTrustedForFullWithdrawals(slotNumber: Slot): boolean {
// We are ok with outdated indexer for detection withdrawal
// because of MIN_VALIDATOR_WITHDRAWABILITY_DELAY
// TODO: get constants from node
const MIN_VALIDATOR_WITHDRAWABILITY_DELAY = 256;
Expand All @@ -157,14 +157,20 @@ export class KeysIndexer implements OnModuleInit {
}

private async initOrReadServiceData() {
const defaultInfo: Info = {
const defaultInfo: KeysIndexerServiceInfo = {
moduleAddress: this.config.get('LIDO_STAKING_MODULE_ADDRESS'),
moduleId: 0,
storageStateSlot: 0,
lastValidatorsCount: 0,
};
this.info = new Low<Info>(new JSONFile<Info>('.keys-indexer-info.json'), defaultInfo);
this.storage = new Low<Storage>(new JSONFile<Storage>('.keys-indexer-storage.json'), {});
this.info = new Low<KeysIndexerServiceInfo>(
new JSONFile<KeysIndexerServiceInfo>('.keys-indexer-info.json'),
defaultInfo,
);
this.storage = new Low<KeysIndexerServiceStorage>(
new JSONFile<KeysIndexerServiceStorage>('.keys-indexer-storage.json'),
{},
);
await this.info.read();
await this.storage.read();

Expand Down Expand Up @@ -202,7 +208,7 @@ export class KeysIndexer implements OnModuleInit {
);
for (let i = 0; i < validators.length; i++) {
const node = iterator.next().value;
const v = validators.type.elementType.tree_toValue(node);
const v = node.value;
const pubKey = '0x'.concat(Buffer.from(v.pubkey).toString('hex'));
const keyInfo = keysMap.get(pubKey);
if (!keyInfo) continue;
Expand Down
10 changes: 5 additions & 5 deletions src/daemon/services/roots-processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { LOGGER_PROVIDER } from '@lido-nestjs/logger';
import { Inject, Injectable, LoggerService } from '@nestjs/common';

import { KeysIndexer } from './keys-indexer';
import { RootsStack } from './roots-stack';
import { RootSlot, RootsStack } from './roots-stack';
import { HandlersService } from '../../common/handlers/handlers.service';
import { Consensus } from '../../common/providers/consensus/consensus';
import { RootHex } from '../../common/providers/consensus/response.interface';
Expand All @@ -20,14 +20,14 @@ export class RootsProcessor {
public async process(blockRoot: RootHex): Promise<void> {
this.logger.log(`🛃 Root in processing [${blockRoot}]`);
const blockInfo = await this.consensus.getBlockInfo(blockRoot);
const rootSlot = {
const rootSlot: RootSlot = {
blockRoot,
slotNumber: Number(blockInfo.message.slot),
};
const indexerIsOK = this.keysIndexer.eligibleForEveryDuty(rootSlot.slotNumber);
if (!indexerIsOK) await this.rootsStack.push(rootSlot); // only new will be pushed
const indexerIsTrusted = this.keysIndexer.isTrustedForEveryDuty(rootSlot.slotNumber);
if (!indexerIsTrusted) await this.rootsStack.push(rootSlot); // only new will be pushed
await this.handlers.proveIfNeeded(blockRoot, blockInfo, this.keysIndexer.getKey);
if (indexerIsOK) await this.rootsStack.purge(rootSlot);
if (indexerIsTrusted) await this.rootsStack.purge(rootSlot);
await this.rootsStack.setLastProcessed(rootSlot);
}
}
14 changes: 7 additions & 7 deletions src/daemon/services/roots-stack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,16 @@ import { RootHex } from '../../common/providers/consensus/response.interface';

export type RootSlot = { blockRoot: RootHex; slotNumber: number };

type Info = {
type RootsStackServiceInfo = {
lastProcessedRootSlot: RootSlot | undefined;
};

type Storage = { [slot: number]: RootHex };
type RootsStackServiceStorage = { [slot: number]: RootHex };

@Injectable()
export class RootsStack implements OnModuleInit {
private info: Low<Info>;
private storage: Low<Storage>;
private info: Low<RootsStackServiceInfo>;
private storage: Low<RootsStackServiceStorage>;

constructor(protected readonly keysIndexer: KeysIndexer) {}

Expand All @@ -26,7 +26,7 @@ export class RootsStack implements OnModuleInit {

public getNextEligible(): RootSlot | undefined {
for (const slot in this.storage.data) {
if (this.keysIndexer.eligibleForAnyDuty(Number(slot))) {
if (this.keysIndexer.isTrustedForAnyDuty(Number(slot))) {
return { blockRoot: this.storage.data[slot], slotNumber: Number(slot) };
}
}
Expand Down Expand Up @@ -54,10 +54,10 @@ export class RootsStack implements OnModuleInit {
}

private async initOrReadServiceData() {
this.info = new Low<Info>(new JSONFile('.roots-stack-info.json'), {
this.info = new Low<RootsStackServiceInfo>(new JSONFile('.roots-stack-info.json'), {
lastProcessedRootSlot: undefined,
});
this.storage = new Low<Storage>(new JSONFile('.roots-stack-storage.json'), {});
this.storage = new Low<RootsStackServiceStorage>(new JSONFile('.roots-stack-storage.json'), {});
await this.info.read();
await this.storage.read();
}
Expand Down

0 comments on commit 35a294f

Please sign in to comment.