Skip to content

Commit 14dc909

Browse files
authored
Merge pull request #253 from lidofinance/develop
Develop to main
2 parents 845d053 + 862e46b commit 14dc909

16 files changed

+453
-576
lines changed

how-estimation-works.md

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -85,14 +85,21 @@ where `unfinalized` is the amount of the withdrawal request considered summed wi
8585

8686
If there is not enough ether to fulfill the withdrawal request (`unfinalized > totalBuffer`), the previous case might be appended with the known validators are to be withdrawn (when the `withdrawable_epoch` is assigned).
8787

88-
It's needed to select the Lido-participating validators which are already in process of withdrawal and group them by `withdrawable_epoch` to `frameBalances`, allowing to find the oracle report frame containing enough funds from:
88+
It's needed to select the Lido-participating validators which are already in process of withdrawal and group them by calculated frame of expected withdrawal to `frameBalances`, allowing to find the oracle report frame containing enough funds from:
8989

9090
- buffer (`totalBuffer`)
9191
- projectedRewards (`rewardsPerEpoch * epochsTillTheFrame`)
92-
- frameBalances (`object { [frame]: [sum of balances of validators with withdrawable_epoch for certain frame] }`)
92+
- frameBalances (`object { [frame]: [sum of balances of validators with calculated withdrawal frame] }`)
9393

94-
So the final formula for that case looks like this:
95-
`frame (which has engough validator balances) + sweepingMean`. More about `sweepingMean` [here](#sweeping mean).
94+
#### Algorithm of calculation withdrawal frame of validators:
95+
96+
1. Withdrawals sweep cursor goes from 0 to the last validator index in infinite loop.
97+
2. When the cursor reaches a withdrawable validator, it withdraws ETH from that validator.
98+
3. The cursor can withdraw from a maximum of 16 validators per slot.
99+
4. We assume that all validators in network have to something to withdraw (partially or fully)
100+
5. `percentOfActiveValidators` is used to exclude inactive validators from the queue, ensuring more accurate calculations.
101+
6. Formula to get number of slots to wait is `(number of validators to withdraw before cursor get index of validator) / 16`
102+
7. By knowing number slots we can calculate frame of withdrawal
96103

97104
---
98105

package.json

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
"dependencies": {
2828
"@ethersproject/bignumber": "^5.7.0",
2929
"@ethersproject/units": "^5.7.0",
30-
"@fastify/static": "^6.6.0",
30+
"@fastify/static": "^7.0.4",
3131
"@lido-nestjs/consensus": "^1.5.0",
3232
"@lido-nestjs/constants": "^5.2.0",
3333
"@lido-nestjs/contracts": "^9.3.0",
@@ -37,12 +37,12 @@
3737
"@lido-nestjs/logger": "^1.0.1",
3838
"@lidofinance/satanizer": "^0.32.0",
3939
"@nestjs/cache-manager": "^2.2.2",
40-
"@nestjs/common": "^9.2.1",
40+
"@nestjs/common": "^10.4.4",
4141
"@nestjs/config": "^2.2.0",
42-
"@nestjs/core": "^9.2.1",
43-
"@nestjs/platform-fastify": "^9.2.1",
42+
"@nestjs/core": "^10.4.4",
43+
"@nestjs/platform-fastify": "^10.4.4",
4444
"@nestjs/schedule": "^2.2.0",
45-
"@nestjs/swagger": "^6.1.4",
45+
"@nestjs/swagger": "^7.4.2",
4646
"@nestjs/terminus": "^9.1.4",
4747
"@nestjs/throttler": "^6.0.0",
4848
"@sentry/node": "^7.29.0",
@@ -51,7 +51,6 @@
5151
"class-transformer": "^0.5.1",
5252
"class-validator": "^0.14.0",
5353
"ethers": "^6.13.2",
54-
"fastify-swagger": "^5.2.0",
5554
"node-abort-controller": "^3.0.1",
5655
"prom-client": "^14.1.1",
5756
"reflect-metadata": "^0.1.13",
@@ -62,8 +61,8 @@
6261
},
6362
"devDependencies": {
6463
"@nestjs/cli": "^10.4.4",
65-
"@nestjs/schematics": "^9.0.4",
66-
"@nestjs/testing": "^9.2.1",
64+
"@nestjs/schematics": "^10.1.4",
65+
"@nestjs/testing": "^10.4.4",
6766
"@types/cron": "^2.0.1",
6867
"@types/jest": "^29.2.5",
6968
"@types/node": "^18.15.11",
@@ -80,7 +79,7 @@
8079
"ts-loader": "^9.2.6",
8180
"ts-node": "^10.4.0",
8281
"tsconfig-paths": "^4.1.2",
83-
"typescript": "^4.5.4"
82+
"typescript": "^5.1.0"
8483
},
8584
"jest": {
8685
"moduleFileExtensions": [

src/common/execution-provider/execution-provider.service.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,17 @@
11
import { SimpleFallbackJsonRpcBatchProvider } from '@lido-nestjs/execution';
22
import { CHAINS } from '@lido-nestjs/constants';
33
import { Injectable } from '@nestjs/common';
4+
import { ethers } from 'ethers';
5+
import { ConfigService } from '@nestjs/config';
6+
import { PrometheusService } from '../prometheus';
47

58
@Injectable()
69
export class ExecutionProviderService {
7-
constructor(protected readonly provider: SimpleFallbackJsonRpcBatchProvider) {}
10+
constructor(
11+
protected readonly provider: SimpleFallbackJsonRpcBatchProvider,
12+
protected readonly prometheusService: PrometheusService,
13+
protected readonly configService: ConfigService,
14+
) {}
815

916
/**
1017
* Returns network name
@@ -22,4 +29,19 @@ export class ExecutionProviderService {
2229
const { chainId } = await this.provider.getNetwork();
2330
return chainId;
2431
}
32+
33+
// using ethers.JsonRpcProvider direct request to "eth_getBlockByNumber"
34+
// default @ethersproject provider getBlock does not contain "withdrawals" property
35+
public async getLatestWithdrawals(): Promise<Array<{ validatorIndex: string }>> {
36+
const endTimer = this.prometheusService.elRpcRequestDuration.startTimer();
37+
try {
38+
const provider = new ethers.JsonRpcProvider(this.configService.get('EL_RPC_URLS')[0]);
39+
const block = await provider.send('eth_getBlockByNumber', ['latest', false]);
40+
endTimer({ result: 'success' });
41+
return block.withdrawals;
42+
} catch (error) {
43+
endTimer({ result: 'error' });
44+
throw error;
45+
}
46+
}
2547
}

src/http/estimate/estimate.controller.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,9 @@ import {
55
HttpStatus,
66
UseInterceptors,
77
Version,
8-
CacheTTL,
98
Query,
109
} from '@nestjs/common';
10+
import { CacheTTL } from '@nestjs/cache-manager';
1111
import { ApiResponse, ApiTags } from '@nestjs/swagger';
1212
import { Throttle } from '@nestjs/throttler';
1313
import { HTTP_PATHS } from 'http/http.constants';

src/http/nft/nft.controller.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,11 @@ import {
55
HttpStatus,
66
UseInterceptors,
77
Version,
8-
CacheTTL,
98
Param,
109
Query,
1110
Header,
1211
} from '@nestjs/common';
12+
import { CacheTTL } from '@nestjs/cache-manager';
1313
import { ApiResponse, ApiTags } from '@nestjs/swagger';
1414
import { Throttle } from '@nestjs/throttler';
1515
import { HTTP_PATHS } from 'http/http.constants';

src/http/validators/validators.controller.ts

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,5 @@
1-
import {
2-
ClassSerializerInterceptor,
3-
Controller,
4-
Get,
5-
HttpStatus,
6-
UseInterceptors,
7-
Version,
8-
CacheTTL,
9-
} from '@nestjs/common';
1+
import { ClassSerializerInterceptor, Controller, Get, HttpStatus, UseInterceptors, Version } from '@nestjs/common';
2+
import { CacheTTL } from '@nestjs/cache-manager';
103
import { ApiResponse, ApiTags } from '@nestjs/swagger';
114
import { HTTP_PATHS } from 'http/http.constants';
125
import { ValidatorsService } from './validators.service';

src/http/validators/validators.service.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ export class ValidatorsService {
1515
const lastUpdatedAt = this.validatorsServiceStorage.getLastUpdate();
1616
const maxExitEpoch = Number(this.validatorsServiceStorage.getMaxExitEpoch());
1717
const frameBalancesBigNumber = this.validatorsServiceStorage.getFrameBalances();
18-
const totalValidators = this.validatorsServiceStorage.getTotal();
18+
const totalValidators = this.validatorsServiceStorage.getActiveValidatorsCount();
1919
const currentFrame = this.genesisTimeService.getFrameOfEpoch(this.genesisTimeService.getCurrentEpoch());
2020

2121
const frameBalances = Object.keys(frameBalancesBigNumber).reduce((acc, item) => {
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { BigNumber } from '@ethersproject/bignumber';
2+
import { WITHDRAWALS_VALIDATORS_PER_SLOT } from '../validators.constants';
3+
import { SECONDS_PER_SLOT } from 'common/genesis-time';
4+
5+
/*
6+
#### Algorithm of calculation withdrawal frame of validators:
7+
8+
1. Withdrawals sweep cursor goes from 0 to the last validator index in infinite loop.
9+
2. When the cursor reaches a withdrawable validator, it withdraws ETH from that validator.
10+
3. The cursor can withdraw from a maximum of 16 validators per slot.
11+
4. We assume that all validators in network have to something to withdraw (partially or fully)
12+
5. `percentOfActiveValidators` is used to exclude inactive validators from the queue, ensuring more accurate calculations.
13+
6. Formula to get number of slots to wait is `(number of validators to withdraw before cursor get index of validator) / 16`
14+
7. By knowing number slots we can calculate frame of withdrawal
15+
16+
Examples:
17+
18+
1. If the current cursor is 50 and the total number of validators is 100,
19+
then if we want to know when the validator with index 75 will be withdrawn:
20+
(75 - 50) / 16 = 2 slots.
21+
22+
2. If the current cursor is 50 and the total number of validators is 100,
23+
and we want to know when the validator with index 25 will be withdrawn
24+
(since the cursor will go to the end and start from 0):
25+
(100 - 50 + 25) / 16 = 5 slots.
26+
27+
*/
28+
export function getValidatorWithdrawalTimestamp(
29+
index: BigNumber,
30+
lastWithdrawalValidatorIndex: BigNumber,
31+
activeValidatorCount: number,
32+
totalValidatorsCount: number,
33+
) {
34+
const diff = index.sub(lastWithdrawalValidatorIndex);
35+
const percentOfActiveValidators = activeValidatorCount / totalValidatorsCount;
36+
const lengthQueueValidators = diff.lt(0)
37+
? BigNumber.from(activeValidatorCount).sub(lastWithdrawalValidatorIndex).add(index)
38+
: diff;
39+
40+
const slots = lengthQueueValidators.div(BigNumber.from(WITHDRAWALS_VALIDATORS_PER_SLOT));
41+
const seconds = slots.toNumber() * SECONDS_PER_SLOT * percentOfActiveValidators;
42+
43+
return Date.now() + seconds * 1000;
44+
}

src/jobs/validators/validators.constants.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,5 @@ export const ORACLE_REPORTS_CRON_BY_CHAIN_ID = {
99
[CHAINS.Mainnet]: '30 4/8 * * *', // 4 utc, 12 utc, 20 utc
1010
[CHAINS.Holesky]: CronExpression.EVERY_3_HOURS, // happens very often, not necessary sync in testnet
1111
};
12+
13+
export const WITHDRAWALS_VALIDATORS_PER_SLOT = 16;

src/jobs/validators/validators.service.ts

Lines changed: 26 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,20 +4,22 @@ import { LOGGER_PROVIDER, LoggerService } from 'common/logger';
44
import { JobService } from 'common/job';
55
import { ConfigService } from 'common/config';
66
import { ConsensusProviderService } from 'common/consensus-provider';
7+
import { ExecutionProviderService } from 'common/execution-provider';
78
import { GenesisTimeService } from 'common/genesis-time';
89
import { OneAtTime } from '@lido-nestjs/decorators';
910
import { ValidatorsStorageService } from 'storage';
1011
import { FAR_FUTURE_EPOCH, ORACLE_REPORTS_CRON_BY_CHAIN_ID, MAX_SEED_LOOKAHEAD } from './validators.constants';
1112
import { BigNumber } from '@ethersproject/bignumber';
1213
import { processValidatorsStream } from 'jobs/validators/utils/validators-stream';
13-
import { unblock } from '../../common/utils/unblock';
14+
import { unblock } from 'common/utils/unblock';
1415
import { LidoKeysService } from './lido-keys';
1516
import { ResponseValidatorsData, Validator } from './validators.types';
16-
import { parseGweiToWei } from '../../common/utils/parse-gwei-to-big-number';
17+
import { parseGweiToWei } from 'common/utils/parse-gwei-to-big-number';
1718
import { ValidatorsCacheService } from 'storage/validators/validators-cache.service';
1819
import { CronExpression } from '@nestjs/schedule';
19-
import { PrometheusService } from '../../common/prometheus';
20-
import { stringifyFrameBalances } from '../../common/validators/strigify-frame-balances';
20+
import { PrometheusService } from 'common/prometheus';
21+
import { stringifyFrameBalances } from 'common/validators/strigify-frame-balances';
22+
import { getValidatorWithdrawalTimestamp } from './utils/get-validator-withdrawal-timestamp';
2123

2224
export class ValidatorsService {
2325
static SERVICE_LOG_NAME = 'validators';
@@ -27,6 +29,7 @@ export class ValidatorsService {
2729

2830
protected readonly prometheusService: PrometheusService,
2931
protected readonly consensusProviderService: ConsensusProviderService,
32+
protected readonly executionProviderService: ExecutionProviderService,
3033
protected readonly configService: ConfigService,
3134
protected readonly jobService: JobService,
3235
protected readonly validatorsStorageService: ValidatorsStorageService,
@@ -65,12 +68,12 @@ export class ValidatorsService {
6568
const data: ResponseValidatorsData = await processValidatorsStream(stream);
6669
const currentEpoch = this.genesisTimeService.getCurrentEpoch();
6770

68-
let totalValidators = 0;
71+
let activeValidatorCount = 0;
6972
let latestEpoch = `${currentEpoch + MAX_SEED_LOOKAHEAD + 1}`;
7073

7174
for (const item of data) {
7275
if (['active_ongoing', 'active_exiting', 'active_slashed'].includes(item.status)) {
73-
totalValidators++;
76+
activeValidatorCount++;
7477
}
7578

7679
if (item.validator.exit_epoch !== FAR_FUTURE_EPOCH.toString()) {
@@ -81,7 +84,9 @@ export class ValidatorsService {
8184

8285
await unblock();
8386
}
84-
this.validatorsStorageService.setTotal(totalValidators);
87+
88+
this.validatorsStorageService.setActiveValidatorsCount(activeValidatorCount);
89+
this.validatorsStorageService.setTotalValidatorsCount(data.length);
8590
this.validatorsStorageService.setMaxExitEpoch(latestEpoch);
8691
this.validatorsStorageService.setLastUpdate(Math.floor(Date.now() / 1000));
8792

@@ -92,7 +97,7 @@ export class ValidatorsService {
9297
const currentFrame = this.genesisTimeService.getFrameOfEpoch(this.genesisTimeService.getCurrentEpoch());
9398
this.logger.log('End update validators', {
9499
service: ValidatorsService.SERVICE_LOG_NAME,
95-
totalValidators,
100+
activeValidatorCount,
96101
latestEpoch,
97102
frameBalances: stringifyFrameBalances(frameBalances),
98103
currentFrame,
@@ -113,12 +118,18 @@ export class ValidatorsService {
113118
protected async getLidoValidatorsWithdrawableBalances(validators: Validator[]) {
114119
const keysData = await this.lidoKeys.fetchLidoKeysData();
115120
const lidoValidators = await this.lidoKeys.getLidoValidatorsByKeys(keysData.data, validators);
116-
121+
const lastWithdrawalValidatorIndex = await this.getLastWithdrawalValidatorIndex();
117122
const frameBalances = {};
118123

119124
for (const item of lidoValidators) {
120125
if (item.validator.withdrawable_epoch !== FAR_FUTURE_EPOCH.toString() && BigNumber.from(item.balance).gt(0)) {
121-
const frame = this.genesisTimeService.getFrameOfEpoch(Number(item.validator.withdrawable_epoch));
126+
const withdrawalTimestamp = getValidatorWithdrawalTimestamp(
127+
BigNumber.from(item.index),
128+
lastWithdrawalValidatorIndex,
129+
this.validatorsStorageService.getActiveValidatorsCount(),
130+
this.validatorsStorageService.getTotalValidatorsCount(),
131+
);
132+
const frame = this.genesisTimeService.getFrameByTimestamp(withdrawalTimestamp) + 1;
122133
const prevBalance = frameBalances[frame];
123134
const balance = parseGweiToWei(item.balance);
124135
frameBalances[frame] = prevBalance ? prevBalance.add(balance) : BigNumber.from(balance);
@@ -129,4 +140,9 @@ export class ValidatorsService {
129140

130141
return frameBalances;
131142
}
143+
144+
protected async getLastWithdrawalValidatorIndex() {
145+
const withdrawals = await this.executionProviderService.getLatestWithdrawals();
146+
return BigNumber.from(withdrawals[withdrawals.length - 1].validatorIndex);
147+
}
132148
}

src/storage/validators/validators-cache.service.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ export class ValidatorsCacheService {
5050
return;
5151
}
5252

53-
this.validatorsStorage.setTotal(Number(data[0]));
53+
this.validatorsStorage.setActiveValidatorsCount(Number(data[0]));
5454
this.validatorsStorage.setMaxExitEpoch(data[1]);
5555
this.validatorsStorage.setLastUpdate(Number(data[2]));
5656
this.validatorsStorage.setFrameBalances(this.parseFrameBalances(data[3]));
@@ -73,7 +73,7 @@ export class ValidatorsCacheService {
7373

7474
await mkdir(ValidatorsCacheService.CACHE_DIR, { recursive: true });
7575
const data = [
76-
this.validatorsStorage.getTotal(),
76+
this.validatorsStorage.getActiveValidatorsCount(),
7777
this.validatorsStorage.getMaxExitEpoch(),
7878
this.validatorsStorage.getLastUpdate(),
7979
stringifyFrameBalances(this.validatorsStorage.getFrameBalances()),

src/storage/validators/validators.service.ts

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@ import { BigNumber } from '@ethersproject/bignumber';
44
@Injectable()
55
export class ValidatorsStorageService {
66
protected maxExitEpoch: string;
7-
protected total: number;
7+
protected activeValidatorsCount: number;
8+
protected totalValidatorsCount: number;
89
protected lastUpdate: number;
910
protected frameBalances: Record<string, BigNumber>;
1011

@@ -20,8 +21,8 @@ export class ValidatorsStorageService {
2021
* Get total validators
2122
* @returns total validators number
2223
*/
23-
public getTotal(): number {
24-
return this.total;
24+
public getActiveValidatorsCount(): number {
25+
return this.activeValidatorsCount;
2526
}
2627

2728
/**
@@ -42,10 +43,10 @@ export class ValidatorsStorageService {
4243

4344
/**
4445
* Updates total validators
45-
* @param total - total validators number
46+
* @param activeValidatorsCount - total validators number
4647
*/
47-
public setTotal(total: number): void {
48-
this.total = total;
48+
public setActiveValidatorsCount(activeValidatorsCount: number): void {
49+
this.activeValidatorsCount = activeValidatorsCount;
4950
}
5051

5152
/**
@@ -71,4 +72,12 @@ export class ValidatorsStorageService {
7172
public setFrameBalances(frameBalances: Record<string, BigNumber>): void {
7273
this.frameBalances = frameBalances;
7374
}
75+
76+
public setTotalValidatorsCount(totalValidatorsCount: number) {
77+
this.totalValidatorsCount = totalValidatorsCount;
78+
}
79+
80+
public getTotalValidatorsCount() {
81+
return this.totalValidatorsCount;
82+
}
7483
}

0 commit comments

Comments
 (0)