Skip to content

Commit a93585a

Browse files
committed
feat: added sweep module, consensus client service, refactoring
1 parent 8cbefbe commit a93585a

20 files changed

+361
-26
lines changed

package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
"@fastify/static": "^7.0.4",
3131
"@lido-nestjs/consensus": "^1.5.0",
3232
"@lido-nestjs/constants": "^5.2.0",
33-
"@lido-nestjs/contracts": "^9.3.0",
33+
"@lido-nestjs/contracts": "^9.6.0",
3434
"@lido-nestjs/decorators": "^1.0.0",
3535
"@lido-nestjs/execution": "^1.9.3",
3636
"@lido-nestjs/fetch": "^1.4.0",
@@ -46,6 +46,7 @@
4646
"@nestjs/terminus": "^9.1.4",
4747
"@nestjs/throttler": "^6.0.0",
4848
"@sentry/node": "^7.29.0",
49+
"@types/stream-json": "^1.7.8",
4950
"@willsoto/nestjs-prometheus": "^5.1.0",
5051
"cache-manager": "^5.7.4",
5152
"class-transformer": "^0.5.1",
@@ -57,7 +58,7 @@
5758
"rimraf": "^3.0.2",
5859
"rxjs": "^7.5.2",
5960
"stream-chain": "^2.2.5",
60-
"stream-json": "^1.8.0"
61+
"stream-json": "^1.9.1"
6162
},
6263
"devDependencies": {
6364
"@nestjs/cli": "^10.4.4",
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { ConsensusProviderService } from './index';
2+
import { Injectable } from '@nestjs/common';
3+
import { processJsonStreamBeaconState } from './utils/process-json-stream-beacon-state';
4+
import { BeaconState } from './consensus-provider.types';
5+
6+
@Injectable()
7+
export class ConsensusClientService {
8+
private API_GET_STATE = (stateId: string) => `/eth/v2/debug/beacon/states/${stateId}`;
9+
constructor(protected readonly consensusService: ConsensusProviderService) {}
10+
11+
public async isElectraActivated(epoch: number) {
12+
const spec = await this.consensusService.getSpec();
13+
return epoch >= +spec.data.ELECTRA_FORK_EPOCH;
14+
}
15+
16+
public async getStateStream(stateId: string): Promise<BeaconState> {
17+
const stream = await this.consensusService.fetchStream(this.API_GET_STATE(stateId));
18+
const result = await processJsonStreamBeaconState(stream);
19+
return result as BeaconState;
20+
}
21+
}

src/common/consensus-provider/consensus-provider.module.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { ConsensusModule } from '@lido-nestjs/consensus';
22
import { Global, Module } from '@nestjs/common';
33
import { CONSENSUS_POOL_INTERVAL_MS } from './consensus-provider.constants';
44
import { ConsensusFetchModule } from './consensus-fetch.module';
5+
import { ConsensusClientService } from './consensus-client.service';
56

67
@Global()
78
@Module({
@@ -11,5 +12,7 @@ import { ConsensusFetchModule } from './consensus-fetch.module';
1112
pollingInterval: CONSENSUS_POOL_INTERVAL_MS,
1213
}),
1314
],
15+
exports: [ConsensusClientService],
16+
providers: [ConsensusClientService],
1417
})
1518
export class ConsensusProviderModule {}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { ConsensusMethodResult } from '@lido-nestjs/consensus/dist/interfaces/consensus.interface';
2+
3+
export type ResponseValidatorsData = Awaited<ConsensusMethodResult<'getStateValidators'>>['data'];
4+
export type IndexedValidator = ResponseValidatorsData[number];
5+
export type Validator = ResponseValidatorsData[number]['validator'];
6+
7+
/**
8+
* Spec reference:
9+
* https://github.com/ethereum/consensus-specs/blob/dev/specs/electra/beacon-chain.md#pendingpartialwithdrawal
10+
*/
11+
export interface PendingPartialWithdrawal {
12+
validator_index: string;
13+
amount: string;
14+
withdrawable_epoch: string;
15+
}
16+
17+
/**
18+
* Spec reference:
19+
* https://github.com/ethereum/consensus-specs/blob/dev/specs/electra/beacon-chain.md#beaconstate
20+
* included only used properties
21+
*/
22+
export interface BeaconState {
23+
slot: string;
24+
pending_partial_withdrawals: PendingPartialWithdrawal[];
25+
validators: Validator[];
26+
balances: string[];
27+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { parser } from 'stream-json';
2+
import { pick } from 'stream-json/filters/Pick';
3+
import { streamObject } from 'stream-json/streamers/StreamObject';
4+
import { chain } from 'stream-chain';
5+
import { BeaconState } from '../consensus-provider.types';
6+
7+
const keys = ['slot', 'pending_partial_withdrawals', 'validators', 'balances'] as const;
8+
9+
export async function processJsonStreamBeaconState(readableStream) {
10+
return new Promise((resolve, reject) => {
11+
const pipeline = chain([
12+
readableStream, // Incoming ReadableStream
13+
parser(), // Parses JSON as a stream
14+
pick({ filter: 'data' }),
15+
streamObject(), // Streams key-value pairs { key, value }
16+
]);
17+
18+
const result = {} as BeaconState;
19+
20+
pipeline.on('data', ({ key, value }) => {
21+
console.log('key', key);
22+
if (keys.includes(key)) {
23+
result[key] = value; // Store key-value pairs in an object
24+
}
25+
});
26+
27+
pipeline.on('end', () => {
28+
resolve(result); // Resolve with the final object
29+
});
30+
31+
pipeline.on('error', reject);
32+
});
33+
}

src/common/sweep/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export * from './sweep.module';
2+
export * from './sweep.service';
3+
export * from './sweep.constants';

src/common/sweep/sweep.constants.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { MAX_EFFECTIVE_BALANCE } from '../../waiting-time/waiting-time.constants';
2+
3+
export const MAX_WITHDRAWALS_PER_PAYLOAD = 2 ** 4;
4+
export const MAX_PENDING_PARTIALS_PER_WITHDRAWALS_SWEEP = 2 ** 3;
5+
export const MIN_ACTIVATION_BALANCE = MAX_EFFECTIVE_BALANCE;

src/common/sweep/sweep.module.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { Module } from '@nestjs/common';
2+
import { ConsensusProviderModule } from 'common/consensus-provider';
3+
import { LoggerModule } from 'common/logger';
4+
import { SweepService } from './sweep.service';
5+
import { ContractConfigStorageModule } from '../../storage';
6+
import { GenesisTimeModule } from '../genesis-time';
7+
8+
@Module({
9+
imports: [LoggerModule, ConsensusProviderModule, ContractConfigStorageModule, GenesisTimeModule],
10+
providers: [SweepService],
11+
exports: [SweepService],
12+
})
13+
export class SweepModule {}

src/common/sweep/sweep.service.ts

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import { Inject, Injectable, LoggerService, OnModuleInit } from '@nestjs/common';
2+
import { LOGGER_PROVIDER } from '@lido-nestjs/logger';
3+
import { GenesisTimeService, SLOTS_PER_EPOCH } from '../genesis-time';
4+
import {
5+
getMaxEffectiveBalance,
6+
isFullyWithdrawableValidator,
7+
isPartiallyWithdrawableValidator,
8+
} from '../../jobs/validators/utils/validator-state-utils';
9+
import { FAR_FUTURE_EPOCH } from '../../jobs/validators';
10+
import {
11+
MAX_PENDING_PARTIALS_PER_WITHDRAWALS_SWEEP,
12+
MAX_WITHDRAWALS_PER_PAYLOAD,
13+
MIN_ACTIVATION_BALANCE,
14+
} from './sweep.constants';
15+
import { ConsensusClientService } from '../consensus-provider/consensus-client.service';
16+
import { parseGwei } from '../utils/parse-gwei';
17+
import { bigNumberMin } from '../utils/big-number-min';
18+
import { Withdrawal } from './sweep.types';
19+
import { BeaconState, IndexedValidator, Validator } from '../consensus-provider/consensus-provider.types';
20+
21+
@Injectable()
22+
export class SweepService implements OnModuleInit {
23+
constructor(
24+
@Inject(LOGGER_PROVIDER) protected readonly logger: LoggerService,
25+
protected readonly consensusClientService: ConsensusClientService,
26+
protected readonly genesisService: GenesisTimeService,
27+
) {}
28+
29+
public async onModuleInit(): Promise<void> {
30+
const epoch = this.genesisService.getCurrentEpoch();
31+
await this.getSweepDelayInEpochs([], epoch);
32+
}
33+
34+
getConsensusVersion() {
35+
// todo add call contract this.vebOracle.getConsensusVersion()
36+
return 1;
37+
}
38+
39+
public async getSweepDelayInEpochs(indexedValidators: IndexedValidator[], currentEpoch: number) {
40+
const isElectraActivate = await this.consensusClientService.isElectraActivated(currentEpoch);
41+
if (this.getConsensusVersion() < 3 || !isElectraActivate) {
42+
return this.getSweepDelayInEpochsPreElectra(indexedValidators, currentEpoch);
43+
}
44+
45+
const state = await this.consensusClientService.getStateStream('head');
46+
return this.getSweepDelayInEpochsPostElectra(state, indexedValidators);
47+
}
48+
49+
private getSweepDelayInEpochsPreElectra(indexedValidators: IndexedValidator[], epoch: number): number {
50+
const totalWithdrawableValidators = this.getWithdrawableValidators(indexedValidators, epoch).length;
51+
52+
const fullSweepInEpochs = totalWithdrawableValidators / MAX_WITHDRAWALS_PER_PAYLOAD / SLOTS_PER_EPOCH;
53+
return Math.floor(fullSweepInEpochs * 0.5);
54+
}
55+
56+
// pre pectra
57+
private getWithdrawableValidators(indexedValidators: IndexedValidator[], epoch: number) {
58+
return indexedValidators.filter(
59+
(v) =>
60+
isPartiallyWithdrawableValidator(v.validator, parseGwei(v.balance)) ||
61+
isFullyWithdrawableValidator(v.validator, parseGwei(v.balance), epoch),
62+
);
63+
}
64+
65+
private getSweepDelayInEpochsPostElectra(state: BeaconState, indexedValidators: IndexedValidator[]): number {
66+
const withdrawalsNumberInSweepCycle = this.predictWithdrawalsNumberInSweepCycle(state, indexedValidators);
67+
const fullSweepCycleInEpochs = Math.ceil(
68+
withdrawalsNumberInSweepCycle / MAX_WITHDRAWALS_PER_PAYLOAD / SLOTS_PER_EPOCH,
69+
);
70+
return Math.floor(fullSweepCycleInEpochs / 2);
71+
}
72+
73+
private predictWithdrawalsNumberInSweepCycle(state: BeaconState, indexedValidators: IndexedValidator[]): number {
74+
const pendingPartialWithdrawals = this.getPendingPartialWithdrawals(state);
75+
const validatorsWithdrawals = this.getValidatorsWithdrawals(state, pendingPartialWithdrawals, indexedValidators);
76+
77+
const pendingPartialWithdrawalsNumber = pendingPartialWithdrawals.length;
78+
const validatorsWithdrawalsNumber = validatorsWithdrawals.length;
79+
80+
const partialWithdrawalsMaxRatio =
81+
MAX_PENDING_PARTIALS_PER_WITHDRAWALS_SWEEP /
82+
(MAX_WITHDRAWALS_PER_PAYLOAD - MAX_PENDING_PARTIALS_PER_WITHDRAWALS_SWEEP);
83+
84+
const pendingPartialWithdrawalsMaxNumberInCycle = Math.ceil(
85+
validatorsWithdrawalsNumber * partialWithdrawalsMaxRatio,
86+
);
87+
88+
const pendingPartialWithdrawalsNumberInCycle = Math.min(
89+
pendingPartialWithdrawalsNumber,
90+
pendingPartialWithdrawalsMaxNumberInCycle,
91+
);
92+
93+
return validatorsWithdrawalsNumber + pendingPartialWithdrawalsNumberInCycle;
94+
}
95+
96+
private getPendingPartialWithdrawals(state: BeaconState): Withdrawal[] {
97+
const withdrawals: Withdrawal[] = [];
98+
99+
for (const pendingPartialWithdrawal of state.pending_partial_withdrawals) {
100+
const index = pendingPartialWithdrawal.validator_index;
101+
const validator: Validator = state.validators[index];
102+
const hasSufficientEffectiveBalance = parseGwei(validator.effective_balance).gte(MIN_ACTIVATION_BALANCE);
103+
const hasExcessBalance = parseGwei(state.balances[index]).gt(MIN_ACTIVATION_BALANCE);
104+
105+
if (validator.exit_epoch === FAR_FUTURE_EPOCH.toString() && hasSufficientEffectiveBalance && hasExcessBalance) {
106+
const withdrawableBalance = bigNumberMin(
107+
parseGwei(state.balances[index]).sub(MIN_ACTIVATION_BALANCE),
108+
parseGwei(pendingPartialWithdrawal.amount),
109+
);
110+
withdrawals.push({ validatorIndex: index, amount: withdrawableBalance });
111+
}
112+
}
113+
return withdrawals;
114+
}
115+
116+
// post pectra
117+
getValidatorsWithdrawals(
118+
state: BeaconState,
119+
partialWithdrawals: Withdrawal[],
120+
indexedValidators: IndexedValidator[],
121+
): Withdrawal[] {
122+
const epoch = Math.ceil(+state.slot / SLOTS_PER_EPOCH);
123+
const withdrawals: Withdrawal[] = [];
124+
const partiallyWithdrawnMap: Record<number, number> = {};
125+
126+
for (const withdrawal of partialWithdrawals) {
127+
partiallyWithdrawnMap[withdrawal.validatorIndex] =
128+
(partiallyWithdrawnMap[withdrawal.validatorIndex] || 0) + withdrawal.amount;
129+
}
130+
131+
for (const indexedValidator of indexedValidators) {
132+
const validatorIndex = indexedValidator.index;
133+
const validator = indexedValidator.validator;
134+
const partiallyWithdrawnBalance = partiallyWithdrawnMap[validatorIndex] || 0;
135+
const balance = parseGwei(state.balances[validatorIndex]).sub(partiallyWithdrawnBalance);
136+
137+
if (isFullyWithdrawableValidator(validator, balance, epoch)) {
138+
withdrawals.push({ validatorIndex, amount: balance });
139+
} else if (isPartiallyWithdrawableValidator(validator, balance)) {
140+
const maxEffectiveBalance = getMaxEffectiveBalance(validator);
141+
withdrawals.push({ validatorIndex, amount: balance.sub(maxEffectiveBalance) });
142+
}
143+
}
144+
145+
return withdrawals;
146+
}
147+
}

src/common/sweep/sweep.types.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import { BigNumber } from '@ethersproject/bignumber';
2+
3+
export interface Withdrawal {
4+
validatorIndex: string;
5+
amount: BigNumber;
6+
}

src/common/utils/big-number-min.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { BigNumber } from '@ethersproject/bignumber';
2+
3+
export const bigNumberMin = (a: BigNumber, b: BigNumber) => {
4+
return a.lt(b) ? a : b;
5+
};
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { BigNumber } from '@ethersproject/bignumber';
22

3-
export const parseGweiToWei = (gweiValue: string) => {
3+
export const parseGwei = (gweiValue: string) => {
44
const toWei = BigNumber.from('1000000000');
55
return BigNumber.from(gweiValue).mul(toWei);
66
};

src/jobs/validators/lido-keys/lido-keys.service.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import { Inject } from '@nestjs/common';
22
import { LOGGER_PROVIDER, LoggerService } from 'common/logger';
3+
import { IndexedValidator } from 'common/consensus-provider/consensus-provider.types';
34
import { ConfigService } from 'common/config';
45
import { LidoKey, LidoKeysData } from './lido-keys.types';
56
import { KEYS_API_ADDRESS } from './lido-keys.constants';
6-
import { Validator } from '../validators.types';
77

88
export class LidoKeysService {
99
constructor(
@@ -19,7 +19,7 @@ export class LidoKeysService {
1919
return lidoKeys;
2020
}
2121

22-
public async getLidoValidatorsByKeys(keys: LidoKey[], validators: Validator[]) {
22+
public async getLidoValidatorsByKeys(keys: LidoKey[], validators: IndexedValidator[]) {
2323
const keysDict = keys.reduce((acc, lidoKey) => {
2424
acc[lidoKey.key] = true;
2525
return acc;

src/jobs/validators/utils/get-validator-withdrawal-timestamp.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,15 +26,15 @@ Examples:
2626
2727
*/
2828
export function getValidatorWithdrawalTimestamp(
29-
index: BigNumber,
29+
validatorIndex: BigNumber,
3030
lastWithdrawalValidatorIndex: BigNumber,
3131
activeValidatorCount: number,
3232
totalValidatorsCount: number,
3333
) {
34-
const diff = index.sub(lastWithdrawalValidatorIndex);
34+
const diff = validatorIndex.sub(lastWithdrawalValidatorIndex);
3535
const percentOfActiveValidators = activeValidatorCount / totalValidatorsCount;
3636
const lengthQueueValidators = diff.lt(0)
37-
? BigNumber.from(totalValidatorsCount).sub(lastWithdrawalValidatorIndex).add(index)
37+
? BigNumber.from(totalValidatorsCount).sub(lastWithdrawalValidatorIndex).add(validatorIndex)
3838
: diff;
3939

4040
const slots = lengthQueueValidators.div(BigNumber.from(WITHDRAWALS_VALIDATORS_PER_SLOT));

0 commit comments

Comments
 (0)