diff --git a/.github/workflows/tests_and_checks.yml b/.github/workflows/tests_and_checks.yml index 32e75a6a..3e70ef4c 100644 --- a/.github/workflows/tests_and_checks.yml +++ b/.github/workflows/tests_and_checks.yml @@ -19,7 +19,7 @@ jobs: - name: Run tests run: yarn test - name: Run e2e transports - run: docker-compose -f docker-compose.test.yml up -d + run: docker compose -f docker-compose.test.yml up -d - name: Run e2e tests run: yarn test:e2e env: diff --git a/.gitignore b/.gitignore index 44332067..52c63f32 100644 --- a/.gitignore +++ b/.gitignore @@ -52,3 +52,6 @@ lerna-debug.log* # Ansible /ansible + +leveldb-spec +leveldb-cache diff --git a/Dockerfile b/Dockerfile index 88cbb6db..dd535b71 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,7 +2,7 @@ FROM node:14.18.1-alpine3.13 as building # needed for git dependencies RUN apk update && apk upgrade && \ - apk add --no-cache bash=5.1.16-r0 git=2.30.6-r0 openssh=8.4_p1-r4 + apk add --no-cache bash=5.1.16-r0 git=2.30.6-r0 openssh=8.4_p1-r4 python3=3.8.15-r0 make=4.3-r0 g++=10.2.1_pre1-r3 RUN mkdir /council @@ -13,10 +13,9 @@ RUN npm i -g npm@7.19.0 COPY ./package*.json ./ COPY ./yarn*.lock ./ -RUN yarn install --frozen-lockfile --non-interactive && yarn cache clean - COPY ./tsconfig*.json ./ COPY ./src ./src +RUN yarn install --frozen-lockfile --non-interactive && yarn cache clean RUN yarn typechain && yarn build diff --git a/docker-compose.test.yml b/docker-compose.test.yml index 37eac1b5..c32639c8 100644 --- a/docker-compose.test.yml +++ b/docker-compose.test.yml @@ -2,22 +2,34 @@ version: '3.7' services: zookeeper: - image: wurstmeister/zookeeper + image: confluentinc/cp-zookeeper + hostname: zookeeper + container_name: zookeeper ports: - "2181:2181" + environment: + ZOOKEEPER_SERVER_ID: 1 + ZOOKEEPER_CLIENT_PORT: 2181 + ZOOKEEPER_TICK_TIME: 2000 + ZOOKEEPER_INIT_LIMIT: 5 + ZOOKEEPER_SYNC_LIMIT: 2 + ZOOKEEPER_SERVERS: server.1=zookeeper:2888:3888 kafka: - image: wurstmeister/kafka:2.13-2.7.1 + image: confluentinc/cp-kafka + hostname: localhost + container_name: kafka ports: - "9092:9092" - links: - - zookeeper environment: + KAFKA_BROKER_ID: 1 KAFKA_ADVERTISED_HOST_NAME: 127.0.0.1 - KAFKA_CREATE_TOPICS: "test:1:1" - KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181 + KAFKA_ZOOKEEPER_CONNECT: "zookeeper:2181" + KAFKA_ADVERTISED_LISTENERS: "PLAINTEXT://127.0.0.1:9092" + KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1 KAFKA_AUTO_CREATE_TOPICS_ENABLE: 'true' - KAFKA_MESSAGE_MAX_BYTES: 2000000 + KAFKA_CREATE_TOPICS: "test:1:1" + KAFKA_CFG_ZOOKEEPER_CONNECT: zookeeper:2181 depends_on: - zookeeper diff --git a/package.json b/package.json index dd4723c3..95fd1a9d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "lido-council-daemon", - "version": "2.1.1", + "version": "3.0.2", "description": "Lido Council Daemon", "author": "Lido team", "private": true, @@ -31,7 +31,7 @@ "dependencies": { "@chainsafe/blst": "^0.2.4", "@chainsafe/ssz": "^0.9.2", - "@ethersproject/providers": "^5.4.5", + "@ethersproject/providers": "5.7.2", "@lido-nestjs/fetch": "^1.3.1", "@lido-nestjs/key-validation": "^7.4.0", "@lido-nestjs/middleware": "^1.1.1", @@ -48,9 +48,10 @@ "class-transformer": "^0.5.1", "class-validator": "^0.14.0", "compare-versions": "^6.1.0", - "ethers": "^5.4.7", + "ethers": "5.7.2", "glob": "^7.1.2", "kafkajs": "^1.15.0", + "level": "^8.0.1", "lru-cache": "^9.1.1", "nest-winston": "^1.6.1", "node-abort-controller": "^3.0.1", @@ -79,7 +80,7 @@ "eslint": "^7.30.0", "eslint-config-prettier": "^8.3.0", "eslint-plugin-prettier": "^3.4.0", - "ganache": "7.7.5", + "ganache": "7.9.0", "jest": "^27.0.6", "prettier": "^2.3.2", "supertest": "^6.1.3", diff --git a/src/abi/IStakingModule.abi.json b/src/abi/IStakingModule.abi.json new file mode 100644 index 00000000..34008ae9 --- /dev/null +++ b/src/abi/IStakingModule.abi.json @@ -0,0 +1,329 @@ +[ + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint256", + "name": "nonce", + "type": "uint256" + } + ], + "name": "NonceChanged", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "uint256", + "name": "nodeOperatorId", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "bytes", + "name": "pubkey", + "type": "bytes" + } + ], + "name": "SigningKeyAdded", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "uint256", + "name": "nodeOperatorId", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "bytes", + "name": "pubkey", + "type": "bytes" + } + ], + "name": "SigningKeyRemoved", + "type": "event" + }, + { + "inputs": [ + { "internalType": "bytes", "name": "_nodeOperatorIds", "type": "bytes" }, + { + "internalType": "bytes", + "name": "_vettedSigningKeysCounts", + "type": "bytes" + } + ], + "name": "decreaseVettedSigningKeysCount", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "getActiveNodeOperatorsCount", + "outputs": [{ "internalType": "uint256", "name": "", "type": "uint256" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { "internalType": "uint256", "name": "_offset", "type": "uint256" }, + { "internalType": "uint256", "name": "_limit", "type": "uint256" } + ], + "name": "getNodeOperatorIds", + "outputs": [ + { + "internalType": "uint256[]", + "name": "nodeOperatorIds", + "type": "uint256[]" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "_nodeOperatorId", + "type": "uint256" + } + ], + "name": "getNodeOperatorIsActive", + "outputs": [{ "internalType": "bool", "name": "", "type": "bool" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "_nodeOperatorId", + "type": "uint256" + } + ], + "name": "getNodeOperatorSummary", + "outputs": [ + { + "internalType": "uint256", + "name": "targetLimitMode", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "targetValidatorsCount", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "stuckValidatorsCount", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "refundedValidatorsCount", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "stuckPenaltyEndTimestamp", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "totalExitedValidators", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "totalDepositedValidators", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "depositableValidatorsCount", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "getNodeOperatorsCount", + "outputs": [{ "internalType": "uint256", "name": "", "type": "uint256" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "getNonce", + "outputs": [{ "internalType": "uint256", "name": "", "type": "uint256" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "getStakingModuleSummary", + "outputs": [ + { + "internalType": "uint256", + "name": "totalExitedValidators", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "totalDepositedValidators", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "depositableValidatorsCount", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "getType", + "outputs": [{ "internalType": "bytes32", "name": "", "type": "bytes32" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "_depositsCount", + "type": "uint256" + }, + { "internalType": "bytes", "name": "_depositCalldata", "type": "bytes" } + ], + "name": "obtainDepositData", + "outputs": [ + { "internalType": "bytes", "name": "publicKeys", "type": "bytes" }, + { "internalType": "bytes", "name": "signatures", "type": "bytes" } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "onExitedAndStuckValidatorsCountsUpdated", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { "internalType": "uint256", "name": "_totalShares", "type": "uint256" } + ], + "name": "onRewardsMinted", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "onWithdrawalCredentialsChanged", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "_nodeOperatorId", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "_exitedValidatorsCount", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "_stuckValidatorsCount", + "type": "uint256" + } + ], + "name": "unsafeUpdateValidatorsCount", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { "internalType": "bytes", "name": "_nodeOperatorIds", "type": "bytes" }, + { + "internalType": "bytes", + "name": "_exitedValidatorsCounts", + "type": "bytes" + } + ], + "name": "updateExitedValidatorsCount", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "_nodeOperatorId", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "_refundedValidatorsCount", + "type": "uint256" + } + ], + "name": "updateRefundedValidatorsCount", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { "internalType": "bytes", "name": "_nodeOperatorIds", "type": "bytes" }, + { + "internalType": "bytes", + "name": "_stuckValidatorsCounts", + "type": "bytes" + } + ], + "name": "updateStuckValidatorsCount", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "_nodeOperatorId", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "_targetLimitMode", + "type": "uint256" + }, + { "internalType": "uint256", "name": "_targetLimit", "type": "uint256" } + ], + "name": "updateTargetValidatorsLimits", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + } +] diff --git a/src/abi/security.abi.json b/src/abi/security.abi.json index 70a8c959..e901ebe2 100644 --- a/src/abi/security.abi.json +++ b/src/abi/security.abi.json @@ -18,17 +18,17 @@ }, { "internalType": "uint256", - "name": "_maxDepositsPerBlock", + "name": "_pauseIntentValidityPeriodBlocks", "type": "uint256" }, { "internalType": "uint256", - "name": "_minDepositBlockDistance", + "name": "_unvetIntentValidityPeriodBlocks", "type": "uint256" }, { "internalType": "uint256", - "name": "_pauseIntentValidityPeriodBlocks", + "name": "_maxOperatorsPerUnvetting", "type": "uint256" } ], @@ -47,22 +47,22 @@ }, { "inputs": [], - "name": "DepositNonceChanged", + "name": "DepositRootChanged", "type": "error" }, { "inputs": [], - "name": "DepositRootChanged", + "name": "DepositTooFrequent", "type": "error" }, { "inputs": [], - "name": "DepositTooFrequent", + "name": "DepositUnexpectedBlockHash", "type": "error" }, { "inputs": [], - "name": "DepositUnexpectedBlockHash", + "name": "DepositsNotPaused", "type": "error" }, { @@ -81,6 +81,11 @@ "name": "InvalidSignature", "type": "error" }, + { + "inputs": [], + "name": "ModuleNonceChanged", + "type": "error" + }, { "inputs": [ { @@ -110,7 +115,22 @@ }, { "inputs": [], - "name": "SignatureNotSorted", + "name": "SignaturesNotSorted", + "type": "error" + }, + { + "inputs": [], + "name": "UnvetIntentExpired", + "type": "error" + }, + { + "inputs": [], + "name": "UnvetPayloadInvalid", + "type": "error" + }, + { + "inputs": [], + "name": "UnvetUnexpectedBlockHash", "type": "error" }, { @@ -143,12 +163,6 @@ "internalType": "address", "name": "guardian", "type": "address" - }, - { - "indexed": true, - "internalType": "uint24", - "name": "stakingModuleId", - "type": "uint24" } ], "name": "DepositsPaused", @@ -156,14 +170,7 @@ }, { "anonymous": false, - "inputs": [ - { - "indexed": true, - "internalType": "uint24", - "name": "stakingModuleId", - "type": "uint24" - } - ], + "inputs": [], "name": "DepositsUnpaused", "type": "event" }, @@ -216,7 +223,7 @@ "type": "uint256" } ], - "name": "MaxDepositsChanged", + "name": "LastDepositBlockChanged", "type": "event" }, { @@ -229,7 +236,7 @@ "type": "uint256" } ], - "name": "MinDepositBlockDistanceChanged", + "name": "MaxOperatorsPerUnvettingChanged", "type": "event" }, { @@ -258,6 +265,19 @@ "name": "PauseIntentValidityPeriodBlocksChanged", "type": "event" }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint256", + "name": "newValue", + "type": "uint256" + } + ], + "name": "UnvetIntentValidityPeriodBlocksChanged", + "type": "event" + }, { "inputs": [], "name": "ATTEST_MESSAGE_PREFIX", @@ -323,6 +343,32 @@ "stateMutability": "view", "type": "function" }, + { + "inputs": [], + "name": "UNVET_MESSAGE_PREFIX", + "outputs": [ + { + "internalType": "bytes32", + "name": "", + "type": "bytes32" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "VERSION", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, { "inputs": [ { @@ -480,7 +526,7 @@ }, { "inputs": [], - "name": "getMaxDeposits", + "name": "getLastDepositBlock", "outputs": [ { "internalType": "uint256", @@ -493,7 +539,7 @@ }, { "inputs": [], - "name": "getMinDepositBlockDistance", + "name": "getMaxOperatorsPerUnvetting", "outputs": [ { "internalType": "uint256", @@ -530,6 +576,32 @@ "stateMutability": "view", "type": "function" }, + { + "inputs": [], + "name": "getUnvetIntentValidityPeriodBlocks", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "isDepositsPaused", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, { "inputs": [ { @@ -553,12 +625,26 @@ "inputs": [ { "internalType": "uint256", - "name": "blockNumber", + "name": "stakingModuleId", "type": "uint256" - }, + } + ], + "name": "isMinDepositDistancePassed", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ { "internalType": "uint256", - "name": "stakingModuleId", + "name": "blockNumber", "type": "uint256" }, { @@ -623,7 +709,7 @@ "type": "uint256" } ], - "name": "setMaxDeposits", + "name": "setMaxOperatorsPerUnvetting", "outputs": [], "stateMutability": "nonpayable", "type": "function" @@ -631,12 +717,12 @@ { "inputs": [ { - "internalType": "uint256", + "internalType": "address", "name": "newValue", - "type": "uint256" + "type": "address" } ], - "name": "setMinDepositBlockDistance", + "name": "setOwner", "outputs": [], "stateMutability": "nonpayable", "type": "function" @@ -644,12 +730,12 @@ { "inputs": [ { - "internalType": "address", + "internalType": "uint256", "name": "newValue", - "type": "address" + "type": "uint256" } ], - "name": "setOwner", + "name": "setPauseIntentValidityPeriodBlocks", "outputs": [], "stateMutability": "nonpayable", "type": "function" @@ -662,22 +748,71 @@ "type": "uint256" } ], - "name": "setPauseIntentValidityPeriodBlocks", + "name": "setUnvetIntentValidityPeriodBlocks", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "unpauseDeposits", "outputs": [], "stateMutability": "nonpayable", "type": "function" }, { "inputs": [ + { + "internalType": "uint256", + "name": "blockNumber", + "type": "uint256" + }, + { + "internalType": "bytes32", + "name": "blockHash", + "type": "bytes32" + }, { "internalType": "uint256", "name": "stakingModuleId", "type": "uint256" + }, + { + "internalType": "uint256", + "name": "nonce", + "type": "uint256" + }, + { + "internalType": "bytes", + "name": "nodeOperatorIds", + "type": "bytes" + }, + { + "internalType": "bytes", + "name": "vettedSigningKeysCounts", + "type": "bytes" + }, + { + "components": [ + { + "internalType": "bytes32", + "name": "r", + "type": "bytes32" + }, + { + "internalType": "bytes32", + "name": "vs", + "type": "bytes32" + } + ], + "internalType": "struct DepositSecurityModule.Signature", + "name": "sig", + "type": "tuple" } ], - "name": "unpauseDeposits", + "name": "unvetSigningKeys", "outputs": [], "stateMutability": "nonpayable", "type": "function" } -] \ No newline at end of file +] diff --git a/src/abi/security.deprecated.pause.abi.json b/src/abi/security.deprecated.pause.abi.json new file mode 100644 index 00000000..28061412 --- /dev/null +++ b/src/abi/security.deprecated.pause.abi.json @@ -0,0 +1,37 @@ +[ + { + "inputs": [ + { + "internalType": "uint256", + "name": "blockNumber", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "stakingModuleId", + "type": "uint256" + }, + { + "components": [ + { + "internalType": "bytes32", + "name": "r", + "type": "bytes32" + }, + { + "internalType": "bytes32", + "name": "vs", + "type": "bytes32" + } + ], + "internalType": "struct DepositSecurityModule.Signature", + "name": "sig", + "type": "tuple" + } + ], + "name": "pauseDeposits", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + } +] diff --git a/src/cache/cache.constants.ts b/src/cache/cache.constants.ts deleted file mode 100644 index 7a040afd..00000000 --- a/src/cache/cache.constants.ts +++ /dev/null @@ -1,5 +0,0 @@ -export const CACHE_DIR = 'cache'; - -export const CACHE_FILE_NAME = 'cacheFileName'; -export const CACHE_DEFAULT_VALUE = 'cacheDefaultValue'; -export const CACHE_BATCH_SIZE = 'cacheBatchSize'; diff --git a/src/cache/cache.module.ts b/src/cache/cache.module.ts deleted file mode 100644 index 569a4d2a..00000000 --- a/src/cache/cache.module.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { DynamicModule, Module } from '@nestjs/common'; -import { CACHE_BATCH_SIZE, CACHE_DEFAULT_VALUE, CACHE_FILE_NAME } from 'cache'; -import { ProviderModule } from 'provider'; -import { CACHE_DIR } from './cache.constants'; -import { CacheService } from './cache.service'; - -@Module({}) -export class CacheModule { - static register( - fileName: string, - batchSize: number, - defaultValue: unknown, - ): DynamicModule { - return { - module: CacheModule, - imports: [ProviderModule], - providers: [ - CacheService, - { - provide: CACHE_DIR, - useValue: 'cache', - }, - { - provide: CACHE_FILE_NAME, - useValue: fileName, - }, - { - provide: CACHE_BATCH_SIZE, - useValue: batchSize, - }, - { - provide: CACHE_DEFAULT_VALUE, - useValue: defaultValue, - }, - ], - exports: [CacheService], - }; - } -} diff --git a/src/cache/cache.service.spec.ts b/src/cache/cache.service.spec.ts deleted file mode 100644 index 13518329..00000000 --- a/src/cache/cache.service.spec.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { Test } from '@nestjs/testing'; -import { MockProviderModule } from 'provider'; -import { ConfigModule } from 'common/config'; -import { LoggerModule } from 'common/logger'; -import { CacheModule } from 'cache'; -import { CacheService } from './cache.service'; - -describe('CacheService', () => { - const defaultCacheValue = { - headers: {}, - data: [] as any[], - }; - - const batchSize = 10; - - type C = typeof defaultCacheValue; - - const cacheFile = 'test.json'; - let cacheService: CacheService; - - beforeEach(async () => { - const moduleRef = await Test.createTestingModule({ - imports: [ - ConfigModule.forRoot(), - MockProviderModule.forRoot(), - CacheModule.register(cacheFile, batchSize, defaultCacheValue), - LoggerModule, - ], - }).compile(); - - cacheService = moduleRef.get(CacheService); - }); - - afterEach(async () => { - try { - await cacheService.deleteCache(); - } catch (error) {} - }); - - describe('getCache, setCache', () => { - it('should return default cache', async () => { - const result = await cacheService.getCache(); - expect(result).toEqual(defaultCacheValue); - }); - - it('should return saved cache', async () => { - const expected = { headers: {}, data: [{ foo: 'bar' }] }; - - await cacheService.setCache(expected); - const result = await cacheService.getCache(); - expect(result).toEqual(expected); - }); - }); -}); diff --git a/src/cache/cache.service.ts b/src/cache/cache.service.ts deleted file mode 100644 index 9fd34331..00000000 --- a/src/cache/cache.service.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { Inject, Injectable } from '@nestjs/common'; -import { readFile, writeFile, unlink, mkdir } from 'fs/promises'; -import { join } from 'path'; -import { promisify } from 'util'; -import * as gl from 'glob'; -import { - CACHE_DIR, - CACHE_DEFAULT_VALUE, - CACHE_FILE_NAME, - CACHE_BATCH_SIZE, -} from './cache.constants'; -import { ProviderService } from 'provider'; - -const glob = promisify(gl.glob); - -@Injectable() -export class CacheService< - H extends unknown, - D extends unknown, - T extends { headers: H; data: D[] } = { headers: H; data: D[] }, -> { - constructor( - private providerService: ProviderService, - @Inject(CACHE_DIR) private cacheDir: string, - @Inject(CACHE_FILE_NAME) private cacheFile: string, - @Inject(CACHE_BATCH_SIZE) private cacheBatchSize: number, - @Inject(CACHE_DEFAULT_VALUE) private cacheDefaultValue: T, - ) {} - - private cache: T | null = null; - - public async getCache(): Promise { - if (!this.cache) { - this.cache = await this.getCacheFromFiles(); - } - - return this.cache; - } - - public async setCache(cache: T): Promise { - this.cache = cache; - return await this.saveCacheToFiles(); - } - - public async deleteCache(): Promise { - this.cache = null; - return await this.deleteCacheFiles(); - } - - private async getCacheDirPath(): Promise { - const chainId = await this.providerService.getChainId(); - const networkDir = `chain-${chainId}`; - - return join(this.cacheDir, networkDir); - } - - private getCacheFileName(batchIndex: number): string { - return `${batchIndex}.${this.cacheFile}`; - } - - private async getCacheFilePaths(): Promise { - const dirPath = await this.getCacheDirPath(); - const result = await glob(`*([0-9]).${this.cacheFile}`, { cwd: dirPath }); - - return result - .sort((a, b) => parseInt(a, 10) - parseInt(b, 10)) - .map((filePath) => join(dirPath, filePath)); - } - - private async getCacheFromFiles(): Promise { - try { - const filePaths = await this.getCacheFilePaths(); - - let headers = this.cacheDefaultValue.headers as H; - let data = [] as D[]; - - await Promise.all( - filePaths.map(async (filePath) => { - const content = await readFile(filePath); - const parsed = JSON.parse(String(content)); - - if ( - JSON.stringify(headers) !== JSON.stringify(parsed.headers) && - headers !== this.cacheDefaultValue.headers - ) { - throw new Error('Headers are not equal'); - } - - headers = parsed.headers; - data = data.concat(parsed.data); - }), - ); - - return { headers, data } as T; - } catch (error) { - return this.cacheDefaultValue; - } - } - - private async saveCacheToFiles(): Promise { - if (!this.cache) throw new Error('Cache is not set'); - - const { headers, data } = this.cache; - - const dirPath = await this.getCacheDirPath(); - await mkdir(dirPath, { recursive: true }); - - await this.deleteCacheFiles(); - - const totalBatches = Math.ceil(data.length / this.cacheBatchSize); - - for (let batchIndex = 0; batchIndex < totalBatches; batchIndex++) { - const from = batchIndex * this.cacheBatchSize; - const to = (batchIndex + 1) * this.cacheBatchSize; - const batchedData = data.slice(from, to); - - const filePath = join(dirPath, this.getCacheFileName(batchIndex)); - await writeFile(filePath, JSON.stringify({ headers, data: batchedData })); - } - } - - private async deleteCacheFiles(): Promise { - try { - const filePaths = await this.getCacheFilePaths(); - await Promise.all(filePaths.map(async (filePath) => unlink(filePath))); - } catch (error) {} - } -} diff --git a/src/cache/index.ts b/src/cache/index.ts deleted file mode 100644 index 8156b373..00000000 --- a/src/cache/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from './cache.constants'; -export * from './cache.module'; -export * from './cache.service'; diff --git a/src/common/config/config-loader.service.spec.ts b/src/common/config/config-loader.service.spec.ts index 2ae03bb3..9f33789c 100644 --- a/src/common/config/config-loader.service.spec.ts +++ b/src/common/config/config-loader.service.spec.ts @@ -1,5 +1,7 @@ +import { BigNumber } from '@ethersproject/bignumber'; import { Test } from '@nestjs/testing'; import { plainToClass } from 'class-transformer'; +import { validateOrReject, ValidationError } from 'class-validator'; import { ConfigLoaderService } from './config-loader.service'; import { InMemoryConfiguration } from './in-memory-configuration'; @@ -12,6 +14,25 @@ const DEFAULTS = { RPC_URL: 'some-rpc-url', RABBITMQ_URL: 'some-rabbit-url', RABBITMQ_LOGIN: 'some-rabbit-login', + KEYS_API_URL: 'keys-api', +}; + +const extractError = async ( + fn: Promise, +): Promise<[ValidationError[], T]> => { + try { + return [[], await fn]; + } catch (error: any) { + return [error as ValidationError[], undefined as unknown as T]; + } +}; + +const toHaveProblemWithRecords = ( + recordsKeys: string[], + errors: ValidationError[], +) => { + const errorKeys = errors.map((error) => error.property); + expect(recordsKeys.sort()).toEqual(errorKeys.sort()); }; describe('ConfigLoaderService base spec', () => { @@ -104,6 +125,96 @@ describe('ConfigLoaderService base spec', () => { }); }); + describe('kapi url config', () => { + test('all invariants are empty', async () => { + const prepConfig = plainToClass(InMemoryConfiguration, { + RABBITMQ_PASSCODE: 'some-rabbit-passcode', + ...DEFAULTS, + KEYS_API_URL: undefined, + }); + const [validationErrors] = await extractError( + configLoaderService.loadSecrets(prepConfig), + ); + + toHaveProblemWithRecords( + ['KEYS_API_URL', 'KEYS_API_PORT', 'KEYS_API_HOST'], + validationErrors, + ); + }); + + test('KEYS_API_URL is set and the rest is default', async () => { + const KEYS_API_URL = 'kapi-url'; + const KEYS_API_HOST = ''; + const KEYS_API_PORT = 0; + const prepConfig = plainToClass(InMemoryConfiguration, { + RABBITMQ_PASSCODE: 'some-rabbit-passcode', + ...DEFAULTS, + KEYS_API_URL, + }); + const [validationErrors, result] = await extractError( + configLoaderService.loadSecrets(prepConfig), + ); + expect(validationErrors).toHaveLength(0); + expect(result.KEYS_API_URL).toBe(KEYS_API_URL); + expect(result.KEYS_API_HOST).toBe(KEYS_API_HOST); + expect(result.KEYS_API_PORT).toBe(KEYS_API_PORT); + }); + + test('KEYS_API_URL is empty and the rest is set', async () => { + const KEYS_API_URL = undefined; + const KEYS_API_HOST = 'kapi-host'; + const KEYS_API_PORT = 2222; + const prepConfig = plainToClass(InMemoryConfiguration, { + RABBITMQ_PASSCODE: 'some-rabbit-passcode', + ...DEFAULTS, + KEYS_API_URL, + KEYS_API_HOST, + KEYS_API_PORT, + }); + const [validationErrors, result] = await extractError( + configLoaderService.loadSecrets(prepConfig), + ); + expect(validationErrors).toHaveLength(0); + expect(result.KEYS_API_URL).toBe(KEYS_API_URL); + expect(result.KEYS_API_HOST).toBe(KEYS_API_HOST); + expect(result.KEYS_API_PORT).toBe(KEYS_API_PORT); + }); + + test('KEYS_API_URL and KEYS_API_PORT are empty and the KEYS_API_HOST is set', async () => { + const KEYS_API_URL = undefined; + const KEYS_API_HOST = 'kapi-host'; + const KEYS_API_PORT = 0; + const prepConfig = plainToClass(InMemoryConfiguration, { + RABBITMQ_PASSCODE: 'some-rabbit-passcode', + ...DEFAULTS, + KEYS_API_URL, + KEYS_API_HOST, + KEYS_API_PORT, + }); + const [validationErrors] = await extractError( + configLoaderService.loadSecrets(prepConfig), + ); + toHaveProblemWithRecords(['KEYS_API_PORT'], validationErrors); + }); + + test('KEYS_API_URL and KEYS_API_HOST are empty and the KEYS_API_PORT is set', async () => { + const KEYS_API_URL = undefined; + const KEYS_API_HOST = ''; + const KEYS_API_PORT = 2222; + const prepConfig = plainToClass(InMemoryConfiguration, { + RABBITMQ_PASSCODE: 'some-rabbit-passcode', + ...DEFAULTS, + KEYS_API_URL, + KEYS_API_HOST, + KEYS_API_PORT, + }); + const [validationErrors] = await extractError( + configLoaderService.loadSecrets(prepConfig), + ); + toHaveProblemWithRecords(['KEYS_API_HOST'], validationErrors); + }); + }); + describe('wallet', () => { let configLoaderService: ConfigLoaderService; const DEFAULTS_WITH_RABBIT = { @@ -168,4 +279,173 @@ describe('ConfigLoaderService base spec', () => { expect(config).toHaveProperty('WALLET_PRIVATE_KEY', 'wallet'); }); }); + + describe('balance', () => { + const DEFAULTS_WITH_RABBIT = { + ...DEFAULTS, + RABBITMQ_PASSCODE: 'some-rabbit-passcode', + }; + + test('should throw an error for an excessively small WALLET_CRITICAL_BALANCE', async () => { + const WALLET_CRITICAL_BALANCE = '0.0000000000000000001'; + const plainConfig = plainToClass(InMemoryConfiguration, { + WALLET_CRITICAL_BALANCE, + ...DEFAULTS_WITH_RABBIT, + }); + + expect(plainConfig).toHaveProperty('WALLET_CRITICAL_BALANCE'); + expect(plainConfig.WALLET_CRITICAL_BALANCE).toBeNaN(); + + await validateOrReject(plainConfig, { + validationError: { target: false, value: false }, + }).catch((errors) => { + expect(errors).toBeInstanceOf(Array); + expect(errors.length).toBe(1); + expect(errors[0]).toBeInstanceOf(ValidationError); + expect(errors[0].property).toBe('WALLET_CRITICAL_BALANCE'); + expect(errors[0].constraints).toHaveProperty( + 'isInstance', + 'WALLET_CRITICAL_BALANCE must be an instance of BigNumber', + ); + }); + }); + + test('should throw an error for an empty WALLET_CRITICAL_BALANCE', async () => { + const WALLET_CRITICAL_BALANCE = ''; + const plainConfig = plainToClass(InMemoryConfiguration, { + WALLET_CRITICAL_BALANCE, + ...DEFAULTS_WITH_RABBIT, + }); + + expect(plainConfig).toHaveProperty('WALLET_CRITICAL_BALANCE'); + expect(plainConfig.WALLET_CRITICAL_BALANCE).toBeNaN(); + + await validateOrReject(plainConfig, { + validationError: { target: false, value: false }, + }).catch((errors) => { + expect(errors).toBeInstanceOf(Array); + expect(errors.length).toBe(1); + expect(errors[0]).toBeInstanceOf(ValidationError); + expect(errors[0].property).toBe('WALLET_CRITICAL_BALANCE'); + expect(errors[0].constraints).toHaveProperty( + 'isInstance', + 'WALLET_CRITICAL_BALANCE must be an instance of BigNumber', + ); + }); + }); + + test('should handle normal WALLET_CRITICAL_BALANCE values correctly', async () => { + const plainConfig = plainToClass(InMemoryConfiguration, { + WALLET_CRITICAL_BALANCE: '0.2', + ...DEFAULTS_WITH_RABBIT, + }); + + await validateOrReject(plainConfig, { + validationError: { target: false, value: false }, + }).then(() => { + expect(plainConfig).toHaveProperty('WALLET_CRITICAL_BALANCE'); + expect(plainConfig.WALLET_CRITICAL_BALANCE).toBeInstanceOf(BigNumber); + expect(plainConfig.WALLET_CRITICAL_BALANCE.toString()).toBe( + '200000000000000000', + ); + }); + }); + + test('should use default WALLET_CRITICAL_BALANCE value', async () => { + const plainConfig = plainToClass(InMemoryConfiguration, { + ...DEFAULTS_WITH_RABBIT, + }); + + await validateOrReject(plainConfig, { + validationError: { target: false, value: false }, + }).then(() => { + expect(plainConfig).toHaveProperty('WALLET_CRITICAL_BALANCE'); + expect(plainConfig.WALLET_CRITICAL_BALANCE).toBeInstanceOf(BigNumber); + expect(plainConfig.WALLET_CRITICAL_BALANCE.toString()).toBe( + '200000000000000000', + ); + }); + }); + + test('should throw an error for an excessively small WALLET_MIN_BALANCE', async () => { + const WALLET_MIN_BALANCE = '0.0000000000000000001'; + const plainConfig = plainToClass(InMemoryConfiguration, { + WALLET_MIN_BALANCE, + ...DEFAULTS_WITH_RABBIT, + }); + + expect(plainConfig).toHaveProperty('WALLET_MIN_BALANCE'); + expect(plainConfig.WALLET_MIN_BALANCE).toBeNaN(); + + await validateOrReject(plainConfig, { + validationError: { target: false, value: false }, + }).catch((errors) => { + expect(errors).toBeInstanceOf(Array); + expect(errors.length).toBe(1); + expect(errors[0]).toBeInstanceOf(ValidationError); + expect(errors[0].property).toBe('WALLET_MIN_BALANCE'); + expect(errors[0].constraints).toHaveProperty( + 'isInstance', + 'WALLET_MIN_BALANCE must be an instance of BigNumber', + ); + }); + }); + + test('should throw an error for an empty WALLET_MIN_BALANCE', async () => { + const WALLET_MIN_BALANCE = ''; + const plainConfig = plainToClass(InMemoryConfiguration, { + WALLET_MIN_BALANCE, + ...DEFAULTS_WITH_RABBIT, + }); + + expect(plainConfig).toHaveProperty('WALLET_MIN_BALANCE'); + expect(plainConfig.WALLET_MIN_BALANCE).toBeNaN(); + + await validateOrReject(plainConfig, { + validationError: { target: false, value: false }, + }).catch((errors) => { + expect(errors).toBeInstanceOf(Array); + expect(errors.length).toBe(1); + expect(errors[0]).toBeInstanceOf(ValidationError); + expect(errors[0].property).toBe('WALLET_MIN_BALANCE'); + expect(errors[0].constraints).toHaveProperty( + 'isInstance', + 'WALLET_MIN_BALANCE must be an instance of BigNumber', + ); + }); + }); + + test('should handle normal WALLET_MIN_BALANCE values correctly', async () => { + const plainConfig = plainToClass(InMemoryConfiguration, { + WALLET_MIN_BALANCE: '0.2', + ...DEFAULTS_WITH_RABBIT, + }); + + await validateOrReject(plainConfig, { + validationError: { target: false, value: false }, + }).then(() => { + expect(plainConfig).toHaveProperty('WALLET_MIN_BALANCE'); + expect(plainConfig.WALLET_MIN_BALANCE).toBeInstanceOf(BigNumber); + expect(plainConfig.WALLET_MIN_BALANCE.toString()).toBe( + '200000000000000000', + ); + }); + }); + + test('should use default WALLET_MIN_BALANCE value', async () => { + const plainConfig = plainToClass(InMemoryConfiguration, { + ...DEFAULTS_WITH_RABBIT, + }); + + await validateOrReject(plainConfig, { + validationError: { target: false, value: false }, + }).then(() => { + expect(plainConfig).toHaveProperty('WALLET_MIN_BALANCE'); + expect(plainConfig.WALLET_MIN_BALANCE).toBeInstanceOf(BigNumber); + expect(plainConfig.WALLET_MIN_BALANCE.toString()).toBe( + '500000000000000000', + ); + }); + }); + }); }); diff --git a/src/common/config/configuration.ts b/src/common/config/configuration.ts index 8abce4d4..5539d95a 100644 --- a/src/common/config/configuration.ts +++ b/src/common/config/configuration.ts @@ -1,5 +1,6 @@ import { createInterface } from '../di/functions/createInterface'; import { SASLMechanism } from '../../transport'; +import { ethers } from 'ethers'; export const Configuration = createInterface('Configuration'); @@ -30,4 +31,8 @@ export interface Configuration { REGISTRY_KEYS_QUERY_CONCURRENCY: number; KEYS_API_PORT: number; KEYS_API_HOST: string; + KEYS_API_URL: string; + LOCATOR_DEVNET_ADDRESS: string; + WALLET_MIN_BALANCE: ethers.BigNumber; + WALLET_CRITICAL_BALANCE: ethers.BigNumber; } diff --git a/src/common/config/in-memory-configuration.ts b/src/common/config/in-memory-configuration.ts index 0b7a5790..b69809cd 100644 --- a/src/common/config/in-memory-configuration.ts +++ b/src/common/config/in-memory-configuration.ts @@ -1,6 +1,7 @@ import { Transform } from 'class-transformer'; import { IsIn, + IsInstance, IsNotEmpty, IsNumber, IsOptional, @@ -12,6 +13,8 @@ import { Injectable } from '@nestjs/common'; import { Configuration, PubsubService } from './configuration'; import { SASLMechanism } from '../../transport'; import { implementationOf } from '../di/decorators/implementationOf'; +import { ethers, BigNumber } from 'ethers'; +import { TransformToWei } from 'common/decorators/transform-to-wei'; const RABBITMQ = 'rabbitmq'; const KAFKA = 'kafka'; @@ -125,13 +128,36 @@ export class InMemoryConfiguration implements Configuration { @Transform(({ value }) => parseInt(value, 10), { toClassOnly: true }) REGISTRY_KEYS_QUERY_CONCURRENCY = 5; + @ValidateIf((conf) => !conf.KEYS_API_URL) @IsNotEmpty() @IsNumber() @Min(1) @Transform(({ value }) => parseInt(value, 10), { toClassOnly: true }) - KEYS_API_PORT = 3001; + KEYS_API_PORT = 0; + + @ValidateIf((conf) => !conf.KEYS_API_URL) + @IsNotEmpty() + @IsString() + KEYS_API_HOST = ''; + + @ValidateIf((conf) => { + return !conf.KEYS_API_PORT && !conf.KEYS_API_HOST; + }) + @IsNotEmpty() + @IsString() + KEYS_API_URL = ''; @IsOptional() @IsString() - KEYS_API_HOST = 'http://localhost'; + LOCATOR_DEVNET_ADDRESS = ''; + + @IsOptional() + @TransformToWei() + @IsInstance(BigNumber) + WALLET_MIN_BALANCE: BigNumber = ethers.utils.parseEther('0.5'); + + @IsOptional() + @TransformToWei() + @IsInstance(BigNumber) + WALLET_CRITICAL_BALANCE: BigNumber = ethers.utils.parseEther('0.2'); } diff --git a/src/common/decorators/one-at-time.spec.ts b/src/common/decorators/one-at-time.spec.ts new file mode 100644 index 00000000..514b14d7 --- /dev/null +++ b/src/common/decorators/one-at-time.spec.ts @@ -0,0 +1,78 @@ +import { OneAtTime, OneAtTimeCallId } from './one-at-time'; + +class TestOneAtTime { + public value; + public oneAtTimeCallId = new Map(); + + public executionLog: string[] = []; + + sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); + } + + @OneAtTime(2000) + async test(value: number) { + this.executionLog.push(`start-${value}`); + this.value = value; + + await this.sleep(1000); + this.executionLog.push(`end-${value}`); + } + + @OneAtTime(2000) + async testOneAtTimeCallId(@OneAtTimeCallId id, value) { + this.executionLog.push(`start-${id}-${value}`); + this.oneAtTimeCallId.set(id, value); + + await this.sleep(1000); + this.executionLog.push(`end-${id}-${value}`); + } +} + +it('OneAtTime', async () => { + const testOneAtTime = new TestOneAtTime(); + + testOneAtTime.test(1); + testOneAtTime.test(2); + + await testOneAtTime.sleep(1100); + + expect(testOneAtTime.value).toEqual(1); + expect(testOneAtTime.executionLog).toEqual(['start-1', 'end-1']); + + testOneAtTime.executionLog = []; + await testOneAtTime.test(2); + + expect(testOneAtTime.value).toEqual(2); + expect(testOneAtTime.executionLog).toEqual(['start-2', 'end-2']); +}); + +it('StakingModuleId', async () => { + const testOneAtTime = new TestOneAtTime(); + + expect(testOneAtTime.oneAtTimeCallId.get(1)).toBeUndefined(); + expect(testOneAtTime.oneAtTimeCallId.get(2)).toBeUndefined(); + + testOneAtTime.testOneAtTimeCallId(1, 1); + testOneAtTime.testOneAtTimeCallId(1, 2); + testOneAtTime.testOneAtTimeCallId(2, 2); + + await testOneAtTime.sleep(1500); + + expect(testOneAtTime.executionLog.length).toEqual(4); + expect(testOneAtTime.executionLog).toEqual( + expect.arrayContaining(['start-1-1', 'end-1-1', 'start-2-2', 'end-2-2']), + ); + + expect(testOneAtTime.oneAtTimeCallId.get(1)).toEqual(1); + expect(testOneAtTime.oneAtTimeCallId.get(2)).toEqual(2); + + testOneAtTime.executionLog = []; + await testOneAtTime.testOneAtTimeCallId(1, 2); + + expect(testOneAtTime.executionLog.length).toEqual(2); + expect(testOneAtTime.executionLog).toEqual( + expect.arrayContaining(['start-1-2', 'end-1-2']), + ); + expect(testOneAtTime.oneAtTimeCallId.get(1)).toEqual(2); +}); diff --git a/src/common/decorators/one-at-time.ts b/src/common/decorators/one-at-time.ts index abdf7d60..9060a03b 100644 --- a/src/common/decorators/one-at-time.ts +++ b/src/common/decorators/one-at-time.ts @@ -1,22 +1,101 @@ -export function OneAtTime Promise>() { +import 'reflect-metadata'; +const oneAtTimeCallIdKey = Symbol('OneAtTimeCallId'); + +/** + * A decorator that marks a specific parameter in a method for identifying the OneAtTime call ID. + * This ID allows the same method to be executed concurrently with different parameters. + */ +export function OneAtTimeCallId( + target: any, + propertyKey: string | symbol, + parameterIndex: number, +) { + const existingMetadata: number[] = + Reflect.getOwnMetadata(oneAtTimeCallIdKey, target, propertyKey) || []; + + if (existingMetadata.length === 0) { + Reflect.defineMetadata( + oneAtTimeCallIdKey, + [parameterIndex], + target, + propertyKey, + ); + } else { + throw new Error( + `OneAtTimeCallId decorator can only be applied to one parameter in method ${String( + propertyKey, + )}. It is already applied to parameter index ${existingMetadata[0]}`, + ); + } +} + +/** + * A decorator factory that ensures a function executes one at a time. + * Calls to the decorated method are restricted so that only one instance can be executed concurrently, + * either globally or per OneAtTime call ID. + * A stuck function with the OneAtTime decorator will prevent the next executions of this function. + * That is why a timeout is set. If the execution of the promise is stuck, a timeout will occur. The default timeout is 10 minutes. + */ +export function OneAtTime Promise>( + timeout = 600000, +) { return function ( - target: unknown, + target: any, propertyName: string, descriptor: TypedPropertyDescriptor, ) { const method = descriptor.value; let isExecuting = false; + const isExecutingMap = new Map(); descriptor.value = async function (this: any, ...args) { - if (isExecuting) return; + const oneAtTimeCallIdArgs = + Reflect.getMetadata(oneAtTimeCallIdKey, target, propertyName) || []; - try { + const callId = + oneAtTimeCallIdArgs.length > 0 ? args[oneAtTimeCallIdArgs[0]] : null; + + if ((callId && isExecutingMap.get(callId)) || isExecuting) { + this.logger?.debug(`Already running ${propertyName}`, { + propertyName, + executing: isExecuting, + }); + + return; + } + + if (callId) { + isExecutingMap.set(callId, true); + } else { isExecuting = true; - return await method?.apply(this, args); + } + + let handler: NodeJS.Timeout | undefined; + + try { + const execTimeout = new Promise((_, reject) => { + handler = setTimeout(() => { + reject( + new Error( + `Timeout: ${propertyName} took longer than ${timeout}ms`, + ), + ); + }, timeout); + }); + + return await Promise.race([method?.apply(this, args), execTimeout]); } catch (error) { this.logger.error(error); } finally { - isExecuting = false; + if (callId) { + isExecutingMap.set(callId, false); + } else { + isExecuting = false; + } + + if (handler !== undefined) { + clearTimeout(handler); + } } } as T; }; diff --git a/src/common/decorators/transform-to-wei.ts b/src/common/decorators/transform-to-wei.ts new file mode 100644 index 00000000..5960636a --- /dev/null +++ b/src/common/decorators/transform-to-wei.ts @@ -0,0 +1,16 @@ +import { Transform } from 'class-transformer'; +import { ethers } from 'ethers'; + +export function TransformToWei() { + return Transform( + ({ value }) => { + try { + const weiValue = ethers.utils.parseEther(value); + return weiValue; + } catch (error) { + return NaN; + } + }, + { toClassOnly: true }, + ); +} diff --git a/src/common/prometheus/prometheus.constants.ts b/src/common/prometheus/prometheus.constants.ts index 5ac5a13d..0ff91f44 100644 --- a/src/common/prometheus/prometheus.constants.ts +++ b/src/common/prometheus/prometheus.constants.ts @@ -3,6 +3,7 @@ export const METRICS_PREFIX = 'council_daemon_'; export const METRIC_SENT_MESSAGES = `${METRICS_PREFIX}sent_messages_total`; export const METRIC_PAUSE_ATTEMPTS = `${METRICS_PREFIX}pause_deposits_attempts_total`; +export const METRIC_UNVET_ATTEMPTS = `${METRICS_PREFIX}unvet_attempts_total`; export const METRIC_RPC_REQUEST_DURATION = `${METRICS_PREFIX}rpc_requests_duration_seconds`; export const METRIC_RPC_REQUEST_ERRORS = `${METRICS_PREFIX}rpc_requests_errors`; @@ -22,8 +23,6 @@ export const METRIC_OPERATORS_KEYS_TOTAL = `${METRICS_PREFIX}operators_keys_tota export const METRIC_KEYS_API_REQUEST_DURATION = `${METRICS_PREFIX}keys_api_requests_duration_seconds`; -export const METRIC_DUPLICATED_VETTED_UNUSED_KEYS_TOTAL = `${METRICS_PREFIX}duplicated_vetted_unused_keys`; +export const METRIC_DUPLICATED_KEYS_TOTAL = `${METRICS_PREFIX}duplicated_keys_total`; -export const METRIC_DUPLICATED_USED_KEYS_TOTAL = `${METRICS_PREFIX}duplicated_used_keys`; - -export const METRIC_INVALID_KEYS_TOTAL = `${METRICS_PREFIX}invalid_keys`; +export const METRIC_INVALID_KEYS_TOTAL = `${METRICS_PREFIX}invalid_keys_total`; diff --git a/src/common/prometheus/prometheus.module.ts b/src/common/prometheus/prometheus.module.ts index aae759ea..5e593547 100644 --- a/src/common/prometheus/prometheus.module.ts +++ b/src/common/prometheus/prometheus.module.ts @@ -13,9 +13,9 @@ import { PrometheusDepositedKeysProvider, PrometheusOperatorsKeysProvider, PrometheusKeysApiRequestsProvider, - PrometheusUsedKeysProvider, - PrometheusVettedUnusedKeysProvider, + PrometheusDuplicatedKeysProvider, PrometheusInvalidKeysProvider, + PrometheusUnvetKeysCounterProvider, } from './prometheus.provider'; import { METRICS_PREFIX, METRICS_URL } from './prometheus.constants'; @@ -41,9 +41,9 @@ const providers = [ PrometheusDepositedKeysProvider, PrometheusOperatorsKeysProvider, PrometheusKeysApiRequestsProvider, - PrometheusVettedUnusedKeysProvider, - PrometheusUsedKeysProvider, + PrometheusDuplicatedKeysProvider, PrometheusInvalidKeysProvider, + PrometheusUnvetKeysCounterProvider, ]; PrometheusModule.global = true; diff --git a/src/common/prometheus/prometheus.provider.ts b/src/common/prometheus/prometheus.provider.ts index 63c46211..afa426ca 100644 --- a/src/common/prometheus/prometheus.provider.ts +++ b/src/common/prometheus/prometheus.provider.ts @@ -17,9 +17,9 @@ import { METRIC_DEPOSITED_KEYS_TOTAL, METRIC_OPERATORS_KEYS_TOTAL, METRIC_KEYS_API_REQUEST_DURATION, - METRIC_DUPLICATED_VETTED_UNUSED_KEYS_TOTAL, - METRIC_DUPLICATED_USED_KEYS_TOTAL, + METRIC_DUPLICATED_KEYS_TOTAL, METRIC_INVALID_KEYS_TOTAL, + METRIC_UNVET_ATTEMPTS, } from './prometheus.constants'; export const PrometheusTransportMessageCounterProvider = makeCounterProvider({ @@ -33,6 +33,11 @@ export const PrometheusPauseDepositsCounterProvider = makeCounterProvider({ help: 'Attempts to pause deposits', }); +export const PrometheusUnvetKeysCounterProvider = makeCounterProvider({ + name: METRIC_UNVET_ATTEMPTS, + help: 'Attempts to unvet keys', +}); + export const PrometheusRPCRequestsHistogramProvider = makeHistogramProvider({ name: METRIC_RPC_REQUEST_DURATION, help: 'RPC request duration', @@ -97,16 +102,10 @@ export const PrometheusKeysApiRequestsProvider = makeHistogramProvider({ labelNames: ['result', 'status'] as const, }); -export const PrometheusVettedUnusedKeysProvider = makeGaugeProvider({ - name: METRIC_DUPLICATED_VETTED_UNUSED_KEYS_TOTAL, - help: 'Number of duplicated vetted unused keys events', - labelNames: ['stakingModuleId'] as const, -}); - -export const PrometheusUsedKeysProvider = makeGaugeProvider({ - name: METRIC_DUPLICATED_USED_KEYS_TOTAL, - help: 'Number of duplicated used keys events', - labelNames: ['stakingModuleId'] as const, +export const PrometheusDuplicatedKeysProvider = makeGaugeProvider({ + name: METRIC_DUPLICATED_KEYS_TOTAL, + help: 'Number of duplicated keys', + labelNames: ['type', 'stakingModuleId'] as const, }); export const PrometheusInvalidKeysProvider = makeGaugeProvider({ diff --git a/src/contracts/deposit/deposit.constants.ts b/src/contracts/deposit/deposit.constants.ts deleted file mode 100644 index 300f6afb..00000000 --- a/src/contracts/deposit/deposit.constants.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { CHAINS } from '@lido-sdk/constants'; - -export const DEPLOYMENT_BLOCK_NETWORK: { - [key in CHAINS]?: number; -} = { - [CHAINS.Mainnet]: 11052984, - [CHAINS.Goerli]: 4367322, - [CHAINS.Holesky]: 0, -}; - -export const getDeploymentBlockByNetwork = (chainId: CHAINS): number => { - const address = DEPLOYMENT_BLOCK_NETWORK[chainId]; - if (address == null) throw new Error(`Chain ${chainId} is not supported`); - - return address; -}; - -export const DEPOSIT_EVENTS_CACHE_LAG_BLOCKS = 100; -export const DEPOSIT_EVENTS_STEP = 10_000; -export const DEPOSIT_EVENTS_CACHE_UPDATE_BLOCK_RATE = 10; - -export const DEPOSIT_CACHE_FILE_NAME = 'deposit.events.json'; -export const DEPOSIT_CACHE_BATCH_SIZE = 100_000; - -export const DEPOSIT_CACHE_DEFAULT = Object.freeze({ - headers: { - version: '-1', - startBlock: 0, - endBlock: 0, - }, - data: [], -}); diff --git a/src/contracts/deposit/deposit.module.ts b/src/contracts/deposit/deposit.module.ts deleted file mode 100644 index 2c730b81..00000000 --- a/src/contracts/deposit/deposit.module.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { Module } from '@nestjs/common'; -import { SecurityModule } from 'contracts/security'; -import { CacheModule } from 'cache'; -import { BlsModule } from 'bls'; -import { DepositService } from './deposit.service'; -import { - DEPOSIT_CACHE_BATCH_SIZE, - DEPOSIT_CACHE_DEFAULT, - DEPOSIT_CACHE_FILE_NAME, -} from './deposit.constants'; - -@Module({ - imports: [ - BlsModule, - SecurityModule, - CacheModule.register( - DEPOSIT_CACHE_FILE_NAME, - DEPOSIT_CACHE_BATCH_SIZE, - DEPOSIT_CACHE_DEFAULT, - ), - ], - providers: [DepositService], - exports: [DepositService], -}) -export class DepositModule {} diff --git a/src/contracts/deposit/deposit.service.spec.ts b/src/contracts/deposit/deposit.service.spec.ts deleted file mode 100644 index 9064b10f..00000000 --- a/src/contracts/deposit/deposit.service.spec.ts +++ /dev/null @@ -1,518 +0,0 @@ -jest.mock('utils/sleep'); - -import { CHAINS } from '@lido-sdk/constants'; -import { Test } from '@nestjs/testing'; -import { WINSTON_MODULE_NEST_PROVIDER } from 'nest-winston'; -import { Interface } from '@ethersproject/abi'; -import { LoggerService } from '@nestjs/common'; -import { getNetwork } from '@ethersproject/networks'; -import { sleep } from 'utils'; -import { CacheService } from 'cache'; -import { - ERRORS_LIMIT_EXCEEDED, - MockProviderModule, - ProviderService, -} from 'provider'; -import { DepositAbi__factory } from 'generated'; -import { RepositoryModule, RepositoryService } from 'contracts/repository'; -import { - VerifiedDepositEventsCacheHeaders, - VerifiedDepositEvent, -} from './interfaces'; -import { DepositModule } from './deposit.module'; -import { DepositService } from './deposit.service'; -import { PrometheusModule } from 'common/prometheus'; -import { LoggerModule } from 'common/logger'; -import { ConfigModule } from 'common/config'; -import { APP_VERSION } from 'app.constants'; -import { BlsService } from 'bls'; -import { LocatorService } from 'contracts/repository/locator/locator.service'; -import { mockLocator } from 'contracts/repository/locator/locator.mock'; -import { mockRepository } from 'contracts/repository/repository.mock'; - -const mockSleep = sleep as jest.MockedFunction; - -describe('DepositService', () => { - let providerService: ProviderService; - let cacheService: CacheService< - VerifiedDepositEventsCacheHeaders, - VerifiedDepositEvent - >; - let depositService: DepositService; - let loggerService: LoggerService; - let repositoryService: RepositoryService; - let blsService: BlsService; - let locatorService: LocatorService; - - const depositAddress = '0x' + '1'.repeat(40); - - beforeEach(async () => { - const moduleRef = await Test.createTestingModule({ - imports: [ - ConfigModule.forRoot(), - MockProviderModule.forRoot(), - DepositModule, - PrometheusModule, - LoggerModule, - RepositoryModule, - ], - }).compile(); - - providerService = moduleRef.get(ProviderService); - cacheService = moduleRef.get(CacheService); - depositService = moduleRef.get(DepositService); - repositoryService = moduleRef.get(RepositoryService); - blsService = moduleRef.get(BlsService); - loggerService = moduleRef.get(WINSTON_MODULE_NEST_PROVIDER); - - locatorService = moduleRef.get(LocatorService); - - jest.spyOn(loggerService, 'log').mockImplementation(() => undefined); - jest.spyOn(loggerService, 'warn').mockImplementation(() => undefined); - jest.spyOn(loggerService, 'debug').mockImplementation(() => undefined); - - mockLocator(locatorService); - await mockRepository(repositoryService); - - jest - .spyOn(repositoryService, 'getDepositAddress') - .mockImplementation(async () => depositAddress); - }); - - describe('formatEvent', () => { - it.todo('should return event in the correct format'); - }); - - describe('getDeploymentBlockByNetwork', () => { - it('should return block number for goerli', async () => { - jest - .spyOn(providerService.provider, 'detectNetwork') - .mockImplementation(async () => getNetwork(CHAINS.Goerli)); - - const blockNumber = await depositService.getDeploymentBlockByNetwork(); - expect(typeof blockNumber).toBe('number'); - expect(blockNumber).toBeGreaterThan(0); - }); - - it('should return block number for mainnet', async () => { - jest - .spyOn(providerService.provider, 'detectNetwork') - .mockImplementation(async () => getNetwork(CHAINS.Mainnet)); - - const blockNumber = await depositService.getDeploymentBlockByNetwork(); - expect(typeof blockNumber).toBe('number'); - expect(blockNumber).toBeGreaterThan(0); - }); - }); - - describe('getCachedEvents', () => { - const deploymentBlock = 100; - - beforeEach(async () => { - jest - .spyOn(depositService, 'getDeploymentBlockByNetwork') - .mockImplementation(async () => deploymentBlock); - }); - - it('should return events from cache', async () => { - const cache = { - data: [{} as any], - headers: { - startBlock: deploymentBlock, - endBlock: deploymentBlock + 100, - version: '1', - }, - }; - - const mockCache = jest - .spyOn(cacheService, 'getCache') - .mockImplementation(async () => cache); - - const result = await depositService.getCachedEvents(); - - expect(mockCache).toBeCalledTimes(1); - expect(result).toEqual(cache); - }); - - it('should return deploymentBlock if cache is empty', async () => { - const cache = { - data: [{} as any], - headers: { - startBlock: 0, - endBlock: 0, - version: '1', - }, - }; - - const mockCache = jest - .spyOn(cacheService, 'getCache') - .mockImplementation(async () => cache); - - const result = await depositService.getCachedEvents(); - - expect(mockCache).toBeCalledTimes(1); - expect(result.headers.startBlock).toBe(deploymentBlock); - expect(result.headers.endBlock).toBe(deploymentBlock); - }); - }); - - describe('setCachedEvents', () => { - it('should call setCache from the cacheService', async () => { - const eventGroup = {} as any; - - const mockSetCache = jest - .spyOn(cacheService, 'setCache') - .mockImplementation(async () => undefined); - - await depositService.setCachedEvents(eventGroup); - - expect(mockSetCache).toBeCalledTimes(1); - expect(mockSetCache).toBeCalledWith({ - ...eventGroup, - headers: { version: APP_VERSION }, - }); - }); - }); - - describe('fetchEventsFallOver', () => { - it('should fetch events', async () => { - const expected = {} as any; - const from = 0; - const to = 10; - - const mockFetchEvents = jest - .spyOn(depositService, 'fetchEvents') - .mockImplementation(async () => expected); - - const result = await depositService.fetchEventsFallOver(from, to); - - expect(mockFetchEvents).toBeCalledTimes(1); - expect(mockFetchEvents).toBeCalledWith(from, to); - expect(result).toBe(expected); - }); - - it('should fetch recursive if limit exceeded', async () => { - const event1 = {} as any; - const event2 = {} as any; - const expectedFirst = { events: [event1], startBlock: 0, endBlock: 4 }; - const expectedSecond = { events: [event2], startBlock: 5, endBlock: 10 }; - - const startBlock = 0; - const endBlock = 10; - - const mockFetchEvents = jest - .spyOn(depositService, 'fetchEvents') - .mockImplementationOnce(async () => { - throw { error: { code: ERRORS_LIMIT_EXCEEDED[0] } }; - }) - .mockImplementationOnce(async () => expectedFirst) - .mockImplementationOnce(async () => expectedSecond); - - const result = await depositService.fetchEventsFallOver( - startBlock, - endBlock, - ); - - const { calls, results } = mockFetchEvents.mock; - const events = [event1, event2]; - - expect(result).toEqual({ events, startBlock, endBlock }); - expect(mockFetchEvents).toBeCalledTimes(3); - expect(calls[0]).toEqual([startBlock, endBlock]); - expect(calls[1]).toEqual([ - expectedFirst.startBlock, - expectedFirst.endBlock, - ]); - expect(calls[2]).toEqual([ - expectedSecond.startBlock, - expectedSecond.endBlock, - ]); - await expect(results[1].value).resolves.toEqual(expectedFirst); - await expect(results[2].value).resolves.toEqual(expectedSecond); - }); - - it('should retry if error is unknown', async () => { - const events = []; - const startBlock = 0; - const endBlock = 10; - const expected = { events, startBlock, endBlock }; - - mockSleep.mockImplementationOnce(async () => undefined); - - const mockFetchEvents = jest - .spyOn(depositService, 'fetchEvents') - .mockImplementationOnce(async () => { - throw new Error(); - }) - .mockImplementationOnce(async () => expected); - - const result = await depositService.fetchEventsFallOver( - startBlock, - endBlock, - ); - - const { calls, results } = mockFetchEvents.mock; - - expect(result).toEqual(expected); - expect(mockFetchEvents).toBeCalledTimes(2); - expect(calls[0]).toEqual([startBlock, endBlock]); - expect(calls[1]).toEqual([startBlock, endBlock]); - await expect(results[0].value).rejects.toThrow(); - await expect(results[1].value).resolves.toEqual(expected); - - expect(mockSleep).toBeCalledTimes(1); - expect(mockSleep).toBeCalledWith(expect.any(Number)); - }); - }); - - describe('fetchEvents', () => { - it('should fetch events', async () => { - const freshPubkeys = ['0x4321', '0x8765']; - const startBlock = 100; - const endBlock = 200; - - jest - .spyOn(providerService.provider, 'getBlockNumber') - .mockImplementation(async () => endBlock); - - jest.spyOn(blsService, 'verify').mockImplementation(() => true); - - const mockProviderCall = jest - .spyOn(providerService.provider, 'getLogs') - .mockImplementation(async () => { - const iface = new Interface(DepositAbi__factory.abi); - const eventFragment = iface.getEvent('DepositEvent'); - - return freshPubkeys.map((pubkey) => { - const args = [pubkey, '0x', '0x', '0x', 1]; - return iface.encodeEventLog(eventFragment, args) as any; - }); - }); - - const result = await depositService.fetchEvents(startBlock, endBlock); - expect(result).toEqual( - expect.objectContaining({ - startBlock, - endBlock, - events: freshPubkeys.map((pubkey) => - expect.objectContaining({ pubkey }), - ), - }), - ); - expect(mockProviderCall).toBeCalledTimes(1); - }); - }); - - describe('updateEventsCache', () => { - const cachedPubkeys = ['0x1234', '0x5678']; - const cache = { - headers: { - startBlock: 0, - endBlock: 2, - version: '1', - }, - data: cachedPubkeys.map((pubkey) => ({ pubkey } as any)), - }; - const currentBlock = 1000; - const firstNotCachedBlock = cache.headers.endBlock + 1; - - beforeEach(async () => { - jest - .spyOn(depositService, 'getCachedEvents') - .mockImplementation(async () => ({ ...cache })); - - jest - .spyOn(providerService, 'getBlockNumber') - .mockImplementation(async () => currentBlock); - }); - - it('should collect events', async () => { - const mockFetchEventsFallOver = jest - .spyOn(depositService, 'fetchEventsFallOver') - .mockImplementation(async (startBlock, endBlock) => ({ - startBlock, - endBlock, - events: [], - })); - - jest - .spyOn(depositService, 'setCachedEvents') - .mockImplementation(async () => undefined); - - await depositService.updateEventsCache(); - - expect(mockFetchEventsFallOver).toBeCalledTimes(1); - const { calls: fetchCalls } = mockFetchEventsFallOver.mock; - expect(fetchCalls[0][0]).toBe(firstNotCachedBlock); - expect(fetchCalls[0][1]).toBeLessThan(currentBlock); - }); - - it('should save events to the cache', async () => { - jest - .spyOn(depositService, 'fetchEventsFallOver') - .mockImplementation(async (startBlock, endBlock) => ({ - startBlock, - endBlock, - events: [], - })); - - const mockSetCachedEvents = jest - .spyOn(depositService, 'setCachedEvents') - .mockImplementation(async () => undefined); - - await depositService.updateEventsCache(); - - expect(mockSetCachedEvents).toBeCalledTimes(1); - const { calls: cacheCalls } = mockSetCachedEvents.mock; - expect(cacheCalls[0][0].headers.startBlock).toBe( - cache.headers.startBlock, - ); - expect(cacheCalls[0][0].headers.endBlock).toBeLessThan(currentBlock); - expect(cacheCalls[0][0].data).toEqual(cache.data); - }); - }); - - describe('getAllDepositedEvents', () => { - const cachedPubkeys = ['0x1234', '0x5678']; - const freshPubkeys = ['0x4321', '0x8765']; - const cachedEvents = { - headers: { - startBlock: 0, - endBlock: 2, - version: '1', - }, - data: cachedPubkeys.map((pubkey) => ({ pubkey } as any)), - }; - const currentBlock = 10; - const currentBlockHash = '0x12'; - const firstNotCachedBlock = cachedEvents.headers.endBlock + 1; - - beforeEach(async () => { - jest - .spyOn(depositService, 'getCachedEvents') - .mockImplementation(async () => ({ ...cachedEvents })); - - jest - .spyOn(providerService, 'getBlockNumber') - .mockImplementation(async () => currentBlock); - }); - - it('should return cached events', async () => { - const mockFetchEventsFallOver = jest - .spyOn(depositService, 'fetchEventsFallOver') - .mockImplementation(async () => ({ - startBlock: firstNotCachedBlock, - endBlock: currentBlock, - events: [], - })); - - const result = await depositService.getAllDepositedEvents( - currentBlock, - currentBlockHash, - ); - expect(result).toEqual({ - events: cachedEvents.data, - startBlock: cachedEvents.headers.startBlock, - endBlock: currentBlock, - }); - - expect(mockFetchEventsFallOver).toBeCalledTimes(1); - expect(mockFetchEventsFallOver).toBeCalledWith( - firstNotCachedBlock, - currentBlock, - ); - }); - - it('should return merged pub keys', async () => { - const mockFetchEventsFallOver = jest - .spyOn(depositService, 'fetchEventsFallOver') - .mockImplementation(async () => ({ - startBlock: firstNotCachedBlock, - endBlock: currentBlock, - events: freshPubkeys.map((pubkey) => ({ pubkey } as any)), - })); - - const result = await depositService.getAllDepositedEvents( - currentBlock, - currentBlockHash, - ); - expect(result).toEqual({ - startBlock: cachedEvents.headers.startBlock, - endBlock: currentBlock, - events: cachedPubkeys - .concat(freshPubkeys) - .map((pubkey) => ({ pubkey } as any)), - }); - expect(mockFetchEventsFallOver).toBeCalledTimes(1); - expect(mockFetchEventsFallOver).toBeCalledWith( - firstNotCachedBlock, - currentBlock, - ); - }); - - it('should throw if event blockhash is different', async () => { - const anotherBlockHash = '0x34'; - - jest - .spyOn(depositService, 'fetchEventsFallOver') - .mockImplementation(async () => ({ - startBlock: firstNotCachedBlock, - endBlock: currentBlock, - events: freshPubkeys.map( - (pubkey) => - ({ - pubkey, - blockNumber: currentBlock, - blockHash: anotherBlockHash, - } as any), - ), - })); - - await expect( - depositService.getAllDepositedEvents(currentBlock, currentBlockHash), - ).rejects.toThrow(); - }); - }); - - describe('checkEventsBlockHash', () => { - const events = [ - { blockNumber: 1, blockHash: '0x1' }, - { blockNumber: 2, blockHash: '0x2' }, - ] as any; - - it('should throw if blockhash is different', async () => { - expect(() => { - depositService.checkEventsBlockHash(events, 2, '0x3'); - }).toThrow(); - }); - - it('should not throw if there are no events for the block', async () => { - expect(() => { - depositService.checkEventsBlockHash(events, 3, '0x3'); - }).not.toThrow(); - }); - - it('should not throw if blockhash is the same', async () => { - expect(() => { - depositService.checkEventsBlockHash(events, 2, '0x2'); - }).not.toThrow(); - }); - }); - - describe('getDepositRoot', () => { - it('should return deposit root', async () => { - const expected = '0x' + '0'.repeat(64); - - const mockProviderCall = jest - .spyOn(providerService.provider, 'call') - .mockImplementation(async () => { - const iface = new Interface(DepositAbi__factory.abi); - return iface.encodeFunctionResult('get_deposit_root', [expected]); - }); - - const result = await depositService.getDepositRoot(); - expect(result).toEqual(expected); - expect(mockProviderCall).toBeCalledTimes(1); - }); - }); -}); diff --git a/src/contracts/deposit/deposit.service.ts b/src/contracts/deposit/deposit.service.ts deleted file mode 100644 index 10086b80..00000000 --- a/src/contracts/deposit/deposit.service.ts +++ /dev/null @@ -1,396 +0,0 @@ -import { Inject, Injectable, LoggerService } from '@nestjs/common'; -import { performance } from 'perf_hooks'; -import { WINSTON_MODULE_NEST_PROVIDER } from 'nest-winston'; -import { ProviderService } from 'provider'; -import { DepositEventEvent } from 'generated/DepositAbi'; -import { - DEPOSIT_EVENTS_STEP, - getDeploymentBlockByNetwork, - DEPOSIT_EVENTS_CACHE_UPDATE_BLOCK_RATE, - DEPOSIT_EVENTS_CACHE_LAG_BLOCKS, -} from './deposit.constants'; -import { - DepositEvent, - VerifiedDepositEvent, - VerifiedDepositEventsCache, - VerifiedDepositEventsCacheHeaders, - VerifiedDepositEventGroup, -} from './interfaces'; -import { RepositoryService } from 'contracts/repository'; -import { CacheService } from 'cache'; -import { BlockTag } from 'provider'; -import { BlsService } from 'bls'; -import { APP_VERSION } from 'app.constants'; - -@Injectable() -export class DepositService { - constructor( - @Inject(WINSTON_MODULE_NEST_PROVIDER) private logger: LoggerService, - private providerService: ProviderService, - private repositoryService: RepositoryService, - private cacheService: CacheService< - VerifiedDepositEventsCacheHeaders, - VerifiedDepositEvent - >, - private blsService: BlsService, - ) {} - - public async handleNewBlock(blockNumber: number): Promise { - if (blockNumber % DEPOSIT_EVENTS_CACHE_UPDATE_BLOCK_RATE !== 0) return; - - // The event cache is stored with an N block lag to avoid caching data from uncle blocks - // so we don't worry about blockHash here - await this.updateEventsCache(); - } - - public async initialize(blockNumber: number) { - const cachedEvents = await this.getCachedEvents(); - const isCacheValid = this.validateCache(cachedEvents, blockNumber); - - if (isCacheValid) return; - - try { - await this.deleteCachedEvents(); - } catch (error) { - this.logger.error(error); - process.exit(1); - } - } - - /** - * Validates the app cache - * @param cachedEvents - cached events - * @param currentBlock - current block number - * @returns true if cache is valid - */ - public validateCache( - cachedEvents: VerifiedDepositEventsCache, - currentBlock: number, - ): boolean { - return ( - this.validateCacheBlock(cachedEvents, currentBlock) && - this.validateCacheVersion(cachedEvents) - ); - } - - /** - * Validates app version in the cache - * @param cachedEvents - cached events - * @returns true if cached app version is the same - */ - public validateCacheVersion( - cachedEvents: VerifiedDepositEventsCache, - ): boolean { - const isSameVersion = cachedEvents.headers.version === APP_VERSION; - - const versions = { - cachedVersion: cachedEvents.headers.version, - currentVersion: APP_VERSION, - }; - - if (isSameVersion) { - this.logger.log( - 'Deposit events cache version matches the application version', - versions, - ); - } - - if (!isSameVersion) { - this.logger.warn( - 'Deposit events cache does not match the application version, clearing the cache', - versions, - ); - } - - return isSameVersion; - } - - /** - * Validates block number in the cache - * @param cachedEvents - cached events - * @param currentBlock - current block number - * @returns true if cached app version is the same - */ - public validateCacheBlock( - cachedEvents: VerifiedDepositEventsCache, - currentBlock: number, - ): boolean { - const isCacheValid = currentBlock >= cachedEvents.headers.endBlock; - - const blocks = { - cachedStartBlock: cachedEvents.headers.startBlock, - cachedEndBlock: cachedEvents.headers.endBlock, - currentBlock, - }; - - if (isCacheValid) { - this.logger.log('Deposit events cache has valid age', blocks); - } - - if (!isCacheValid) { - this.logger.warn( - 'Deposit events cache is newer than the current block', - blocks, - ); - } - - return isCacheValid; - } - - /** - * Returns only required information about the event, - * to reduce the size of the information stored in the cache - */ - public formatEvent(rawEvent: DepositEventEvent): DepositEvent { - const { - args, - transactionHash: tx, - blockNumber, - blockHash, - logIndex, - } = rawEvent; - const { withdrawal_credentials: wc, pubkey, amount, signature } = args; - - return { - pubkey, - wc, - amount, - signature, - tx, - blockNumber, - blockHash, - logIndex, - }; - } - - /** - * Returns a block number when the deposited contract was deployed - * @returns block number - */ - public async getDeploymentBlockByNetwork(): Promise { - const chainId = await this.providerService.getChainId(); - return getDeploymentBlockByNetwork(chainId); - } - - /** - * Gets node operators data from cache - * @returns event group - */ - public async getCachedEvents(): Promise { - const { headers, ...rest } = await this.cacheService.getCache(); - const deploymentBlock = await this.getDeploymentBlockByNetwork(); - - return { - headers: { - ...headers, - startBlock: Math.max(headers.startBlock, deploymentBlock), - endBlock: Math.max(headers.endBlock, deploymentBlock), - }, - ...rest, - }; - } - - /** - * Saves deposited events to cache - */ - public async setCachedEvents( - cachedEvents: VerifiedDepositEventsCache, - ): Promise { - return await this.cacheService.setCache({ - ...cachedEvents, - headers: { - ...cachedEvents.headers, - version: APP_VERSION, - }, - }); - } - - /** - * Delete deposited events cache - */ - public async deleteCachedEvents(): Promise { - await this.cacheService.deleteCache(); - this.logger.warn('Deposit events cache cleared'); - } - - /** - * Returns events in the block range - * If the request failed, it tries to repeat it or split it into two - * @param startBlock - start of the range - * @param endBlock - end of the range - * @returns event group - */ - public async fetchEventsFallOver( - startBlock: number, - endBlock: number, - ): Promise { - return await this.providerService.fetchEventsFallOver( - startBlock, - endBlock, - this.fetchEvents.bind(this), - ); - } - - /** - * Returns events in the block range - * @param startBlock - start of the range - * @param endBlock - end of the range - * @returns event group - */ - public async fetchEvents( - startBlock: number, - endBlock: number, - ): Promise { - const contract = await this.repositoryService.getCachedDepositContract(); - const filter = contract.filters.DepositEvent(); - const rawEvents = await contract.queryFilter(filter, startBlock, endBlock); - const events = rawEvents.map((rawEvent) => { - const formatted = this.formatEvent(rawEvent); - const valid = this.verifyDeposit(formatted); - return { valid, ...formatted }; - }); - - return { events, startBlock, endBlock }; - } - - /** - * Updates the cache deposited events - * The last N blocks are not stored, in order to avoid storing reorganized blocks - */ - public async updateEventsCache(): Promise { - const fetchTimeStart = performance.now(); - - const [currentBlock, initialCache] = await Promise.all([ - this.providerService.getBlockNumber(), - this.getCachedEvents(), - ]); - - const updatedCachedEvents = { - headers: { ...initialCache.headers }, - data: [...initialCache.data], - }; - const firstNotCachedBlock = initialCache.headers.endBlock + 1; - const toBlock = currentBlock - DEPOSIT_EVENTS_CACHE_LAG_BLOCKS; - - for ( - let block = firstNotCachedBlock; - block <= toBlock; - block += DEPOSIT_EVENTS_STEP - ) { - const chunkStartBlock = block; - const chunkToBlock = Math.min(toBlock, block + DEPOSIT_EVENTS_STEP - 1); - - const chunkEventGroup = await this.fetchEventsFallOver( - chunkStartBlock, - chunkToBlock, - ); - - updatedCachedEvents.headers.endBlock = chunkEventGroup.endBlock; - updatedCachedEvents.data = updatedCachedEvents.data.concat( - chunkEventGroup.events, - ); - - this.logger.log('Historical events are fetched', { - toBlock, - startBlock: chunkStartBlock, - endBlock: chunkToBlock, - events: updatedCachedEvents.data.length, - }); - - await this.setCachedEvents(updatedCachedEvents); - } - - const totalEvents = updatedCachedEvents.data.length; - const newEvents = totalEvents - initialCache.data.length; - - const fetchTimeEnd = performance.now(); - const fetchTime = Math.ceil(fetchTimeEnd - fetchTimeStart) / 1000; - - // TODO: replace timer with metric - - this.logger.log('Deposit events cache is updated', { - newEvents, - totalEvents, - fetchTime, - }); - } - - /** - * Returns all deposited events based on cache and fresh data - */ - public async getAllDepositedEvents( - blockNumber: number, - blockHash: string, - ): Promise { - const endBlock = blockNumber; - const cachedEvents = await this.getCachedEvents(); - - const isCacheValid = this.validateCacheBlock(cachedEvents, blockNumber); - if (!isCacheValid) process.exit(1); - - const firstNotCachedBlock = cachedEvents.headers.endBlock + 1; - const freshEventGroup = await this.fetchEventsFallOver( - firstNotCachedBlock, - endBlock, - ); - const freshEvents = freshEventGroup.events; - const lastEvent = freshEvents[freshEvents.length - 1]; - const lastEventBlockHash = lastEvent?.blockHash; - - this.checkEventsBlockHash(freshEvents, blockNumber, blockHash); - - this.logger.debug?.('Fresh events are fetched', { - events: freshEvents.length, - startBlock: firstNotCachedBlock, - endBlock, - blockHash, - lastEventBlockHash, - }); - - const mergedEvents = cachedEvents.data.concat(freshEvents); - - return { - events: mergedEvents, - startBlock: cachedEvents.headers.startBlock, - endBlock, - }; - } - - /** - * Checks events block hash - * An additional check to avoid events processing in an alternate chain - */ - public checkEventsBlockHash( - events: DepositEvent[], - blockNumber: number, - blockHash: string, - ): void { - events.forEach((event) => { - if (event.blockNumber === blockNumber && event.blockHash !== blockHash) { - throw new Error( - 'Blockhash of the received events does not match the current blockhash', - ); - } - }); - } - - /** - * Returns a deposit root - */ - public async getDepositRoot(blockTag?: BlockTag): Promise { - const contract = await this.repositoryService.getCachedDepositContract(); - const depositRoot = await contract.get_deposit_root({ - blockTag: blockTag as any, - }); - - return depositRoot; - } - - /** - * Verifies a deposit signature - */ - public verifyDeposit(depositEvent: DepositEvent): boolean { - const { pubkey, wc, amount, signature } = depositEvent; - return this.blsService.verify({ pubkey, wc, amount, signature }); - } -} diff --git a/src/contracts/deposit/index.ts b/src/contracts/deposit/index.ts deleted file mode 100644 index 753548e5..00000000 --- a/src/contracts/deposit/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from './deposit.module'; -export * from './deposit.service'; -export * from './interfaces'; diff --git a/src/contracts/deposits-registry/crypto/containers.ts b/src/contracts/deposits-registry/crypto/containers.ts new file mode 100644 index 00000000..572bb858 --- /dev/null +++ b/src/contracts/deposits-registry/crypto/containers.ts @@ -0,0 +1 @@ +export { DepositData } from 'bls/bls.containers'; diff --git a/src/contracts/deposits-registry/crypto/index.ts b/src/contracts/deposits-registry/crypto/index.ts new file mode 100644 index 00000000..3400a40c --- /dev/null +++ b/src/contracts/deposits-registry/crypto/index.ts @@ -0,0 +1,3 @@ +export * from './containers'; +export * from './utils'; +export { toHexString } from '@chainsafe/ssz'; diff --git a/src/contracts/deposits-registry/crypto/utils.ts b/src/contracts/deposits-registry/crypto/utils.ts new file mode 100644 index 00000000..5c7505ed --- /dev/null +++ b/src/contracts/deposits-registry/crypto/utils.ts @@ -0,0 +1,16 @@ +import { fromHexString, toHexString } from '@chainsafe/ssz'; +import { UintNum64 } from 'bls/bls.constants'; +export { digest2Bytes32 } from '@chainsafe/as-sha256'; +export { fromHexString, toHexString }; + +export const parseLittleEndian64 = (str: string) => { + return UintNum64.deserialize(fromHexString(str)); +}; + +export const toLittleEndian64 = (value: number): string => { + return toHexString(UintNum64.serialize(value)); +}; + +export const toLittleEndian64BigInt = (value: bigint): string => { + return toHexString(UintNum64.serialize(Number(value))); +}; diff --git a/src/contracts/deposits-registry/deposits-registry.constants.ts b/src/contracts/deposits-registry/deposits-registry.constants.ts new file mode 100644 index 00000000..417a338f --- /dev/null +++ b/src/contracts/deposits-registry/deposits-registry.constants.ts @@ -0,0 +1,23 @@ +import { CHAINS } from '@lido-sdk/constants'; + +export const DEPLOYMENT_BLOCK_NETWORK: { + [key in CHAINS]?: number; +} = { + [CHAINS.Mainnet]: 11052984, + [CHAINS.Goerli]: 4367322, + [CHAINS.Holesky]: 0, +}; + +export const DEPOSIT_EVENTS_STEP = 10_000; + +export const DEPOSIT_CACHE_DEFAULT = Object.freeze({ + headers: { + startBlock: 0, + endBlock: 0, + }, + data: [], +}); + +export const DEPOSIT_REGISTRY_FINALIZED_TAG = Symbol.for( + 'DEPOSIT_REGISTRY_FINALIZED_TAG', +); diff --git a/src/contracts/deposits-registry/deposits-registry.module.ts b/src/contracts/deposits-registry/deposits-registry.module.ts new file mode 100644 index 00000000..f53689be --- /dev/null +++ b/src/contracts/deposits-registry/deposits-registry.module.ts @@ -0,0 +1,43 @@ +import { DynamicModule, Module } from '@nestjs/common'; +import { SecurityModule } from 'contracts/security'; +import { DepositsRegistryStoreModule } from './store'; +import { DepositRegistryService } from './deposits-registry.service'; +import { + DEPOSIT_CACHE_DEFAULT, + DEPOSIT_REGISTRY_FINALIZED_TAG, +} from './deposits-registry.constants'; +import { DepositsRegistryFetcherModule } from './fetcher'; +import { DepositRegistrySanityCheckerModule } from './sanity-checker'; + +@Module({}) +export class DepositsRegistryModule { + /** + * Registers the deposits module with a specific tag to handle block finality. + * The `finalizedTag` is primarily used to address issues with the Ganache handling of the 'finalized' tag, + * where it needs to be substituted with 'latest' for end-to-end tests. This tag is necessary only on a Ethereum node + * to avoid issues with blockchain reorganizations. + * In a production environment, this argument should either be empty or set to 'finalized'. + * + * @param {string} [finalizedTag='finalized'] - The tag to be used for identifying the status of blocks concerning finality. + * @returns {DynamicModule} - The dynamic module configuration for the Deposits Registry. + */ + static register(finalizedTag = 'finalized'): DynamicModule { + return { + module: DepositsRegistryModule, + imports: [ + SecurityModule, + DepositsRegistryFetcherModule, + DepositRegistrySanityCheckerModule, + DepositsRegistryStoreModule.register(DEPOSIT_CACHE_DEFAULT), + ], + providers: [ + DepositRegistryService, + { + provide: DEPOSIT_REGISTRY_FINALIZED_TAG, + useValue: finalizedTag, + }, + ], + exports: [DepositRegistryService], + }; + } +} diff --git a/src/contracts/deposits-registry/deposits-registry.service.ts b/src/contracts/deposits-registry/deposits-registry.service.ts new file mode 100644 index 00000000..66fa09c5 --- /dev/null +++ b/src/contracts/deposits-registry/deposits-registry.service.ts @@ -0,0 +1,255 @@ +import { Inject, Injectable, LoggerService } from '@nestjs/common'; +import { performance } from 'perf_hooks'; +import { WINSTON_MODULE_NEST_PROVIDER } from 'nest-winston'; +import { ProviderService } from 'provider'; +import { + DEPOSIT_EVENTS_STEP, + DEPOSIT_REGISTRY_FINALIZED_TAG, +} from './deposits-registry.constants'; +import { + VerifiedDepositEventsCache, + VerifiedDepositedEventGroup, + VerifiedDepositEvent, +} from './interfaces'; +import { RepositoryService } from 'contracts/repository'; +import { BlockTag } from 'provider'; +import { DepositsRegistryStoreService } from './store'; +import { DepositsRegistryFetcherService } from './fetcher/fetcher.service'; +import { DepositRegistrySanityCheckerService } from './sanity-checker/sanity-checker.service'; +import { toHexString } from './crypto'; + +@Injectable() +export class DepositRegistryService { + constructor( + @Inject(WINSTON_MODULE_NEST_PROVIDER) private logger: LoggerService, + private providerService: ProviderService, + private repositoryService: RepositoryService, + + private sanityChecker: DepositRegistrySanityCheckerService, + private fetcher: DepositsRegistryFetcherService, + private store: DepositsRegistryStoreService, + + @Inject(DEPOSIT_REGISTRY_FINALIZED_TAG) private finalizedTag: string, + ) {} + + public async handleNewBlock(): Promise { + await this.updateEventsCache(); + } + + public async initialize() { + await this.store.initialize(); + const cachedEvents = await this.store.getEventsCache(); + await this.sanityChecker.initialize(cachedEvents); + + await this.updateEventsCache(); + } + + /** + * Gets node operators data from cache + * @returns event group + */ + public async getCachedEvents(): Promise { + const { headers, ...rest } = await this.store.getEventsCache(); + const deploymentBlock = await this.fetcher.getDeploymentBlockByNetwork(); + + return { + headers: { + ...headers, + startBlock: Math.max(headers.startBlock, deploymentBlock), + endBlock: Math.max(headers.endBlock, deploymentBlock), + }, + ...rest, + }; + } + + /** + * Updates the cache deposited events + * The last N blocks are not stored, in order to avoid storing reorganized blocks + */ + public async updateEventsCache(): Promise { + const fetchTimeStart = performance.now(); + + const [finalizedBlock, initialCache] = await Promise.all([ + this.providerService.getBlock(this.finalizedTag), + this.getCachedEvents(), + ]); + + const { number: finalizedBlockNumber, hash: finalizedBlockHash } = + finalizedBlock; + const firstNotCachedBlock = initialCache.headers.endBlock + 1; + + const totalEventsCount = initialCache.data.length; + let newEventsCount = 0; + + // check that the cache is written to a block less than or equal to the current block + // otherwise we consider that the Ethereum node has started sending incorrect data + const isCacheValid = this.sanityChecker.verifyCacheBlock( + initialCache, + finalizedBlockNumber, + ); + + if (!isCacheValid) return; + + let lastIndexedEvent: VerifiedDepositEvent | undefined = undefined; + + for ( + let block = firstNotCachedBlock; + block <= finalizedBlockNumber; + block += DEPOSIT_EVENTS_STEP + ) { + const chunkStartBlock = block; + const chunkToBlock = Math.min( + finalizedBlockNumber, + block + DEPOSIT_EVENTS_STEP - 1, + ); + + const chunkEventGroup = await this.fetcher.fetchEventsFallOver( + chunkStartBlock, + chunkToBlock, + ); + + await this.sanityChecker.addEventGroupToIndex( + chunkStartBlock, + chunkToBlock, + chunkEventGroup.events, + ); + + // Even if the cache is not valid we can't help but write it down + // because the delay in updating the cache will eventually cause + // the getAllDepositedEvents method to take a very long time to process, as changes + // will be accumulated and not processed. + await this.store.insertEventsCacheBatch({ + headers: { + ...initialCache.headers, + endBlock: chunkEventGroup.endBlock, + }, + data: chunkEventGroup.events, + }); + + newEventsCount += chunkEventGroup.events.length; + + const lastEventFromGroup = + chunkEventGroup.events[chunkEventGroup.events.length - 1]; + + if (lastEventFromGroup) lastIndexedEvent = lastEventFromGroup; + + this.logger.log('Historical events are fetched', { + finalizedBlockNumber, + startBlock: chunkStartBlock, + endBlock: chunkToBlock, + }); + } + + const fetchTimeEnd = performance.now(); + const fetchTime = Math.ceil(fetchTimeEnd - fetchTimeStart) / 1000; + + const isRootValid = await this.sanityChecker.verifyUpdatedEvents( + finalizedBlockHash, + ); + + // Store the last event from the list of updated events separately + // Unfortunately, we cannot validate each event individually upon insertion + // because this would require an archival node + if (isRootValid && lastIndexedEvent) { + await this.store.insertLastValidEvent(lastIndexedEvent); + } + + if (!isRootValid) { + this.logger.error('Integrity check failed on block', { + finalizedBlock, + finalizedBlockHash, + }); + + // Delete invalid cache only after full synchronization due to: + // - we cannot check root at arbitrary times, only if the backlog is less than 120 blocks + await this.store.clearFromLastValidEvent(); + } + + this.logger.log('Deposit events cache is updated', { + newEventsCount, + totalEventsCount: totalEventsCount + newEventsCount, + fetchTime, + }); + } + + /** + * Returns all deposited events based on cache and fresh data + */ + public async getAllDepositedEvents( + blockNumber: number, + blockHash: string, + ): Promise { + const endBlock = blockNumber; + const cachedEvents = await this.getCachedEvents(); + + const isCacheValid = this.sanityChecker.verifyCacheBlock( + cachedEvents, + blockNumber, + ); + + if (!isCacheValid) { + throw new Error( + `Deposit events cache is newer than the current block ${blockNumber}`, + ); + } + + const firstNotCachedBlock = cachedEvents.headers.endBlock + 1; + const freshEventGroup = await this.fetcher.fetchEventsFallOver( + firstNotCachedBlock, + endBlock, + ); + const freshEvents = freshEventGroup.events; + const lastEvent = freshEvents[freshEvents.length - 1]; + const lastEventBlockHash = lastEvent?.blockHash; + + const isValid = await this.sanityChecker.verifyFreshEvents( + blockHash, + freshEvents, + ); + + if (!isValid) { + const { lastValidEvent } = cachedEvents; + this.logger.warn('Integrity check failed on block', { + currentBlockNumber: blockNumber, + currentBlockHash: blockHash, + lastValidBlockNumber: lastValidEvent?.blockNumber, + lastValidBlockHash: lastValidEvent?.blockHash, + lastValidEventIndex: lastValidEvent?.index, + lastValidEventDepositDataRoot: lastValidEvent?.depositDataRoot + ? toHexString(lastValidEvent?.depositDataRoot) + : '', + lastValidEventDepositCount: lastValidEvent?.depositCount, + }); + + throw new Error(`Integrity check failed on block ${blockNumber}`); + } + + this.logger.debug?.('Fresh deposit events are fetched', { + events: freshEvents.length, + startBlock: firstNotCachedBlock, + endBlock, + blockHash, + lastEventBlockHash, + }); + + const mergedEvents = cachedEvents.data.concat(freshEvents); + + return { + events: mergedEvents, + startBlock: cachedEvents.headers.startBlock, + endBlock, + }; + } + + /** + * Returns a deposit root + */ + public async getDepositRoot(blockTag?: BlockTag): Promise { + const contract = await this.repositoryService.getCachedDepositContract(); + const depositRoot = await contract.get_deposit_root({ + blockTag: blockTag as any, + }); + + return depositRoot; + } +} diff --git a/src/contracts/deposits-registry/fetcher/fetcher.module.ts b/src/contracts/deposits-registry/fetcher/fetcher.module.ts new file mode 100644 index 00000000..88fced62 --- /dev/null +++ b/src/contracts/deposits-registry/fetcher/fetcher.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { BlsModule } from 'bls'; +import { DepositsRegistryFetcherService } from './fetcher.service'; + +@Module({ + imports: [BlsModule], + providers: [DepositsRegistryFetcherService], + exports: [DepositsRegistryFetcherService], +}) +export class DepositsRegistryFetcherModule {} diff --git a/src/contracts/deposits-registry/fetcher/fetcher.service.ts b/src/contracts/deposits-registry/fetcher/fetcher.service.ts new file mode 100644 index 00000000..1c7ce144 --- /dev/null +++ b/src/contracts/deposits-registry/fetcher/fetcher.service.ts @@ -0,0 +1,124 @@ +import { Injectable } from '@nestjs/common'; +import { BlsService } from 'bls'; +import { RepositoryService } from 'contracts/repository'; +import { DepositEventEvent } from 'generated/DepositAbi'; + +import { ProviderService } from 'provider'; +import { parseLittleEndian64 } from '../crypto'; +import { DEPLOYMENT_BLOCK_NETWORK } from '../deposits-registry.constants'; +import { DepositEvent, VerifiedDepositEventGroup } from '../interfaces'; +import { DepositTree } from '../sanity-checker/integrity-checker/deposit-tree'; + +@Injectable() +export class DepositsRegistryFetcherService { + constructor( + private providerService: ProviderService, + private repositoryService: RepositoryService, + private blsService: BlsService, + ) {} + + /** + * Returns events in the block range and verify signature + * If the request failed, it tries to repeat it or split it into two + * @param startBlock - start of the range + * @param endBlock - end of the range + * @returns event group + */ + public async fetchEventsFallOver( + startBlock: number, + endBlock: number, + ): Promise { + return await this.providerService.fetchEventsFallOver( + startBlock, + endBlock, + this.fetchEvents.bind(this), + ); + } + + /** + * Returns events in the block range and verify signature + * @param startBlock - start of the range + * @param endBlock - end of the range + * @returns event group + */ + public async fetchEvents( + startBlock: number, + endBlock: number, + ): Promise { + const contract = await this.repositoryService.getCachedDepositContract(); + const filter = contract.filters.DepositEvent(); + const rawEvents = await contract.queryFilter(filter, startBlock, endBlock); + const events = rawEvents.map((rawEvent) => { + const formatted = this.formatEvent(rawEvent); + const valid = this.verifyDeposit(formatted); + return { valid, ...formatted }; + }); + + return { events, startBlock, endBlock }; + } + + /** + * Returns only required information about the event, + * to reduce the size of the information stored in the cache + */ + public formatEvent(rawEvent: DepositEventEvent): DepositEvent { + const { + args, + transactionHash: tx, + blockNumber, + blockHash, + logIndex, + } = rawEvent; + const { + withdrawal_credentials: wc, + pubkey, + amount, + signature, + index, + ...rest + } = args; + + const depositCount = rest['4']; + + const depositDataRoot = DepositTree.formDepositNode({ + pubkey, + wc, + signature, + amount, + }); + + return { + pubkey, + wc, + amount, + signature, + tx, + blockNumber, + blockHash, + logIndex, + index, + depositCount: parseLittleEndian64(depositCount), + depositDataRoot, + }; + } + + /** + * Verifies a deposit signature + */ + public verifyDeposit(depositEvent: DepositEvent): boolean { + const { pubkey, wc, amount, signature } = depositEvent; + return this.blsService.verify({ pubkey, wc, amount, signature }); + } + + /** + * Returns a block number when the deposited contract was deployed + * @returns block number + */ + public async getDeploymentBlockByNetwork(): Promise { + const chainId = await this.providerService.getChainId(); + const address = DEPLOYMENT_BLOCK_NETWORK[chainId]; + if (address == null) throw new Error(`Chain ${chainId} is not supported`); + + return address; + } +} diff --git a/src/contracts/deposits-registry/fetcher/index.ts b/src/contracts/deposits-registry/fetcher/index.ts new file mode 100644 index 00000000..128136bc --- /dev/null +++ b/src/contracts/deposits-registry/fetcher/index.ts @@ -0,0 +1,2 @@ +export * from './fetcher.module'; +export * from './fetcher.service'; diff --git a/src/contracts/deposits-registry/index.ts b/src/contracts/deposits-registry/index.ts new file mode 100644 index 00000000..e573762e --- /dev/null +++ b/src/contracts/deposits-registry/index.ts @@ -0,0 +1,3 @@ +export * from './deposits-registry.module'; +export * from './deposits-registry.service'; +export * from './interfaces'; diff --git a/src/contracts/deposit/interfaces/cache.interface.ts b/src/contracts/deposits-registry/interfaces/cache.interface.ts similarity index 87% rename from src/contracts/deposit/interfaces/cache.interface.ts rename to src/contracts/deposits-registry/interfaces/cache.interface.ts index 102daefb..a421da53 100644 --- a/src/contracts/deposit/interfaces/cache.interface.ts +++ b/src/contracts/deposits-registry/interfaces/cache.interface.ts @@ -3,10 +3,10 @@ import { VerifiedDepositEvent } from './event.interface'; export interface VerifiedDepositEventsCacheHeaders { startBlock: number; endBlock: number; - version: string; } export interface VerifiedDepositEventsCache { headers: VerifiedDepositEventsCacheHeaders; data: VerifiedDepositEvent[]; + lastValidEvent?: VerifiedDepositEvent; } diff --git a/src/contracts/deposits-registry/interfaces/deposit-tree.interface.ts b/src/contracts/deposits-registry/interfaces/deposit-tree.interface.ts new file mode 100644 index 00000000..7d0b082a --- /dev/null +++ b/src/contracts/deposits-registry/interfaces/deposit-tree.interface.ts @@ -0,0 +1,6 @@ +export interface NodeData { + pubkey: string; + wc: string; + amount: string; + signature: string; +} diff --git a/src/contracts/deposit/interfaces/event.interface.ts b/src/contracts/deposits-registry/interfaces/event.interface.ts similarity index 75% rename from src/contracts/deposit/interfaces/event.interface.ts rename to src/contracts/deposits-registry/interfaces/event.interface.ts index 55387f60..470d453f 100644 --- a/src/contracts/deposit/interfaces/event.interface.ts +++ b/src/contracts/deposits-registry/interfaces/event.interface.ts @@ -7,6 +7,9 @@ export interface DepositEvent { blockNumber: number; blockHash: string; logIndex: number; + index: string; + depositCount: number; + depositDataRoot: Uint8Array; } export interface VerifiedDepositEvent extends DepositEvent { @@ -22,3 +25,6 @@ export interface DepositEventGroup { export interface VerifiedDepositEventGroup extends DepositEventGroup { events: VerifiedDepositEvent[]; } + +export interface VerifiedDepositedEventGroup + extends VerifiedDepositEventGroup {} diff --git a/src/contracts/deposit/interfaces/index.ts b/src/contracts/deposits-registry/interfaces/index.ts similarity index 62% rename from src/contracts/deposit/interfaces/index.ts rename to src/contracts/deposits-registry/interfaces/index.ts index 5927a2ea..8edb9bb4 100644 --- a/src/contracts/deposit/interfaces/index.ts +++ b/src/contracts/deposits-registry/interfaces/index.ts @@ -1,2 +1,3 @@ export * from './cache.interface'; export * from './event.interface'; +export * from './deposit-tree.interface'; diff --git a/src/contracts/deposits-registry/sanity-checker/blockchain-checker/blockchain-checker.module.ts b/src/contracts/deposits-registry/sanity-checker/blockchain-checker/blockchain-checker.module.ts new file mode 100644 index 00000000..5bc33ab5 --- /dev/null +++ b/src/contracts/deposits-registry/sanity-checker/blockchain-checker/blockchain-checker.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { BlockchainCheckerService } from './blockchain-checker.service'; + +@Module({ + providers: [BlockchainCheckerService], + exports: [BlockchainCheckerService], +}) +export class BlockchainCheckerModule {} diff --git a/src/contracts/deposits-registry/sanity-checker/blockchain-checker/blockchain-checker.service.ts b/src/contracts/deposits-registry/sanity-checker/blockchain-checker/blockchain-checker.service.ts new file mode 100644 index 00000000..5f2d4706 --- /dev/null +++ b/src/contracts/deposits-registry/sanity-checker/blockchain-checker/blockchain-checker.service.ts @@ -0,0 +1,42 @@ +import { Inject, Injectable, LoggerService } from '@nestjs/common'; +import { WINSTON_MODULE_NEST_PROVIDER } from 'nest-winston'; +import { DepositEvent, VerifiedDepositEventsCache } from '../../interfaces'; + +@Injectable() +export class BlockchainCheckerService { + constructor( + @Inject(WINSTON_MODULE_NEST_PROVIDER) private logger: LoggerService, + ) {} + + /** + * Validates block number in the cache + * @param cachedEvents - cached events + * @param currentBlock - current block number + * @returns true if cached app version is the same + */ + public validateCacheBlock( + cachedEvents: VerifiedDepositEventsCache, + currentBlock: number, + ): boolean { + const isCacheValid = currentBlock >= cachedEvents.headers.endBlock; + + return isCacheValid; + } + + /** + * Checks events block hash + * An additional check to avoid events processing in an alternate chain + */ + public findReorganizedEvent( + events: DepositEvent[], + blockNumber: number, + blockHash: string, + ): DepositEvent | null { + return ( + events.find( + (event) => + event.blockNumber === blockNumber && event.blockHash !== blockHash, + ) || null + ); + } +} diff --git a/src/contracts/deposits-registry/sanity-checker/blockchain-checker/index.ts b/src/contracts/deposits-registry/sanity-checker/blockchain-checker/index.ts new file mode 100644 index 00000000..30d286a5 --- /dev/null +++ b/src/contracts/deposits-registry/sanity-checker/blockchain-checker/index.ts @@ -0,0 +1,2 @@ +export * from './blockchain-checker.module'; +export * from './blockchain-checker.service'; diff --git a/src/contracts/deposits-registry/sanity-checker/index.ts b/src/contracts/deposits-registry/sanity-checker/index.ts new file mode 100644 index 00000000..3af948e6 --- /dev/null +++ b/src/contracts/deposits-registry/sanity-checker/index.ts @@ -0,0 +1,4 @@ +export * from './sanity-checker.module'; +export * from './sanity-checker.service'; + +export * from './integrity-checker'; diff --git a/src/contracts/deposits-registry/sanity-checker/integrity-checker/constants.ts b/src/contracts/deposits-registry/sanity-checker/integrity-checker/constants.ts new file mode 100644 index 00000000..7c9c3e1d --- /dev/null +++ b/src/contracts/deposits-registry/sanity-checker/integrity-checker/constants.ts @@ -0,0 +1,2 @@ +export const DEPOSIT_TREE_STEP_SYNC = 200_000; +export class DepositCacheIntegrityError extends Error {} diff --git a/src/contracts/deposits-registry/sanity-checker/integrity-checker/deposit-tree/deposit-tree.fixture.ts b/src/contracts/deposits-registry/sanity-checker/integrity-checker/deposit-tree/deposit-tree.fixture.ts new file mode 100644 index 00000000..32ebec88 --- /dev/null +++ b/src/contracts/deposits-registry/sanity-checker/integrity-checker/deposit-tree/deposit-tree.fixture.ts @@ -0,0 +1,1160 @@ +export const dataTransformFixtures = [ + { + valid: true, + pubkey: + '0x933ad9491b62059dd065b560d256d8957a8c402cc6e8d8ee7290ae11e8f7329267a8811c397529dac52ae1342ba58c95', + wc: '0x00f50428677c60f997aadeab24aabf7fceaef491c96a52b463ae91f95611cf71', + amount: '0x00ca9a3b00000000', + signature: + '0xa29d01cc8c6296a8150e515b5995390ef841dc18948aa3e79be6d7c1851b4cbb5d6ff49fa70b9c782399506a22a85193151b9b691245cebafd2063012443c1324b6c36debaedefb7b2d71b0503ffdc00150aaffd42e63358238ec888901738b8', + tx: '0x7085c586686d666e8bb6e9477a0f0b09565b2060a11f1c4209d3a52295033832', + blockNumber: 11185311, + blockHash: + '0x1ecb9dd23676c9201af1e8026e7d83a1f979b8abd381064fba0b593fcff7b235', + logIndex: 131, + index: '0x0000000000000000', + depositCount: 0, + depositDataRoot: + '0xaa4a8d0b7d9077248630f1a4701ae9764e42271d7f22b7838778411857fd349e', + }, + { + valid: true, + pubkey: + '0xa1d1ad0714035353258038e964ae9675dc0252ee22cea896825c01458e1807bfad2f9969338798548d9858a571f7425c', + wc: '0x0092c20062cee70389f1cb4fa566a2be5e2319ff43965db26dbaa3ce90b9df99', + amount: '0x00ca9a3b00000000', + signature: + '0x985f365b3459176da437560337cc074d153663f65e3c6bab28197e34cd7f926fa940176ba43484fb5297f679bc869f5d10ee62f64a119d756182005fbb28046c0541f627b430cabfeb3599ebaa1b8efd08de562ec03a8d78c2f9e1b6f01d8aba', + tx: '0xa90ed27521c07e66d52db6ee47d729d118229925303706b35e4d36d8e830ba7a', + blockNumber: 11191448, + blockHash: + '0xa51cbe797cac4ef0297862576e64444a90d3bec332949c352f253405aa129f1e', + logIndex: 126, + index: '0x0100000000000000', + depositCount: 1, + depositDataRoot: + '0x76fffc948646005fce32e27555238dfe801c9e7eea28ff40dbe2afe8f83cf0c6', + }, + { + valid: true, + pubkey: + '0xb2ff4716ed345b05dd1dfc6a5a9fa70856d8c75dcc9e881dd2f766d5f891326f0d10e96f3a444ce6c912b69c22c6754d', + wc: '0x00d66cf353931500a54cbd0bc59cbaac6690cb0932f42dc8afeddc88feeaad6f', + amount: '0x00ca9a3b00000000', + signature: + '0xb868229df29f2b48409c5aac70594c9882be4a7b1e60ba1a9c985f87f4a9cad18bbf74a78734cd9b4911b57a23dc9d4118b70da8e2ae1faaab91c04076d66ead359a0be26845410d18a42910bdf0b9ae4b4bfcc90f8bb528f1a92c91a1ad6547', + tx: '0x14f1d17ef6051109bf4b9e5dd9b494f12580a508a8d412af6d5e857f8d6a0f0b', + blockNumber: 11191495, + blockHash: + '0x37c7097adfd4e30c93b8840d32c215e189d95200a9ef1e3445b926efb48ae99f', + logIndex: 76, + index: '0x0200000000000000', + depositCount: 2, + depositDataRoot: + '0x3e74b357fbf0bf36bed50de7ee3a3caaa11006e7ea5ce644d16de8d666b2c7a9', + }, + { + valid: true, + pubkey: + '0x8e323fd501233cd4d1b9d63d74076a38de50f2f584b001a5ac2412e4e46adb26d2fb2a6041e7e8c57cd4df0916729219', + wc: '0x00d6b91fbbce0146739afb0f541d6c21e8c41e92b97874828f402597bf530ce4', + amount: '0x00ca9a3b00000000', + signature: + '0xb9a4bccc6fc91192b603dd7ee1c99eabee415bdde9d96146c71b2ce4ce9e292ded93fa150850242c327e6ce2f50cb75b134afe5bb7ecca9c328e6f2dc1da931389a2d15d435eaed1222991d22aeecc026b2390afa5f941d2ed5277b3d3fbc350', + tx: '0x6e1e30cb4b6e0029fc4762cf74b264ce66a9d078a0f732583a71544fdadddf72', + blockNumber: 11191501, + blockHash: + '0xa6d7b9926b794b8de798720f058badbf436ef52907b19ebd090172b75d24ed20', + logIndex: 328, + index: '0x0300000000000000', + depositCount: 3, + depositDataRoot: + '0x790284c0a36abd53ec0ce9284f4ad4af72c891a38456e73e15685bb99dfc09e9', + }, +]; + +export const depositDataRootsFixture10k = { + events: [ + '0xaa4a8d0b7d9077248630f1a4701ae9764e42271d7f22b7838778411857fd349e', + '0x76fffc948646005fce32e27555238dfe801c9e7eea28ff40dbe2afe8f83cf0c6', + '0x3e74b357fbf0bf36bed50de7ee3a3caaa11006e7ea5ce644d16de8d666b2c7a9', + '0x790284c0a36abd53ec0ce9284f4ad4af72c891a38456e73e15685bb99dfc09e9', + '0x0fd0bdf2cee28566c006a622cf5a1763de51233faa3a4bf7d95fed76c9a5fd05', + '0xdbd986dc85ceb382708cf90a3500f500f0a393c5ece76963ac3ed72eccd2c301', + '0x0cf210b6d6dcf3581ab34266ae25e2b0e9b20c50581b004dea1080dd0939a97d', + '0x227f7f4f8a112532572bc53ae300d06e7aed9b3ea531a36221629af6783cb1f8', + '0xf963d928e334ef682fa5cf0e651988ea32fddada361f09d04e951b952038dc74', + '0xaacbc03b795b8aec28b10500d3410dd7c5aa4031b083b3d20388e52fdacc7f6e', + '0x01a9de4c3f8775c14384c405745bc6bb34ef87cb9bf8275201f19a28b3f99aa2', + '0x64dc8d9e3cec6a41a42ea105bb38b59d7ca49b14d6c7e678cba309fbfb0e7af4', + '0xbbabd378476db7f6f0520a943b2e6186c305610601ca59fa11212eacdc2ef317', + '0x685ed3e1f3d46604214a101cd5825d55e0df8250af7442403f4ebcfc15be6e99', + '0x6faa51b25ff2f0a1297feec95e1d95e6041c3b2270b55fd46d52e09557bac5fc', + '0x6292c84fb0b7f5bd88fbe9ba7ec38bf589ae2811a8503a6c60cd76f52b63586f', + '0xbb88366a75271d570e88f940fc7de86f119575b755fea171867b38c9e9b4e1df', + '0xaa28011f9bbab5d924e249e9516bdbdc48aa80110e9f59b09b74373c91866002', + '0x4d1ce04b3aebac268eb9c329c8c96e3da8ba133e73dac32f6e14423e1df4f9fe', + '0x291684946eb577357efd7d119f05addc928a780961f3cab307dceb9ed346f26f', + '0x5c094a65e756690096e53f90c262fd88b2ed3bd7b2a1ec943df138d9b6794b7a', + '0xebe8ec1d0329d2bb285efcc157df907d48d0c56eadcd7e2be7e5a71d6d4f59a8', + '0x8053b81185dd809b973c71f54df69c6e82eef774e43f8b12c9a113a4374118bb', + '0xa9ed7ddf21134290767102defa883b87861c891f5d4a8d2d78632a791116258f', + '0x15afffabc64e3eb7b6833494bd38808a2c3600f8c40ecbe4500d1602f6330552', + '0x8f2e40a9507594979456ab30d150905cd85c585e83bfc57ad2b7d301dccfdec4', + '0x8abe605ff94d099346225b998a4986271d0445efd2329ced0db45d0ca3228fcc', + '0x31d3bf344ecfb44fc014abd2f86340f47263fc59bc11b20783cd1e4456a0f177', + '0x27a4028494edee3471bf6d6681db3558f468d347b1be12899e0416f011f1caaa', + '0x240389c323a9830f1c4354d32e0b5a319edfcd8f786d8a709e6c605206b64d27', + '0x2202f5507ec3a809d26ad969b4bae1a34d21574de375347c1749b926e223b3a0', + '0x3f51ee386cc183b1e43da81c5aee14e817496f3cca30bac5a896e58578b71f77', + '0x773bc477c150bcd733d4e8413d682e0d411fa0f68605cf8b86108efc9040b8f6', + '0xa9d57ad371b3889f5f50c241f5e8de1d2f0f053dbd3011934266670989e79133', + '0x5c2ee552a2907fcef809b9b34045644bf635d72ac6f579c8ea8f17e2545e0447', + '0x8b212b94494bdd11fee558807ea2365048d3a2d965e5801f5f6bf29ed385f674', + '0xb468dd1ee4bac68b8dd6980fb806fd6fc158acac1b267dda9383df724776e32d', + '0x1f50cda24d72ee068b4683862919f1b30f15fce6e3bc31f0af8651b9cd59f9a2', + '0x9bc78e7b0c29ec858370dcd9604e7c4a118f8647770f0be8d92ac0a044897b63', + '0x256287af00e8ab72e0947bcea414427154f9d72062d1079c7c6fbb1a8be97af1', + '0xdc53ebcd02d9a355be29b66821abc916e720883e1024b5adf62b4219d5480b44', + '0x7a6e5ae6f425fb538ae763a6900285f6433a476d2ace3d4a50dda1b5be55041a', + '0x0461628cdc499ae9d52b45fa6df2c64fcaf3c70a13142b66e6e9fbfb3374746e', + '0xc148821c28b65063d3863505de5efd14dbae01200f4dcd04e732f579e657d9df', + '0xcdbe7bff81fb547017918e5634fe40d3b35ee91b32831dab8ef8ac99d57bdbf0', + '0xd8d4dd4db26e35cd1a00c899c7286bc3a883427a475aac6365139bca33e04ffa', + '0x14c289d0754ebccc4642bbd494fee4624323febb7573b4589ba300d45616c0d5', + '0xbfd2bd584723243047c5aaabf613b0a52bcbab481983d600a57addde642227a9', + '0xf98dce5a5e98ad51843f922b26b685de87f9041277a4c6a8a12756bac2ae1e80', + '0x3ed5bb4a73e8485df93d3588bf91a5408bd3ba635167656d360077d12f861e31', + '0xabdc41954cc9c783d64acdcf610322ccd29cb2288d1544dd31b9a6e72c370deb', + '0x844b44d5a0738358a3938646881b563a8068aaeb7e10c97ae7a1251147039a9a', + '0x9b9cf0518542594e9d5fab0e65494bf6e62186c3a94da4e19b89b8a4d5f8eaa1', + '0x8a7192e917fdb883bc7bb3c20077c2e211e18c9ef27cfa8016763a90b0fc9056', + '0x68656891f8d5517f59f8b520819440a2e4a6519c3168a18218307d60fd2d1d31', + '0xf4bc40e60d41266effe10f3c128e7ec2745cc18d190be8ec0cadb1ab461bb6f3', + '0xaa2848f1960fd195f924cd96dbe0f490dc7d184010d7dc970e3f54c8291da5dd', + '0x5c351787db91f668b3c70801cdb8d3087a4469ad88235f02fa74ef880341028f', + '0xda17919d50092ae60e1e2df4a5f5537ed3d963be358ef6b56af9f1080f588ad5', + '0xb05465f51be5137b84e1d04d2f2ef9bf7dbd82542c70bc562fcbbdfdd362d96a', + '0x48b36388ae89979c433535573f3f9f347067cc159338d33434a2f4ca65c74bb1', + '0xf4129e092304d083ca7f31296aa296768f6dc51d85ed5f96278fb5be48ef2296', + '0xe622aefd42e8e3c70858da6eb29500bd1135f5670874140f308f435b454c015c', + '0x3d725b0ee40b0563332a6e09baa49ce777c90abe5404f77ada2429620f644e16', + '0xb8f3b35acd787284e4214eb1284517f197266733c0c5ea3baf6cf4aeb04be8a9', + '0x946424faab4e5c047ae9870236b641a73c8e1f42346d59e38cbb77f9f88dd6eb', + '0xac90de872f1f092a8258c35a2440c776c840e4ac0d8eed6a39920fe19a48d60d', + '0x9970cc53fef13e734266a0a00d43b8349addc442ef08b122c54e85c6efaec64d', + '0xbd94fea0a4c628a72474210bd0c01a89c2c9470a429e491190ecedbaeff03ea6', + '0xbe74b4ba0f91af15365258bf2384b2c1ec897c80aee856d40fb97b1057a3f9b0', + '0x53978685ca0b3a5123326756578ddd00b933cac87dc515b25994bbd0b6b11af2', + '0x5f9a348196532d8a0486f7a082071498016d2d1c90c6783fca22020265a3ceca', + '0x7ad296df98be10af7deeb1567847bea529f879829a7029d5d84084cb2ef06395', + '0xe6ac2082359d3cad3217b80b9d004236b64b78e0a9d6fbcd204b56dba360c003', + '0x075f089a2d8069c0062275ab8b20093dcb7a63577f0d367dfe3e5093126336e5', + '0x79cfc5cc08541d85ebe21cfa8d367aff7b4da0bbec3b48fb325be60f7c0cd4a3', + '0x7294fb60e3bcc9e719fd4e7822bb933702d65c53dfde1537e92e28d285bbdc25', + '0xb3e82cfa34dc502d28d412a59ea7e42bb96c2a24c1df16a414ebded848f0c5cc', + '0xc1fc69a88517e0585887f234b417c775c526da2af7956cdf9c81285e94d8797d', + '0xf9e7ddb3b78d0b71f829c3f56f99f6ab45232bce37b0f4a8350c58b55bf85c10', + '0xb6e1cca9693ac9488c6914b8d698fd490958c687fe6d047faa0e5fd76440da1c', + '0x8c880663bc3f919b0504a6a151d04e78f607532fafadfbcfc2388a6aab3fb717', + '0x22ae224e105d7a62afb5c3203975acd9d6e94aab2d1f0b5bbefe7ee6d57453d5', + '0xc938e0775050b42a324ec832cb945cb1a9782ef58295763533cf8fa89c0d8ab0', + '0x580abe3ff2494d245ff23c27483651b9cfdc51c011347d6dc34abfdff52ce03c', + '0x5242278356feddc272521712273f8083409a915cb40721e2690e14759a3ad20a', + '0x12cceef0598e018b4192f160c2f2175b67adeeffbfdf52a21fa646c6c0c9d24a', + '0xa18a1d47c2229c441ab2ff24d78a86b0d966ad8a2e92cbe3544773f032a295a2', + '0x5e97cd8b6401d254e100199bc523c41c2f313a6980cf33c3b86d195c71e68054', + '0xef3cb1ec905ff3c537f34be3008b3e98100c938bf9fe3f79a4854cbd90b2ff03', + '0x169675c35ffbacf44fd3c994a9d18452914cd8fdee7a7910386dc3505b9f1c5a', + '0x4c525e191c3a67aa3ece110b5571d9d6900c277867026379f9cdf8ba6ebe1024', + '0x806040ae46bc27b9f48ab2cd660e0f7c44a905d3f05a3b9f002c0766c354f876', + '0x1ed2be894a6bc85a74682c0c4045a15fa9b3e1671c30e67140de2fbb9fec4151', + '0x01de4f2f53213f326d7c774a6b3a26f3a5efcddb515053a530286632f7393806', + '0x60f5591e1459df0ac8b2496e92dd69bc3ef8a39ee00b49ccaf0fc7d725da9339', + '0x944be7472306ef5ac4f3ad63387ed1f56f586882fd1394ca88f8057b648d305e', + '0xf4aca036b84864b07d27d7445105613b616e321d815aa25a0083db7c7446881a', + '0xb4caeb6fcf9ac5694e8757353b5aa80f2e9b7c42968e7623f2eca1f41f97a343', + '0x082140ea627495e3a842fc7a047695ec237a332f5c937720ab852f01c33f5ff7', + '0xc04efa5c6944e4f27370a8afce7b56a61309495413139bc6e128c68b961386b4', + '0x7ee14aa819f0819a6ef4d22a57bd59c2322662c1f794fa125aea9b9256d48d9e', + '0x6101eb5459ee526c2195322882ea69b74685daa7c7f9d66d85489daf0303079f', + '0xd2ca717681d06630fc60b68ae31eb416e1afb5a70e1d45713247804bec96b198', + '0x473e6abec3c5945505958388bfc8202ed5e1dd9e52f0ab6489d595583c7ecca2', + '0x953ebdb1b2dbc473562b8739c203c49d692531bdc8fe57aba3e0b4cb42c2844e', + '0xb89f3d6cce3046b103b19cd5e023473391e76e61fbafc44b13a32de8c037f69b', + '0x50aebcd7137f166fa59a25bb0ba68fdb08a8c64d858b1106a6f67465dca8b49a', + '0xbba6adce21ae1d20d22e0dbc52127834756c571f0feb23748709f3336e299a04', + '0x28ea7221dfa4fc014023912b3a41db2d3a44be136872bb13eea86b28f4965605', + '0x49250acba166e802e2a4d2d11d1d7222d653f93d664e70d6f6bd6bcc88fff8c8', + '0x4a9f15b0b3c2eb034f4decf41ec6c64b6a4881fe8a637cbcb82e800e9dc32278', + '0x4160268dc31fb5eb3f522e6928c99f2ad1c9f920c5649001ca294aff3a109f3f', + '0xd55140cb0c8bed4ffb4170390bf2b3a1198b0a8cf69148d0c8bed0c3a5a440f5', + '0x06500da7b85c17a7d168fae170dbb70d962fbf587fc6cf79c160db453eed4afb', + '0x1ccd8fb070400f4ed2ccfb1876fc74d924eaf8738394d67cc0cce53ce5937b6c', + '0x55bd2acb82617f032145541ddcde126e5d86507bdf5e3aaef74fe908504dc50a', + '0x9312533bd8b9612bc0cfe113e669d187a8c0c2116ca4324da1524edbbe0b014e', + '0x854c4ae0a25dea79d1a3beef2f2b43ec9f88e664403a925b13f01a68cd725bc8', + '0x433b187b7e8cf4065ea15441c4bad8f886a23cbe553394fc69edca119d37c18b', + '0xd563637c6e5101a657915a418bc4825638132073e18d6fb79757a2d0e9912ced', + '0x634685fc6263a04971c12ca95eeb8ab718c19a3f2f4afc9afe69c7fa320ffb6a', + '0x71f1cd6ea922e3167b159f23f3d649f67697ec2d4cfce63bb08893147c95f0f8', + '0x0914090e5e2206661e66985f53901895c67e923c94a438c906e863b0ceb09e78', + '0x3caaf484ef19cbeefabcacc5478aa65ec4416e3c8f899d190333e45c0b1ea566', + '0xb3165c968851b62f318d1285571b0409e864be256f4817cae88672eeb31d32e1', + '0x41cfe1c698c9043be596a147aa418081b062401cc73bccaa0cfa933714c0f4b8', + '0xf05cbc59d191faeff9ed3d3f4cd690567f20792246f61059944c63770ebdc5d7', + '0x4c3b75eee105866f388ddc157535fd95be88031e1cb6ae39fce3f52b33158550', + '0x3e8da0723685545a8e2904865125c326eff75c3f41bb3c6ab2f799d51fd462e5', + '0xb27fe2aa2cf4563038a3cd41e43ad89797e9e6876e0b38b3a04023c588bb4683', + '0x14c6de0a761ae1d1d94007b4f1926a54f5c2afe5262abc7116d7602b9a39baf7', + '0xa025651ef54b13c36c12199b9bb770808fcbe2fbc06027dcb417864ea675f676', + '0x03aa2160fb1089fb3512cc62019ca94bd1ea909d3ebd2ab139a75618d2ef1031', + '0x1858cbcaa0bac301639154458a05d6e9e09179c82fe7c4e4d44294c734ba827b', + '0x17664f7bf31cfc1cbcc45e93a56e7506121005ab96c67ae4b2791386bb4e2e80', + '0x38d45c31af6fa5a8c9311ff1f5bafa5ffc3d83aee06e6984e4f4712592cbb962', + '0x8d4aa80fc5bbb3bfd4523199ee42bf9cc324185c5c39f25d58c4f3830ce2bec3', + '0x7e1cac47a243baed0360966304e5ab0433f1bd31baeea0c9f8ff3b0a194cf936', + '0x748763344f58c131c83f33f9f3ab67197ce538ea580b964c0891d3e93febb0b6', + '0x5d87cbd61f541df420f3d7182988ea3985392d6248145f75ed8c39c94bf78f8c', + '0xe42c765df85cf5a762766023d4f03f1957ba8c03ac8d0ec9ebf7d941e461d637', + '0xbe85b7eab113fd6c9dc5a79dc54919f320bb16a6c15ccad1bf4188b3a92605f8', + '0x5a0685fc3047a67e1001d9ca2b53a44651893318ba38c48a3537859aed33b7da', + '0x684984da2fa594aeabebf162dd378873d2c18fe7728aaaa0ba2bb8db5aa3d789', + '0xd1a8a85b78da62fcb74190ebe93e353b7952892adcb5f8eb3429e62636d4929c', + '0x557415000cc04400d649a4ed29d15277a7bbf8ac25921481ee54eb2fb9e7ed27', + '0x5cf33d6c47bcff8db958b614b06f05cf7fc03b26d3e80bf8a57d6a7669f198ea', + '0x837523a5308087bfdd8e902a70c65d21a7c369f18ffaea7ec4f11110ad6f8927', + '0x95d83e60206935310e0f544ba18a62af694800d978937ea53de10fa56844b7e9', + '0xc1b83eddf9d20e5506ac2740cd09fc5454fac62237a9840fe36e730af64ef4d5', + '0xb656d797a9778964ebb0ce04f4aee42f5cfb92a8c8180a2f224f2b8eae2a11e8', + '0x6a1032b0252df99a7d4fba20dcce566da57d9b8b2c6889b7544781bbb6b0dfbe', + '0x7832e17600e4410a5944a6b98e321378556487f7479c7a61d76745a73c128929', + '0x617296d4d696a5dcdb14316fccb5391a52a8ed132e686026cf1ef8afcc4dff23', + '0x134730ed4259cc79c2538c276085cd7b829bb0d578ccbc922b26ca944014ad67', + '0xfafe65deead69e2173d3eb1ab2384ff1cd3a814f5bb1f15c1082f4ae519d0f06', + '0x5ded236f4bed3f24e1b8f325f94ef363128519aa7d0248a250655bad8b52fc79', + '0x38f086a7cacf753a6d527cffe6c989fde983f43ce8ea37268dd70580f35d925a', + '0x6fffefc15f8e6055b510c366a9f9d2f080365eb11f0b6037cebb2b3304d30c87', + '0x90a8bf8e435b0290cc8ce073de91da37f7986d61d5f26c4af6c3e33782075a4b', + '0x132bcfef2e758dfbbfecf90cbd3ccd1eda75f0b409d80722970592ab22d5274c', + '0xb738d02ee2c884dcdbf76dda6f30805cefd86169157d34c3705c2ee6c1fe75cc', + '0xd477bd9ce819f3b98a22de0039e82bc95f660c7b50e4434f9ffef0181f48f829', + '0xaa55fcd3865134de79d0be329f17d4b9f50c1f208bdbd88f4a2c0009f39c28e1', + '0x9288ba8bb044603e8872c7ebd997fb15e07f7959c9c36f4d849d881b7f43fa53', + '0x6820043d6bbe9930568ad96d889b6ab5f2106fa210179717e25e9279766c7cda', + '0x5ed2932f5e1c7949218f27be45f17a745e1c1116e2b335c09bfc98f8bb79b97b', + '0x212c20f2421e5028316e736666271db96c85fdd2d908885999266aef098b6d03', + '0x6be230f8b0120d371cdd1b207505a1e2a947fc82da4cac7f6e0d577e58a2341e', + '0xdadc3db8486becc8327c1e80d77039a2d609237cdcf1e19bb25e2a7b23a9d0fc', + '0xe90d991aa875f6773fbb12337616303f972236aa71b7611885093bd22b53773e', + '0x77bf6c89399a8dd1612098a3b4b7823e61211f241bb3b6415fbd127abd20d4d9', + '0x2200b9cc7dfa84050dd2f14a7e8d52689790d8cb1bdc7f072b66b992aa74ca8b', + '0x2c371ccbbabdfb895e39a07257d73fa021fcdd94caadfa1d294c9a718b629f48', + '0x3cc465a2184f91c2014d07d99a00827709f996779d8d0d155be54e009fbf4d81', + '0x0455b8d543cd783b66be8ef7c9154b15a9df91d604ccd75e311acb7cd8aae713', + '0x105cedd0ff44c7c8f01c84c1dfe747f42043c7487e766a6b8f864c3d6fb8e056', + '0x5b957726b8b0e2824240925b28f1de33b0d26f6c3449378e0253bf2af12539b9', + '0x7a3b4851fab70861611ef909e00e07d3566789eb1215a0087b5cfaf9f054b0aa', + '0x39e6cd9b9efd3b84afab9b5d57a8f6b20de2213467ac007025bf49c3df1f58e9', + '0x257f57f2fee42172d1fb1593eecba42eac7c67f4162c7df35c72f623072133e1', + '0xa4e82c6255985d6adb6e5a7fa133b6b42a3d1216c2a942893e4ac03b9afe825d', + '0xd374f2538241162221855d3ff7a725562f36121f6ee916a71a02fb000bc76c3f', + '0x208b3c485daa20c13711ab86dec6e1fbd2084d3b4308c80c00e5639ac34b63e4', + '0x035d6d9f92078f590891318a462bf84168f4c0d9646d0388098aa40b3e26476b', + '0xd20b62daae2c307040fc6411729d272adc881fac68d58cb6ae1bed8ccd243ddc', + '0x1ebf18167807fd24564cbe8c0783d896b895038519a18d6b751011d7fc0e2982', + '0x90bf16c43f6b5010e835d39448151b1cb4a0c6431579d63451c3f88a33611490', + '0x79d7d15364e68c881ad6ae0b2f57b7d61e6ba09fce8e5b8b371695e73fa0ce38', + '0x93b4a9ad7be7f8b70f8f938c8e55bd221dd52230c754ac5fc7614a935159fe6c', + '0x0489f287085ce771aae94e269a9ba02f6a4903141d46784af7ab54c192b6fd08', + ], + root: '0x3ab1589b2853143f682dad19e443648489d318c8619e79f59a44d14a846fbb1a', +}; + +export const depositDataRootsFixture20k = { + events: [ + '0x01ba5818c17223777b13dd194f5e64ca92927af901d0fd1599e93c4554f17b00', + '0x48367a14c733b6b4907a2c60fd9b254bb6d7e4d00d0792544309f097d3b76a44', + '0xd0637c1d813bb304c3e955dc869e80be1447848f4e909d231d728543902858c7', + '0xf0232e374baa5d6ddfe7c9cb4fa092145133d094a8e2e09c10ef6029e7c76d22', + '0xafb4a665b1c72e2987e4d157a761cce824b6f7ed024914f9ebe0b7a034dcb08a', + '0xc23c024afec5f0a51d98678b9df5fc76a30b8822e0f5f8f28afb102ed47a427f', + '0x738aba50404c467b8a81ff6233d5d961ed1c560ed08ddbc1420d4a994c093d7f', + '0xa2dfd76a79a5f21ec8530436d63daeae105d86da535abdb3af14b8a322718a42', + '0x0afa5b014a0adc3aadf67fcc32825daac16a58458cd57949aa590241868d2723', + '0x9fe0af4b96294da5ec3fecc02a35207e04a3cc3fb4513991257414a8416a4927', + '0x6c769169d0cca53ca6e31e0ff4d79ed2768a9155b4fe2edb7507df3a26cc365a', + '0xf1d5421bd3410cf6838b797dbda63ff32f4f36795b0cb613e1cbcc7c04d8e000', + '0x2a174fd52565a39472a8a3397f0315e6a2a44d8d582b3fede3743d2e3e11b818', + '0xd21564ed94c14a109fe15b3ceb6a0896f75af33d34993827f484c9960306229b', + '0xbeb5ed5674e4ca6f8718fd7a6a1b9107cf46d84a6c382a39480ada92cc3fedc0', + '0x88b90504375f3cd5c8ba6162d27290de53935abca037ba75bc7303b869959347', + '0xfc044a27fb392dbe824ff903820c993b16f283d2fef0e1f997c8b0c0396452a0', + '0xb3e3e66f4bfd4b987396f18be8b479b14479658dea5c0e333d7324035df75618', + '0x3ae63505d86feb7b07cede59db3f3c3641fb9d8525db232fe29170f9b509ec11', + '0xb644322ce4a4f0dcaa755b6c893b34b69a65df7a3213d14bff1195c1b61a360e', + '0xf62ffe4b04d36639c9e370ba854c475657a704f4fd5ec94945cfc46b3f3b8433', + '0xf20e43a2bcc1230691b6d022b160847e95c2e99195631fe239eeb11c8aecabdf', + '0xab7ff6ad81b9eb26b24c89b179f5568df2eb90d4a9b68bc465615e5396000bd4', + '0x52a3df54c682c0d1100da3fb5385328ff73b4e257d324b568bccf541a6020ff3', + '0xac4d7a7baa5c8a485e696cb1c75ae184c042db1967a63e644f6deb28afc8bc9b', + '0x65ab1005b284b46ccee8b20922fba6d41bde3afaafc0ea98993c06e2eee3af79', + '0x4876db3d488827e29b6c63b8cd6b53f11f9237d7a3fc6d60770553018e963605', + '0xab74447c39a3c53740387f22a1220503004765130d5ebecde325d5a334ab3b04', + '0x74ff13436dbc3122b86b341a78ec7c5af02b756de29c1341004630078456ac5e', + '0x67b5c22da6a35a72fd3ddbde426a6b89a3c08a454a3900fe357197097c4e8e14', + '0x0879bce9478489f53033eaf052f5570d74db4570ea88f766bc900d432fbd6a4d', + '0xa57b65bb32e860f7c3064880c1ea13f5e76c1eb97ac241136f52bb6d4390380f', + '0xc90f7751c3b7d307e98bab47f01b3f2a70efd819036af31351ac5702c9e7de19', + '0x6ff19cb4dd3c57c3f2f1ce02ee04fdbdea80e51740f57b3080b6d913ad108426', + '0x186de53ae9e7f7f8315497c8730275b47eac51c7fd0972d638f30b1b0b77c930', + '0x5797aad88f16d359f6d33ab2d7f333fd7c6e361d5307bec9d09147e8f1523ed2', + '0x1c40c73618f726e5ba91732209819038aab6c25befb7e54f49f570944de8ef7d', + '0xa7aada7398b92b5259f13cbfd10e61baac13958e998f1e355af817eb7edf85fc', + '0xe3a50f5bb8741caa1253fd59be88c252bf80fce7115ca5fca87bcedc75010b62', + '0xb99385870b0334fab7b9ff08b6ccabc044900a2a431715ddb2c537a5c4e4d335', + '0x18bc4025754916d402bfcfd813ff77d440cb7ba3fb8400d11b3bf965e3755771', + '0x331859bea72554da55a5bf27370fb5d8ae2dd9b512bd47d6e2549a8a1e3f1d78', + '0x853bbdac6d5ed928dbf396ae2081ecf44f2b3056f0525f62eb9452182a3e7318', + '0x85c08d9bc962886c1c74240839f363f651d547f0546ff1a08e0a2125d1f5763f', + '0x13ac980d9789e45510751e6efe80e3c20f3135eddd3ba02ca12ce3b4a5cecbf1', + '0x6a404b40f37dd9fa0570cd3a82800ae5de48063bfc6d8532f567a3c9cb0366fd', + '0xe23a27f1edcad66dbef2f2eb6e8af9587899d8fa23099f9af8a5cff9bd5de24a', + '0x9dac244ef28797fddcc2e6b295af1d0e3921adadfeebc75c69e59a400ae0308b', + '0x2517dd29acb875e4f59e4b2ac3a37c63e29a580704ce270f9c727961fe161462', + '0x72272014309e0e3ab91bd3c41b4b75e904933a7193582746cd760a36606fd7ca', + '0xa9a87aff71782de400fde8c9de01558b8e02652b2e9d180709881d1729e6e95e', + '0x6adef730f475053eb8d43fd0ecb6565e66efbd37e4e3cd7a845cade93b05adc8', + '0x2042fd8e45f17fdd3c2636358c1ab247924f1c6945175a1f05a31fa1875ba522', + '0x52030e7190dee69cdfa87d2b57fe0d9e524455fd3a3bf04c6129a779ab28a9be', + '0x6bf1fb0df70bc1ccc67ed1166e1c62396ad4df5a2c0734176b343f156dbe5541', + '0x7c113edea9f06387f3bd2c902c26901f11f3e710372e52c0d5ab0ef224d981b0', + '0x38a3caf12eb5e7e97978787ea2e83139f13399fc84a686b83f553b9c9c4cae43', + '0x917520db093df70a06c2187f5ba77980d883b0472a2483d2f2986c11a3f2745f', + '0x39f70fca4d19206619693f1cbf8c140461bcd4cfd89a10bc7cbc55a9c12dd891', + '0x0f962d2a841e4ae490eb5d508618ec4efad7883992204e7e5cc59ca8e4b139c2', + '0x2d7fac279fe9edf0a3ed13ea309a6eae3cb137c9b035783decb69e53c44d8964', + '0x90c5f46d074ccae4c7865879fd4be382b4ac72b00286a248062d371e49a232fe', + '0x8fe6747d267438b03e3bdd62e98a0a9a0bc9b3e860a77be49c6b7410e7eb8cf4', + '0xe6da0c5e86660c0a6baef412b1f265035e8010dcd4f5fa6c700669fc729f6800', + '0xd6dcd3465d04ef1053e3f984eec7cb2815b49f7dd4dc4a07d221388770326856', + '0x79e4042cbc1bcddfbee3b0953679cfef3539d75e05458254f3414476fa8f55b4', + '0xb3f8096a92cccf01c0cc68b11c7b119fea7c2308662922d827d432975186bbeb', + '0xf85e15a50aafd4d7ff17821c301dc7b07249f6a4909917b18659c804ac944858', + '0xc1192656ba98c8be058d9976e95eb397d83326d8c36a762e12035eb5d865a5d7', + '0x3c5618f212ede686ecb55b6c634668478f4634ae37007387f64e39045c49eaf2', + '0xa282dfc3b8d9feb1ebacd29db524ae015bdc198263f81e9c466fa858c4ec1364', + '0xf18886da457750a2febcd2d45ed091ece920f7854dff4115ea88c841e0c25ac1', + '0x62c413646c7e7d85103063085a94e3285c7f1d34495eb1464c27aad095dd3c85', + '0xb2c9cd6dd0065a4427fea3b9b07bad1f2f6c767608adf62ea09edeb58695dc7d', + '0x601f17bf11fedc05f492999071ff3df8011263ca63da89ddb70b511f610e6dc7', + '0x6fc1fb78455e460c57808caa7f81aa462b056f5b45e6b89738efcddf7e55b42b', + '0x0a0dccb95d1b824a4bc4e9647bcfd3624c0e3390d7fde7b9985ef62f227d0719', + '0x04a28692d8688a08cc96c184f1ceef45de98ec83a74815f082835258067f9b0c', + '0x986b988e1ad8b9c69a79def36b62792506e828676b4374e178af74765aada323', + '0x2bd621c35b1dd9436e38ebdee320d766b6d660e90ae039cc09b24828239b23c0', + '0x450eda98c32e3f1eb2506a4bda2365945d2a2910b13967069ff83a1f150b7ee6', + '0x5fd11f8e5884e7e07bf3c84a9ecfe7e4872c4d81466b524b8d6c9cc4908ec8f1', + '0x33642611f89ce08c1190633e2d1d00a17612c4ac04ec21048b633a0c6b5d4ebd', + '0x3246f523d12f5b827eaae3c1046d1c39dd0bf96774b91e448426d57e0c3fd2b6', + '0x5748defe03171b500ca1c18fafaea82d0dc8a04a99e9107238624d038981ca23', + '0x7cef95703a90b54b8121ce514393f4fb087fc89e3bbae0ecbae9f4b6c32e155d', + '0xf438427d7f9e2fb898d0cef4ff5678f66a3566c9aecbbeadfb2c0283b6a8d33e', + '0x482334e2a16192dd58f5b62bab284b071022ee0d99ea5b119f00e4a1f4ceed0d', + '0x87956b1d1c44983869fe5006e34d1463ae98698c1c8b434e9ef90becd83cba13', + '0x53857c9faf5e4b1c7b879e98e90d3e71d8d84996da595723f2bfb15d4d3fd326', + '0x42f1d5c4c53dd02bacf00dda88a3f1e24afcb69c3072a2045010cf35eb68e621', + '0x9d7908cecb052e9a21852fe7e9157ccad594d79a051df9ea21d6f211b3b80c76', + '0x329206088ce91e857c700deb54ded677d74cafb55827148c3f3151a2ee066c10', + '0x36f7e5008d2ca8869c94d702bf72dcdf1d44b39551cf639fe2971c6e120899b3', + '0x23045aeffe2becce54a8e22a561353179a6a4a471e413d2d1b7304b5417bc825', + '0xdff2cf4e82b66c7707617b2d8affeb9ee8f6d6e18a1c35caf95b151eeb1a574c', + '0xe41ffcf451445333e5d92c742df40d3546e09891cb2899a81cadc2bdadeeaf9c', + '0x9f2341d3d22e01bed1bd677988c99f9436520be79c9d310f105c7e233be9d247', + '0xbcbcec8fb97015275f49ce9449ace992e55b6bcf21ed16250b10c73c37c13f0f', + '0xc21b334b44f6fa096601d72ec3b43097e9190fdd3e612b8829a539db261563f7', + '0x02bb11f61cf8a627b1ad416f29d4321db3427c6c2a864eb4d900cd52d4e9683c', + '0x986f49c10d61d9ab94213fb94e9aa22f41c3e4ceaf29d75ee33f6239d6b3a110', + '0x3ccc087186a668e8df2bdc666252350bc8422f70cd86b94ca4503b73d241c0ce', + '0x6f6a71e9a7c9499e483de6496310800d02d377370f29a717cd4b860e9bcc39e6', + '0xb848b6102fc24e04c186c7c00fa0c9bd4b5ca4d3b35607757fd9e994911a6c44', + '0x20ddaa225234e8e3215f5fb0586e0218f6586878741949c188bbe0ea3eeeabe6', + '0x69e517d5af6091e7499447d0d761712a3b2a6dc54a14331808decc96409d7093', + '0x7b8450572bf47ab26c84da93a09337a0e37fb36f082520358165d7e4cf2ada20', + '0x73a613b22efd940383171608b9ea99a9eb18019f97a91a4fff31574b938a40d2', + '0x2fe791820140ccfa102db44375531724ea10a8be45c18145d69a608b98a8ea8f', + '0x7ace10c6e817842388c9f6ac2fbf48d9fd29c70acec560bfe4a1df8ebf648d08', + '0x88b727e22e523a9fe07207b27439b9ce34c360a5c39ecb6102e4ae9c9ac3d152', + '0x1566cf193efd738df3cd06cdcd89b9fa16aa9ad72227d2916214a3e59bc2c145', + '0x6441bec229912f62abc1fdeb22c897aba2c435b6272f47ddd819a58cd36bdc0b', + '0x2386b9ff6dba153bba1fbf3b70fdfa70ba36ab0a62a8679679ec56dd8188f7e1', + '0x167917d2f2bca88f38504e5c8f3911ac62a3c2b2c6bf08810cae00859c113cca', + '0x10c51df5262296630f167b1b1c1a3815b09e00622a36340fae2cfe1909e6a618', + '0x7817ba36b0326cae32dfe1bfea673356aefa54ba729ed17ed0767662784008d3', + '0xeb1aa1ec67a3809cf9153a14effb03868984f82407916550b855bfe4438e24a9', + '0xfdf155771bf0a5f81340535d64b75938e031abdb23a4796ecbb07829d5e2a9b9', + '0xbe7e8ec078707cc91eed548b23b64657071c7ba0dce0eb69adbb465b115c2e72', + '0x03415d9e7db35ecb191884d8cdffb8ebef2ed9c05179c08210eee9d93f77f387', + '0x1b728ec9e4b399ba9141dc17ff999ec31a3942a20223570719c4819aed333248', + '0x251cc9f7b9c50c9e8fe3c7edc5910c5bf59c58db3fb766914f81f5ef405ff3e9', + '0x432a35fc9ce08a123b245eb0e3257caa9e9d1e4be0b593f00959c90b44efa9e8', + '0xe19d0b08d5ff0677b6f84096c10fc8b0e51a058961de65b7c0583da33f661204', + '0xb1c2fed86f8852b8fdc818767e95a55f717b8ce8a9825e45fa3afeba9b581d8b', + '0x00b4dcab8424fa02957f978f32d7af4131993b3950963b3648bee6dff27f6800', + '0x9fea6304431500e4b5fcd0dd0d87e772d9212a69f49597c84809e344fec2d765', + '0x89449b7170baa165bac7b1e75af24b86e35f6bc7c7665ead14ddf81a9f8a66c0', + '0x021017335ba6896cd5897962d227791ce359f1cb6b2d569da663b382b26b5650', + '0x23bdfec8c32ba1d06c194270d1f2191a2491fc71779cb7b5e68040e2b0b7f1ca', + '0x90bc9d019fee4cfcca225ea967ddd5ccfceb0743bd5361bb29d966caa2bca458', + '0xcfba13d576aa11faa82ec4d60ae9f5f59b1c512d910a9813c0bc8493a8ea75e5', + '0x1c56f5529138438edddcf4affac5469a4b87c242c2c36446b313b781e871d7ac', + '0x22bd4d68d340e059f70677c158e80f03e81860b26ba6b633ac25c1968112c32f', + '0x6d56426496814bf26e4a1f0ea51150f8ee1428033bf391632c7bd11abbfe77b3', + '0x44c595711d4e20f4448e120c8de48e20ecf07b808d04af7f98b9e089af3ceca6', + '0x6d0281cca8ecf1a9795f20db48e946b3e247257a41623235f1755f70f8714aa0', + '0x50157a4c71620a10063ca117e87903d205af0644f1870d22da47fd97e24476ab', + '0x93dce6cc88e79e424952f7f389c6b19ee0161c21abe2aaec3af4871c742118c1', + '0x1225c7d0d531140a050cf132fe72211e7e70bd93755760c5c0ebbf47bc8bac40', + '0xd1412fbd031c7e16f801e158fa7cae634c392a7ebdcf7cc7abc96fd372ac8906', + '0x7f7cce141b3d9067e9c3aeecaac0bb12da981c9b8011d584327ffd3c287e3152', + '0xa14ce909ae68614a293d5ed838fc015b6145180744a6b1b497b9a192f2f4e852', + '0x48e549eb1cd44747ce9566be2f697f3aba1b2356a6fbf7c42a787db86ead78da', + '0x001281502652fd04e520af9655ca7f3e2428557455c9adc0de65a16d1a5f60af', + '0xfa49ada4d99dc82782fff02d620db56ac8565e767f09220e8065710e0dfe5067', + '0x564852cca4c77607b179418c84f26d867773f6e11b5a76d1a14259e666236038', + '0x3b1afa4d71e3488d98263b0c3cef5cd0a563bf83b66d857dc6656a84e7740c42', + '0xde56fbb1b06ef7b9c0f4f92dca3df1cd4e2df509f317ceebee4e612568457247', + '0xe32ee7156484eec816317b51fd3b3f95094eee53416dec8c51fc84a4a537a26d', + '0xf5372d7996557762d83d9851f1676d12fcb4936dbb864fdc50c803b925c21933', + '0x6b17efbcfc7f307d1d3154dd8690a5d0c84bc2e435dddd321755ae51f352fef8', + '0x689e099f4e7a95c74d40f53e7ba3b4a5005251b81ebe306b8ecd498c9a16a068', + '0x56da4b7290fdbee62bf01b1f213ec37bb3a346771c55c6c2b9c9050fa168f88e', + '0xeb291218e2b6825a9d2b0389a769a9811c4712d07a3987ad007d7386c8353fb3', + '0xd0873af33e81a107a15a28484377fb972ea98f4c45e12ab57d5253d0acd0907c', + '0xd920cc5c4d514785f22399dbc03c1191e67133b2a960f2d60fbd0bdeba50e4d8', + '0x187b54e41f65300f6a6acb73527ab90cec410beba31bec62458832a3c5665c3a', + '0x23cc25e9c2de9d7b8805d17e27c874728f010bb4d4c58f8e6027cb0830ae9488', + '0x84b88e3186f32fae491c85d43ae14828fb8c3b2500af7c11fce5b2ecf3fc0a89', + '0xba71f251426c6301b4a8c940b3350abb71c5da0988d421461b3f9f414d8d1021', + '0x238424b33c91dce5400d0a5ed77f31c5ddd6575da680547bc4b571c7a38486f5', + '0x33e53d6b868c638401e0a0474423fb0ec53485592ed7a1b0ce9036e061053f5f', + '0x1ced92a780a18870e3fb3c11f18060647b1fb62283cee6f6ec2a0d715aa85046', + '0x329a9d3c223badac24243f8b32490d771cd888c6efe5c3f8dd34fff70ccda779', + '0x74d245812c705dd25b7dfb9c6726d9f680d3dc3c2ab86892b999078b1fe42cbe', + '0x67645ba66486de9fbc6d913ade0772332c0a8e6e3e82d8cccf3e6e14fc275dc7', + '0x957bca177575c8a9bca2766f30fd47764e59fa3c141d31f68f43530d0a558bbf', + '0x063056bd2748eb83f78ee7060abd21f112058fe13dea5f569d62cc0cab2510c8', + '0x1b17e80ba898ee84fdc99ddac942150e0fb43e9379d6ff1183864f73a8c9479d', + '0x7a22e38d2f06de2b23d18e1c8665f48c3c88d66df5198a800fc6a9407d0f2591', + '0xf23b50a0a7708326db1c31c8a577cf75f178a0e211db79cda07a00ae125d3920', + '0x53ece540693e08f24cd5d4aad1f071e25af158ebdcaf0d03946671b56105ca45', + '0x3bc672e957b8d5ece38f5198840919afb7ec7d67d4bab4684aaad862ef1f20b7', + '0x7cebefa8c96807531c67e3f25b6630d14f3758ccd990707d628a5d656d06f8df', + '0x0435f09c6578f07a311aa9c9877e603370e2ae2d23a5f0a31251a4d4c715cf7d', + '0xc4983a7925242a483db8e1c4a6b5baa6d6bfa8be53f07826fd26712b30a3a296', + '0x2f6e48ee4e307ea32ebf3d7ef151b50341b8f1578bf5b60e0c386b28d7f0ac5d', + '0x33082ac46884c24031d5802f82a0099e8b056fe2a7c54e0c52b089ce36bb8b9c', + '0xc43ca322036e9b40b55886a0fddae4cac639a46674851d678958bb6554db2011', + '0xbd694bbbf5fefa8203d0907313326aeab829d69b7a9c9cd7e881ab3a24ce406e', + '0x0933f8e6866dd3371c6a4d70b0205c3f30ad28a4c96f00510e0c48baf31c5a15', + '0x1c57f03b104b82e481c0e0d823117b930cf4ee338289d4426815c900d1861b28', + '0xf3ebee6f4a9481bf94e333e1eae7e56b073e13aa15028cb0004d661d4ced2875', + '0x43865ec25ad9986f1431da349be25e6d0852a56c4573a13f7b62602dc2428b5b', + '0xeaecf3719d9000fed03f8b82a69a7cf876ed9909f8d4a862b03398c9bced04fd', + '0x323ba57eb34834954f8265a0064d70cf61975428926e75dfd82315de10c5ac40', + '0x87f3ced240576df4bd495adadc600ae59cbce60e8a93bb972628cb66a308ce24', + '0x85262601962d57f29e068deddac0920d9b5a4f7dbe87f5c522d64c695300457a', + '0xba331943067c903d5add7ad72280f9a5b56ac4be656cc07c190d30f44a3543b4', + '0x0839ea04a6eec46a6f00f199f6985b80bd8f7eb17724aec3c1e423d1b0c4e441', + '0x45616d1f139e1ce704a264fa81f92fded8cafbf5feed808c99106e5c622f3743', + '0x0e42f380cb9f639383813fb3a992506b4050c857b9cc55f4b23cb39c83d5fbb1', + '0x995d3aa2167b104e45b8e27093f2fdfa909f6bd648fd67c3f585ee8030abcaec', + '0xb0972a362eadf19d23acc327262663e31dac0954d5dc5299742fc9908e19291e', + '0x5d648d205d43092e9b332020cde403001806548a95d6d89607a9009657c56cf9', + '0xa0c1e398eb5512ca64eff128dc9ad272350103f7dde31077fcc1e307053ad49a', + '0x9eb2d3bbfdea568805d65298f095abfc5d9e6e032e6924e78b00981a76368851', + '0x6a295653b7080d6da7782cb26fbcbca329b32218dd2a803bb69ca4c9894f6cc2', + '0x8be72a010583833277f8f14999d8cd72a645537c1417dee6fdf8385b61f3f45b', + '0x7950e1d8c047bfde4526581bae61a77882473fc98493cb6b5abe7de067013e84', + '0x515569b56641cac2810e9aae0b6d16cda4c7673b010f3caed77b799b3b0438b5', + '0x5c6b3db949b2806461cab20ca1d87137589d98511cba85fde8706652e84fb994', + '0x813465f31b90109542edf0d06cf4bf92460a3480479f2026f372118fe6950f43', + '0xcfefea30cda086ed77a85f51e07683ab3b262cc06eb9a191d640e5348c5df53a', + '0x5199fe02a183754e49dbf0e0908574d46ad07600a07aa2390f0f99c73a160654', + '0xd2c7475ae1a3aee23ea73b3f8107f84160b7306598af025cdde6fd2fcc557db8', + '0xbf895f7fe69f19007aa2304c75e1470efd7e3ceebe49828afc15b4257b88f425', + '0x33c93e7c7bf1fcbd8d921f9c16e6dced448e26909cf18e113327b473d595d7b8', + '0xdfa84b48ee49870a406794815f20d21204dd2caeeef711725d50799e68b132e3', + '0xd73d0fc34a37d798a7c77d182c8a498db8476d9eac3e6deeac95a4f36741968d', + '0x3d5046e0f0a1174ff1148e43cdaf8590860b340b319ca5ce06802eec8f2e6886', + '0x4ad4bf54cb585a53d6e60b5bd488105b835940fbe8a43ece880600bf893716d4', + '0xc71a899711b0209f0d505ff4dc3a09420f0545d7a824378bbed0d16b23e30ead', + '0x0d7ecde289d067baac71302fcf5ac5f4453ea5e99e5d65cf0d97d08a25ba2ec1', + '0x4251575a46dffde5a1360ddec2dcfd5c95d16dd607190ffce66df89be00c7291', + '0xaeb038e06220eb289c8757620d7774947eb6ad25a1992a6ae16ff5f8d91788ec', + '0x75a323aea4f405d1ac042f243cd52e7b22f65d6d90928de0348f1fe916358587', + '0x58cbdfa0b244e2faf0585e3e5d225de33510f9993c210bf42957505e8954116b', + '0xd25c524b53c4c95e811e73d9d2a9c4cda25b794647f4050130eca1800037d029', + '0x9a58dc64a5016ca083e17248b43fb574d7dbd20f1297eea0215b813369420fa1', + '0x74f9c299cba6845b73538058b442f90abbb9d8b89d25ee3c6e0062b7f2e7b5c2', + '0x7437fbfff9d8bb1ff83f89a1e3524c91218fa40a5de18f006b9ace7d9e565d62', + '0xb6006a14c0165415fedc88ba039168a8313c582caee7661212970209ba732faa', + '0xde75bc6f1ef4f15caa61d0f20be909d46a9a8f26d8a7df951030e3c981a6f03c', + '0x5ac38b99bf0f2ec09d80c40c03a26a61f12221c84bad39512a1785237ce0c461', + '0xa43c50803e6cb4426d0140d33ced0d29b67a7f37382ce642a5a082c39ec3ff93', + '0x411bc322c6c4aee920d1a63af845361f9e07098f38b6644ea7e159db6c5e6ceb', + '0xf9f34fcb57fe873083e2ef353dd98c5f04860db5b33a37d7666a2d757f35a1d0', + '0x456eed24e6c0b828e535953c7355eecb7e38566c3543045ef722fc613e6bab4b', + '0xa30545e45ee61794004110088cf7cc7c0ee252368c7b3f25f188f096b0808d77', + '0x4c2737003937f8e133bc3b03ab170129cfa73e531ed8a21c8aa6651a92cb3204', + '0x64f56f19ca2b90e6f4b098f803b7f29999cbcb111b4146b1afc3f89d21f7cde8', + '0xcb3aff7c8296ea2a06315be8e52d09d3a387ba7aee74e60b242795bb09bafe7b', + '0xe64416c5882f35180a4ce22d749029c9a3425887b6f5c50c3d25fd1532f413a3', + '0x917a849eb5db1b40ed8fab13dd437d25b317373c211edad9801f6ec73635acb5', + '0xebae06c87362c1968738fbc5ff21df9f5e5d434c53125a7b31f28120fa7be8ed', + '0x3300444acc27d479d8b23dbe39526bbfe3bf478377bf988b7d0d5bb95aa3c438', + '0x971a5fb1cebe5550ad65e7a54e7e2035ee4aff36f8870a3d411a555997dc96fc', + '0xbfb8d27108bddaf4afcd6d9fdad299bbb1daa2a06705832b7f8426290f12c83c', + '0x507865e60ab9aeb3c77da442eb0ca624dc7d00f3f640dfa6a9336125d0245dbb', + '0xc4776f8cc9d822922bceb1beaecb41e5b87fc49b395d1c35b3ad7174d0a04f28', + '0x10e53e31f92832eb537b29d9b999517b9ba84a20c1e1d7a99fe2684903f78f80', + '0x51396807cb41a2b659fe61ec5940ec5d37e33386e6fad3323b5da044dc7dbe57', + '0xd6cfc2c153f9873deb7a0927d720d6df7c0696e7d9974526841ce0ed1e34cf62', + '0x9b929ca3fe779e0994a4668a8bf6737d3faa808b5b67c050cc6c25f15c945932', + '0x21a8daad93d56c4877d976ba154179a7965e3b8fad12d97a5267de1558872bfe', + '0x7f69ad4abd54224ddb93eabef7d941d8d4b83176a4d523d67ad1901d33885036', + '0x9a3cb03d2579c5550741f7bda6ad83c48b60a912467758dcb61ea917282f9ed4', + '0x93f49248701de56083e70005a3e907c23c73d612ce00aa77e014fec40a95c201', + '0x70060481f4ba403ea54ce7722d00f37904e6b410e19adddd7dec83514fcfe771', + '0xf7aa235d2b78a5e72170c31042bc969f2b4f6be697714b5f9c6b6293838e7455', + '0xcfb5408341acc00e8732c31554e0dbf5e818c5d16e1bbc686f20248c7ba0faa3', + '0xb65cc497753387c0507796135b621e5fe3fda430769c749c0f8702e4fc08a093', + '0x5fe1265daef316a522bbbd3c11919a499eaba35fd43e679f65eff0850dd51389', + '0x2f62fc96d7f281f6c2485b3b04e48eb18edbe4ab65ad27d640133681b9624f04', + '0x455619dfb17f0a5141e2ce78caf45d96280b3c16c58d0206bb5835c8b7b2ad1c', + '0x298d9c05ba6c2fa323ed7e2c138ad6e7617f4a5361d85f048ece702eaa372995', + '0xd0addbaaed5c3ff61bc0b8b5ccda8b6a94b347423a4f84893e2529d51929bb76', + '0x2492b617ea9de5082e3522b7d0bff411618768089c3ea656627ad8986d7f4e01', + '0x54839878bf6f29c855e15c1762e2d8b298afd6bd032ea631db0083a79b99f6b4', + '0xdc5036cfe485b38beb5c3be2dacee3e96ce6fafd295b05111402ce24eee26e59', + '0x51b0d7db21369b00c7846f5f8baf2dc514028c06d44a190f46a26bf58f3e60fe', + '0x6ed56c96b3a3a0263b9d4e6f0f544535d7876245a2c718e83d9a2d965faf32cc', + '0x701182142765490d2050dcc502d69feb7a657fa44c6aaabe555881158aa6b486', + '0x28a4d3f1cc783e7cf62d3ef71d5636c5e1554747cb0611af1031dd136b7a269b', + '0x647d0f47feb079561fe21bb68bcff9a3f236bdbecac774a50f95d6af22b859c1', + '0x30b3ac6b2c8650eb9778938bc61d1b5e45a485a6d021f3114bf7963c0fec83b8', + '0x83c9e26fbc21cf0416489cabce48d0eaf8405eb59aeaea428fe79c5496549458', + '0xcee8df28d845c17e1dba7a4ac51578b648d9bcc6b37ee2821def5d62ad3c7541', + '0xaf47067a4baac7ff568dd8f6eb6abe7da640b0eecfc250e98e418da64a77eae8', + '0x213aab4fa9468dae77f68d701b1a32c5f6fc6d5c7c38bc930704965e8c430c7f', + '0x27b50f2d9afd82ee2a0d53e1430563f040f70ea84f2a7c2318b1d2a14a7dd690', + '0xde058ee2250cd7c6e82f0025d8e6aa6a24399f45bfdac5629704218a7c6b83ba', + '0xd6a09624c254efeacdbcfcdc5588a1bce9ab0778eb892369444f47cbf2c34717', + '0x9b515020b8c869ded9d6125402696ba62d243bee374cc3155c8dc619b1709e9e', + '0xc218e83841241b324f94f2e484c4d175a36611a0d70e0e17a1b01fd0644e62bf', + '0x979d145c9a20f9a29b7bd1aaf7765af4e25edaa36ab2282b05d4dd5079dce7d2', + '0xb5efe4927c8d96a7e0702364afc265293491842c0f61a7035af359fa7733c51e', + '0x9a6915160b0861917c894af196d04804fc186fcac250f411d3de9f50f4935ad0', + '0x15d1fc7fdb4a899eaf9f630433af38deb72a0c3da34b5a2cf7d1609963b6f12f', + '0x5f4c82ee68e98bfe2429e5e941e9b519541adfdb4ad2ccbf2ad4a4a28ec0df81', + '0x8c821a42ef5c0ca0e4f4fbb25fe1d125605b1948e58faeb366aa428147fa5564', + '0xda3115f56c99c7d765ed69cc189a8d5605768b07b935b7a0d86c4e10dc582829', + '0xf620f0c0e1a3984b0366ea6b65c89a9f263d707c8a27146577661229ed0523c8', + '0xb8aba00d6d5437834073e9eb9f5a114fdfc47e341fe0dc4ff0ebe96bb20f5d04', + '0x7276a939ede7733ec1297bb1ecadb4ac7569c8ecb9173cb9471687485a1c5451', + '0xff8129b10a3d7e4db5f9db4f69e82650ac4a6b1db845d1fe5fb620ee7ca113c4', + '0x7d23b7d69cdbb3af1a63737196583642f2cbafe35c8bf5a5b5f2f3fa7a4d83fc', + '0xe82b328245b6a554dceb7c928fc537ebc77a562b6196674736dc061b3a13b80e', + '0x93389f1da009d453f443aab01aa18c48cec628cd4ffebbb5a5f7a652f0b2a0e8', + '0x79c640ed577f4f00046cfe21e899133eeb87da71321ee688738867336e57a497', + '0xeb60bb68eafc01090d977cfeb90389df221de557748548e8d6754f10b8e65a81', + '0xd24214038947c3ae0f71709274dede7f896c2de8601b177ea681880ff224c42f', + '0x5342444e7e4039d135c669144caf63abfb4e67cc1d6a127bf66405a733afcff8', + '0x0834eaaacdf3e0e1298f37d8df2d940757402498c246cd5a50cc8666eebe607d', + '0x9e9199a285eba216db915bfabb8bda9f925f3026419e385261ca33028539a5e2', + '0x340ede2b359f116bd7dd820f717a6dc90a950368f438e374a49661fcdfaa3c57', + '0x1addb9d8cb77315e234110f73383c0aa13d95ec14aae1a5a30cd403b212ab9b8', + '0xf45b9b193301fe484d378d7a7639a8233f04db453b35298842a67db5da6937ad', + '0x27f75db4d6fcb21f4b052737d2af3164b01ac4f6bb58ea29f545aedbc5d2f2c1', + '0xef56737c32f2ffde7d20bab3fc8c76d36adb7b604cd4eef34af2070346216039', + '0x7fa6d52aaa05e6f42943e003c7d84614b695dd67353ec99b9817222453117bcf', + '0x718dbb1c8e144df01254b3b2e2f51ef04d77ac0ab0a29e2d6dbc83f1bfce7df3', + '0x794e230acb4f8513969a68c7cd8b0cfe77937936fa95c54fa6ef027bd659b022', + '0xc20b3a1c12fcab565fe4a9469a30a796c59c0d1fd45929a2b85c719707e8edfc', + '0xf96d0802bfbc4fc73195799067d43efae6868087f41367c38a4f798db465f091', + '0x148413f66a0d98d9f067c945c88ad8eae4727833e6270dbd94f48982f4acebe8', + '0x6df67aca084b26650991e69ee00e7e34e9bde608c5b3fb97664ff6eabbd638c8', + '0x03fa6633bcab2bbbcd2e8ffe59a76652c4e685e70f974d1024f396f819f3a8de', + '0x54a1543ad2e1d98b28610e9bd3ee62a9ea7f279305eeedb53c02a570a899c2b5', + '0x5ea5397ca8463115ff7baa0cd721b4b1bbfefd87d68228b5e7a7d13ec91386e1', + '0x4ae1c680c49e468c5018d207d0c61ca26b3d79b28673929c58739a465c49be3c', + '0xacf145138ca97e0b4105a926a3ef4851f1db30259510fdd3130a7c03e392b117', + '0xf617421d312e14ae42b90a13df69f09674e738fc3d297a5b560361a052f02e50', + '0x0db7c5b8c761cff909e75f96ee1e77037a0a7139ccb363bd92b9911571c13047', + '0x61b1f8a054369aad59ae194fb4c5586209d6d9c106c504091f1b7164ffa838b1', + '0x370d187e50d97ba31d0785d8f09dbfa2d3c95d15124456e478db86947fd68b59', + '0x3785e673a79b3d8d8f8c899a1aeda96506f4ddc7d10dedbeb126d5b1ee644b63', + '0xbcede27efcc8b2681abb8848fd3daad03f05d6c2198f94a800d79c03b4653ba3', + '0xc811b6d7553610d3d307e18fb52d3eb148e1177188be4c1c84fb35a454f41590', + '0x21dea2ceee271045cf6f5ee63757ab68280bfffe867e70ffec83f68138aca80a', + '0x87576e126ad47a5e4a04bc7f7a1de20f17d65214e2de4d98b1b54cd4565fc52e', + '0x3bf016a7d83f99d0d7d2fe06c8f50277ed2dde7fa964e0df99cbcb77c9e7d4a1', + '0xd9e4a95e341960dd1ff81c54d105c5ea948031f1fb1f361fd823107bf51de678', + '0x99531d33670a1b6d226a57b4679ecb53962bafd55894a208b3c1e5172e0dfd80', + '0x10a8a0e374e938a2542f2a5e993ff581a1f2537407dd79294b4897f72da8b086', + '0x9dcc02c38007b0f59ec74ad79901efec5081d4f75be082fc9de0203c758812a4', + '0x55768b6a767a6b86832e308590f52ef97e9ddbe11accff530b5669af0f744847', + '0x2a7323979d16e345e107775f174ee9e1ffa5ddf0ce15843bba858430629554ad', + '0x6f06a392e7b0e55d98468bf70e51f75e02a0305d7fcc49e5f7e370a3f406f569', + '0x6fd4c65461ab1682c8580b608d9c4cdeb1dd73961f4e8a28b0e14869985bc437', + '0xe821b5337d4fdc9557dcee5de278f8d83b276a9a044e2df694fbf9eb42bd4701', + '0x1bb1625a8d2262dde6bcd67bcfc604056275d1f77510344c27d3c58708ee8a49', + '0x982b8087c12fe879731ab8aef20fc6f57f71cccd34ce285856c39c898f7857bd', + '0xbf61b54b104c5ac678c2c503fac534f4ec9d60730a3fb8db9f04abaeb47fc009', + '0x49f1483a03e66eae9acb372a7670632970fafe6e812c1fca47c1985ec0047ff2', + '0xf4e2fca070f8e9b99314932db936d2444f3b5b2ef802f87c8627a9eedc52cb85', + '0x7b737c3142d82efe7f92cd3138f25fe7dbe69d84d8d90a6e2e268a1db7469195', + '0x9d306ffe94a6707057a4ed8746d9ce781d7a99a64378f281ed5c70b6f13a409a', + '0xae758e46e28732e046240c87eb6c38b1df5f970e476daa73df4b55e95af06b6f', + '0xa0e502e5d953773050810fda523c2f631acca8b9090778eb4ce6fde07213ddd1', + '0xede25ad1e027f9c19bb0d5432b2b320992bfc7cedc866e368f66a7b10202c050', + '0xeb751b6d66abee3b68aa09926625845e3da2ddc31d7fc36f8344929d9c0b677f', + '0xf5bd963625e431a178d7d4cc6a6fb625b0e9d0d92c26366ecd2d59623aeb51d7', + '0x0edcd0210ec5b256a40704c166a0f5ceef7b90af0198c2d6748e639cf63a1bfc', + '0xdaa30799780b9a581e3bae49b7da5d6f392bd77d2dbf80a18493ba0a690186e1', + '0x8fac00a6e858e845a5ab9c6396f24502f138468b579ef18946e72fa182bd931d', + '0x6a4f96677f758029d022b7d02663efbb1c17fe57fc60a96fc2c7e38ef1f574c3', + '0x223d16715efc95ac69b164dc4019c59a96581b33395d35177863f57d52f8130f', + '0x0c1f9077b3f5c33a1d0b85dd3e94a10ad0edb6502d77d6d71160f0d64a0ad9b9', + '0x1fb6d0f2a58ec0402f2e91763bb2d88dba52b748923dfc1de293dbc675e0da3a', + '0x396d727d78c719087431b343c0a0c33d4367807a4f8ad1f2e94947c35200e3f1', + '0x6188ece27cb7336ffd62490465df2d22e3124d2ba3340dad897353653e253377', + '0x9fb62b820ca6c09ff15c514c5a42fd89243c1cdeb959eaa8744aa04e737b2d8f', + '0xa45389202028f8a102df134091538a75df8b5a2306e8903519a6486ac6a8e5f8', + '0x89ad0db9cd37fa6c6b37da489486f50880df16be6aa702a75060f51090f8af28', + '0x784b8857c0c00a292b8d7fc82b84bbb06f941314797a49153394f7c2aebb2ee4', + '0x4ff707a3ed214b124b8e0e9067bfa7f8325ec041065c0fd8e67f19fb86f2dc6a', + '0xf1b9d58e05b9309874d3d288c118fd0926ae8a209b91bd7e06c1e5539d2b5c80', + '0x9c104856396f2608e8b4c65dcaadc28ac4673fe865d6b557b19ba72417f76c5b', + '0xe667641c0e7f8883dfddf71f8a36dfcacd8e8a201c7d417c63d22ebbe5f92c4f', + '0xec64f16579e9bc0be680ef019e6bbb9e20779e39dea018499ab06567d9a09117', + '0x34c0b7a4e6dc72fa27f6f1ff2752220fe8f45213cdb23acabfefb2c1dfcc8a95', + '0x62eeb213441d09380ef6153e09d5f3d01f7726a7b428fbc26c06c35de054cba2', + '0x322103b1063fe6a50ee9e454ad707f1735b04f2d57e868638390a30c360eae43', + '0x2272bb5acd29a8a41bf997e045d8ecaf2c877c39a2672efc0d7cbfccc2e523c4', + '0x82f29dc93e8192972425c891d8dbeb11332d99fde81e98ba55210c0d28f296f4', + '0x0ad30883b6e4256e8a2acb0e4330bd24a634be587dec4e607fc39700a29dd5a0', + '0xb9f02067057dab54febe78bfaed9e839a2a80a0897c676f678a74e1839a74502', + '0x989e5fe08599a3c43df3c6b4b65a06e46b9f5f89109b4accc4ed3bb0947416cd', + '0xff143fd7f3d278364d7f82cc3180b38d14c612ffcab82a120a341c76bbc781ec', + '0xc8c7e784af251f8a3d3c8838728c3631ec59e82aa45ecc818a823ec72a08b463', + '0x8a62815364cb918265d864820cbc59dda953212dd1b582bb9d51fe3ba821497b', + '0xafcc281833fa08279c2d0c354a6598cb42cbd860a27073507b5787b8b5792314', + '0x03c6917e2e5aa5aaf2b3caa46fd99964c4e5a9a2ef641d4dc9d7f1eb58bb525e', + '0xb33ed288d07b1e78320dd9c0d5e1a8c009e8990c99106df4f0232412fa41eaba', + '0x2ee253dc324194aea5ccb096967f709a06cfaaea68ebdcce5b4214bd540da830', + '0xd2fe9eb95731090f13ab828f7d8a98016ec67f760433bb10a5a592b1eabdbf1f', + '0x8396987d0ba5656b308eeadbbcf91e92d721075ba187bddb332126c2651a976b', + '0x5810b8e6bfa25221393c287cdc106b774f1029dbac256b2823bfcc558bdc0c0b', + '0xf34189ea4e617db50a08247a210795690b3ef45541c78dd19832cc8e2e76936e', + '0xfd81e724a707280bd6860e5951e7649cb170442f0f0f4b4a6abb913164cce4c5', + '0x4f81b4c5d9141e8f5926764edcbaf77fb889464b59cab961226f5a0565a3cf2a', + '0xa913b02bc404dc65ddbf550e1e971def05c509589ff3c5bfe8b116256b17f74b', + '0xd0bd339bb6e6d13568d7e347b3096a97ee2052e64fbe926da0c4aaff0ac75ece', + '0x7d5c263a9f55dde97b4d28f2e5b2c4935f602535aaab1ddc179f4e2c62be0474', + '0x79841f2b95a44c5e9a19e120fa97b62b1591c4cc1a997fe1a63863cb7200d565', + '0x6402a2586c2ee7237be93136db29a22cca83465e397335c9a5815ea041356c35', + '0xb38567c314c1a9331bb91745b0068576ebdaa399c1fa3ae746eb778ff88ef687', + '0x445d684a863a49519f536d990347d5947e6fa31c2b2191be1c49b1b2bd3a1d1b', + '0xd7b90d6b9de7cfe29e1d34c304f85a1abd795f0487f61f6b3b5b9ed5c212aa41', + '0xa1fc37acd45c40f1bc006c8d91669c2dc9fe2aff5b01eafd2910e8108f3d33a8', + '0xbc3dd887a69c9206789c12cd9e27236c2cecac8b3660a609a1207763cfc0089a', + '0xb7a27c4016714a1327cff46dbb54ef6e903f3931b54b9c68a6b0e773073cb7dd', + '0x9fbf72d3ce7d5cd8f407d5dbd65390b43f170adf4b6aec3f9329740f98a88bdb', + '0xcd63a65b84db283ccfcbeb114652f8fd069d446f2070202b8083ae5b5241dd45', + '0x1816185a5b64195b52f0fa724f73dd362eb72bb99bf9b401310e512e4f55fbe6', + '0xa5f54a4f0006fc26d8339deaa0ea36c347c0b5bbd8983ad147e48ed1e983885c', + '0x880acc62c41101c5131178620d6009eb6a65d29c1b40268b44e6634c18c24145', + '0xbc62e763426078ca4860822f9d0423d083cb77fff384acd623e77c4dbd32aa93', + '0x223b166edcc12d39357e9ee41564efe36cae401d647ac88c7d94a798a5179fec', + '0x1ea750272df98dac06e59e5fb033147b09a704846c4a48706a075a70860fa8a1', + '0x2eb04e64bea8c22f9ffe4d5512de246f2bde70a9542ec1e2db0ebcc162d06c6b', + '0x92005eda35c886e369b8bbd2452b9af41fc3f40858b840fa30382d2063069d19', + '0x8a7bbe702facd0dcd46acb5145b82785f0d0da59f1cb0420747e6a55cbac43f5', + '0xaa71f949e03a9dcc022b1b9dffafbbda42d15790c8af4f34291e84998d44d59f', + '0x07a94388f0535b2355df0aaf78bf150b86c362fc1927793557dd16df39856640', + '0xf8b916c7d90de8df713e8c3d26847bf4ec1a46feb7be65668b6bf3aa1c182ef3', + '0xd874dbd786b82ce452a2b9628f4c9f78e7f1804a1dc30b5a58d7eaa9345a9706', + '0x9e5b7188c281cc4c80275fe6d2b7390a8bc3de6f71cc65cbf6ab5a5216956e75', + '0x84f4084726ad896c737d9bb7ddb5ab97d4e25937604c391415b9fa8bf56bf4b3', + '0x5c0159ba7392fdad1a236a6a438c1f786870ee8702a2f1aa03ec1e7b4b287b11', + '0x4369001538aea0af15a0445cc61baa2c5f91db709cc6ae68e572b0ae5f61543e', + '0x55c991508bf5a76dc7fb9cf5e19a7b7a0dff4654a1495a1c4af456c88921fbd0', + '0xf1523208e6a6f6b95d89e2c7e8d15f222ef9e620ca04001a013b7a120e09ac91', + '0x50ab76e385c4ad491a49e936b12fc6bfcae60e58b30b5aeafe07cf9f477d4b1f', + '0xd1de3655da56bcbbd576f80e549a6c5580dcf9f3639b8040c294b6343435d86e', + '0x7081a20264908d5944f1fffb88b27ad9b27dacd09e788c6c18d64c02ac113131', + '0xfd786d1c20a090bb983c3fa2851aa7d08c72d6ab2b93fc8417e069130b5d4f3e', + '0x0657fd1c9e522f9db7ee4c39345dc8bcb7e5668e3e5dec61ce36691fae2be9e6', + '0x5118b7737de0e5a8a2ce6c986034725fe515a5c0b940a12ccca3a677bb777711', + '0xaae06bed83833933c78972b5fe0f04f65679a7c64d5480f5622817b5ae5a0fe1', + '0x6795eaeb9264267e89180196ff8b45f8b7772b8db3d78c670b3953dfb0261aa4', + '0xa750855000b79d6d8b026718f843e68cfc44b15be129cb38f518d786f9ce6870', + '0xa6672597d737e7123223180ee440fbfdd472c25633a14e9804f3b1e64f1cc1cd', + '0xcdcdd13a8bcc4598d41c3000c6fde4ea4e691ddee1322ce70137f805e88f6d43', + '0x9b50cd0563eff1a8245d42bc942481adec75745e49b2fc7e72c6e2c21d118199', + '0xb1ad2f18cabfdef0b9a176dcfbb24841cbe35c5411fd859055e9b72b671ab1bc', + '0xe5b1c747eb4f25f049bb2ab22840d86272c95d1cd691a2f2f765a1ce022bbf6b', + '0x5d0a063af7e0c0f7c4a05781602fd08ead4da56ace0b072f05bbeac79461932a', + '0x7af68574ac32a10096342fa1f42216a234b4296ac4e1516f445362e87d6e8d64', + '0xb5da98feb287465c6e1ab8fff301bd9cca43e93a660189f94127e38b4c43dad2', + '0xcf4d2ff970d771ebf62580a87bce5bcd88b436e35d993f8d031d91a20e9bf69c', + '0xd370696e2dac78a1697eb3fe6dc6c89a1b77ca8fe07bdde3dbda117bc60d9b58', + '0x06396a3b0be004ec093204e1e2de3a961eb006069b8933600e880366db32c7f4', + '0xddc325ee0450525864740c7a9e996d16d586d591b0c509d2c8c84357e72e2837', + '0x23070ecb04a71bdc3ef9272c611da71dfd688a213a7e15144e21f148790e6ea1', + '0xf584634834d6bd1a9f3d430b61b3c14c311699d105f762159c8400b09a62ecbd', + '0xa34727c8c242893511f60bbcf864779284a6ea513f6916c05de1be7956bc3bfa', + '0xecc2bcb630d0d261e0ad40db559a114d42f99bcb1eda05d9b3caf29ec55ec748', + '0x50bc14691ca9e2e25915e16d691cf686b4b58cee672e0efe6bd3398fa2321d62', + '0x2e5a7dbc0479e6f5222e4ad69e11636942fcb07218d3ce3c1d8e01f408709fbd', + '0xbcabe1a5f41a71cb422f5e121c9b7972adb942271ab166f77f15d6a71adfb89d', + '0x9c822497087d07d59c4de6479c0af84d62b3308f9c66ab6d00a8e598a0f9f601', + '0xae70369e455e741404df67500960eec4bec609580d88293e4a1060dedb0ae8cc', + '0x5bca2fdde7c8d8a09688681fc23c0c316e6d76b575f2e2c73a809bcdc225d4a2', + '0xca51aaff1f5cd5ad46591a49d542e196a6152245ceb9078a129c38eb2488ef4e', + '0x764c215ea6f8c73d7a9e6f4bf45ece5323cb0bf182f74e6c64f093d79291567f', + '0xc476aad91b49b35cbc0d5ef61d864c9127faac7ff9993909f546f9c94c504494', + '0xa8359d1e7d1bb019b9c7ff863f71943a35ad407c5f2932256f5aa2ccea3fc1e5', + '0x987577a001072e51258ad06fa78c7fb4d456daa3326290ad383b6166b3fef263', + '0x5932a8135faf71bc8194eab1d1e826d8b7bd90d6108b029361407f35abe0129f', + '0x93b40fe8441021f6fc5db8a1463eccdc503a3ce9b2923e53a514a4263b55e345', + '0xe742d7d066cb3d8bdc6957dea1894fa4e4c928e103aa18461646c9a93742411f', + '0x649b4bd5f4d35fc84b3d85a6e33340720b23d79218b8987d9d3a273161eb1ed7', + '0xc95af09762920b32d07adc05ea2dcfc08e7e42822f74b91f5ae9bd73f67f2db5', + '0x8514aed4a46491464d86adeb53d4da5e0874b3b0c56bdf1e8df9e6caacb8bfc3', + '0xa7cf5bc6e5fe5b6bcb34ff61cf65be22496c14bfb364885d7bba6fe6dcfc70e6', + '0xf32d8ae12aefa2f25d230b72c01a5b015cd4202e31d00c65917a88a286540706', + '0x774c3346ca6193c162045d27fa93e1480af18f6971c360fe358f3293bab8d26d', + '0xbffbb583273780af20b99f61177920062fa25389a7682fdce12c7fcc7be31b7f', + '0x08bc3cdb0cf555699cee480604ce490a5ddcec7b132c9dd1d60c90acb9477482', + '0xbdc96b252079ad9eec0fb5ebf72fd9679e751eaa0ee8a3b854f42ed2cfd09618', + '0xdb25fe48c93d8b85e4acd0870ea0aac0ed2c7888e04886760d62a6867612f8d8', + '0x035cff04ab39e9872e61511d5cc9ebbd4da87cfc28910316c855441b5bccc73b', + '0x9a38981f902f69ca224c17aa3f967d954b0fc7c1e576ff0555c04216e806caa7', + '0xe3f282c0f34dbdcdf5257b3b66f14b4d5c0d455fc47b530bae81f68635b3e3d8', + '0xde826de4ed3cbf98f6dda38adc09c9ab52a96119f592ac1d49f7c6df394de258', + '0xa74f8dbf5b996bec1e15d083082d9bfb51315481fd72bf71358903349d57340f', + '0xae6cd9c7a270cb409a5db27109252a37c53a47637e95f23826854e616e52a870', + '0x0322c82755bd4c6803270f541ef5f0ab92f19ab421056629d25702560679acf2', + '0x246c11c206ac1349356e5e2fd0ba517de3d17b21c3071b67773177fda4a2bfe6', + '0x3598c384e8e2f6b930051c3e4ce9a8158cb2a5d5dda05997c44bb8e3732caa1e', + '0xfd7407aab4bcc5be6473007ed5e348245693f10e38d85c6445149c8264b3ea61', + '0x2666c83b368b23f8874bcbaec8b3a5552cb21b753a59078774feb5bb607def7b', + '0x65165a4341b175a7e85052b33cbd44292c4ec481237565821ae10c92d8dc24d7', + '0x90b93b52d0d6f7efb959f57fd72dac8af235f9276530df95e2a0095be27fb8e1', + '0xdd757f99812cb76cd98eb7898be52ad5074001aa9458713b334f36909144f52c', + '0x70910d079da353935d7c22d1f38c023d53dadc37fa77d542afb588d68e5060fa', + '0xc0665e0cb520e96e10e73b2b3977eecd29a6e6acf8aa8172a84ba7daf57bfced', + '0x4f767d8dcd8c115d984ee878b9fd252f94293bbad82337c254eb1c03458c192d', + '0x338d0e5e92db4d9cf7cdfafdd0ed5c2790dce4bf1e6d2087ef97392abdd1aa5f', + '0xb3be5d559a1b9f85241c97587972b7f5202a939ab3e9921e0ccabd3e80a1e44a', + '0x5d384c2112ffbb4e48b6117bfd42659449e9f2cf025e1938513485aefbec33fb', + '0x28a4ccfe42262ab7f144063c638744d82ae3502cf2ece26ad2c349fd84492f9b', + '0x2ec8776ea96b12f64cadbff9a745eb01fb6cf2da2272871a167fb5f44c55dff8', + '0x52dc90f7bf6196ce3c7d7c1b18d58a8e3ca306b8d4a2416b148956c0285cc9c7', + '0xf375b028b9c00139523b4da818a826a36aec9c9b4c82c23f0feca6344509dfb0', + '0xe47f5c0fd50ea2ca81d95b4a7ef06e408df23ed0571576a863b1ace175ee841f', + '0x2e6a6919df3e97995f9f1f5bdf48147f8df1ecb02190a3e12c6bbdc2b9edbd2d', + '0x7705f24351df00e0fb058341fa86c3aac754a2725679863cd0b2cd512e9e0a79', + '0x7fe899fb43066b002b2950f59db15ff8d3051f2006699c3c6c5343294039c9d0', + '0x2f026ba0433a89fb5c19c519218f4fc28a054f0d7639285ae2194ee2fcdc53b7', + '0x555d51bd461cf9490f466cad3055a9d3221209b18479e594f7a9e456a2bedbd2', + '0xad32a923298511d8878051c307a3b054fa3895b1ae5566b251384b6a87d6b5c4', + '0x06bbfe2135d26115d8545854aefcab9c69d0eaee9e2e8ab9e492090badf06665', + '0xcefab5bd4bc710356a96fcf050805c0e8324532a08c1da3ef28d3b9822e1fe97', + '0xaafe9d1f090afb8537e7e25a60846dfe814e58a2b457b30ec5c2c030e87b1b78', + '0x28a5578009170f6ebeb68c66e027da76800a87ba332c0d0db06c229b09cf90b8', + '0x8a5b720af445936e4a1b42653fdbb659213c1a3580a5ee92a09831105fc9d00f', + '0xb110e55649ad87a16675769432c779080d535400ff0867b2c0e2b2cbb1b90449', + '0xd269d87c50fbf802d7848e94f608c7b543b0ed6049e9a4d915f38f1c7b091d0a', + '0xcf212a7c6b3fccac37bdee34f03894842e03bf0e3b4e4b378686241f2681b614', + '0x0adbb0afdb2b6c67f35f01f80a12da46968b5ade0da3695b7691ae6840078012', + '0xe2abfd10350daec9601d79ddef3ac7193e68baefc1b9911fba071713ffe14ea1', + '0x203f6f55c35c47b78f554a3abf5afbaf425688a1160661fe8b92a3535ebdf9b9', + '0xd64c9f20866f995f5746f4e703992f5c884ff4985ceafc370ea89801e380141f', + '0x20b696000c927144eb65689e07a3952ff968622ce653825fe7b7394e1a4e4e44', + '0x9dc2888b213712669a8b2151a4470d6f626f9ffef99653d2a225262ab4005a7f', + '0x8a2d9f3a52c60faea1dffc6493e50aa556f6741e5e699d0913a6723fce70e17a', + '0xa6a271eba297dd99505534cd6ab3475a896011c459defd18740b247bb570cfe0', + '0x67c67d22ea819afdbe41d2793ede49d5db33273540f0eda56ebce280c352698e', + '0xe984c837b893ae68de0d854b554197dc448a1572d2aaa9472de5c01590ab143f', + '0xfc8c2d77a3f1375d304886ebd4cef1d35a4de9d29f26bc131d2da36a334909ce', + '0xf2fa75a1d3e13ed4a5b1da58bb52f8078150feb0e3d0753ec43af6e792956f3f', + '0x1c3af2028778ec1686e7a24d2aa9c676d6d1a581ddb469c52eb1fdb0e99add67', + '0xaf340609676013fdb6a10c683440b451eccb4dc499e72e3135daf566a85b56c3', + '0xfd12f609c7f4a06296348e5d81906fb5ed32f8d07a02d7b672bda49dafffcac3', + '0xa9643f99820035ceb464b26c1a3093bfcf6c7fc022a63820b3e3602596922d26', + '0x5cd61b3083b7f0e4cef6e6104cdbdd2bd5dfe4fd2bdd21a372c6f8eeb9437aaf', + '0xc456158ad965afa71d7b62b857911a8e8a4e8a43e6beea09e384ddf4dd927b53', + '0x00c88df08fac1c02d3a0aeba0c1e70b8f3e8cbb46ec35f0ab18f4d095855186c', + '0x8809edfe1bb0d106b0a173f8c666b70bc90efb747d16bd92a7477fffad763651', + '0x9411f10193fd4b816de214bc314a2b875a9ec7a3bdc63fe3a1c4bcf56954efbd', + '0xaa4ae30d72b454588bc3269582c2d824748c9b55393601b82c2b07d5f89a1e4c', + '0x906a654056823b64a0c10793c5e679fc8efd9edf3b814f43001a993b095d3df9', + '0xf5ac12bff3f7fa2cf08bb0b32480774f5e341790171a16d4bd1a44d82fbf4c53', + '0xe8b3c343ea8a1504698147f40a451f9e150c5e28d7d43e8ad36c53c8430718f4', + '0xb788cf33c1dfe73c2f18ac6cacf5cd50f20e96aef61d798b71d9e8ff456ef69e', + '0xe3eb7bdfbb7efa2fbc056e1af3cbd6beb7523fc51339907a337272c83747103c', + '0x3c3b879ccd07e261fb7fedac668994f217722f4a01a0707267e7771b7a1c6a1e', + '0x086cf931389154fbd733fd6b782b873ab63484aaa36a0a21edb117426b4f45ef', + '0x280008a367aab7f2959b475ca06c3bb3b9245c85ba5bf9ee83193f17e6951eaa', + '0x0428cb0ad810a5b1380597cc4f84e035974891419eb2b558a7aaf67dd8844e59', + '0xc77aeffcf3083f010fd27e08cbe3af1f43aeb81dd13a8da9f81f52cff4c53924', + '0xe0aa2b3b3efdc3a99e1ca7372beed03b824f88d7f003c3b03fffdb1de178a05d', + '0x8a110d0260c6e46a78f6fc4d8622ba4b8c8f5e5f2cac9fecbe29be3345188682', + '0x193796a7054cbf06e0a4262e3e28e4fccf6a21a8b80075e4e692a3c8b36279e0', + '0x47bad9bd998731ef3c559dc63f95bd8f1c12d53d870d874982091ef1e80753cb', + '0xf7ee397971146aaaadc2575a10b5e978231e4457b70484ed0a12ba17763d142d', + '0x7d29ab857ea0d3ce32331073a1becf5e426dc03e9db480b9c9aa4ce3ba5529db', + '0x2747080aaa140c3d18d0351e922ed20ab63ebdbadf44026b4f69469c83c35ab8', + '0x10bca0dc250f16a175790e1b8307c991411d351532ed035539e34ad13f449445', + '0x7398f4dd903a5be0e1117c2433abd8477cc6e14a15d36c332722ee605d70d587', + '0x69f6cba9436e4f2f48129ec1a57325f46a8cb9c13ca6e81f7a29b667e1f700eb', + '0x7b15e945ed2226b486fa3da81090aa4f6e7296dbd799586643ff6437058bb7c1', + '0xd08e715819a94aa24e993e25151969b7c8f1f4e38fd35c30f9e31a30316941c4', + '0x09aab042b10398f4a3bb1bfd7b6b16567dd7bcd0e671a9a371cd59dd0ba704d9', + '0xb5db8739a0a6e97cb453d109e27f943353ccc865a916195e38cb25d60ecd8c61', + '0xfc7972d7d82c3dca25737b094b286e151548c5704122e7cb52ac45351ffc3cec', + '0xf7527d3be1a9354334e1264e530a2783fbc8daa92571b680636f9e50afde54d6', + '0x856a53df758186e954e657713793c8806561fb9e1033ead49639a19e16263342', + '0x052b227e1db5adce825e75c44b65a037d0a68d22f14273aa3178ee2b9bd9838a', + '0xcc7ed47dd46322ba4dd61f5c34b29a6932486d53a4a3f375ebb71ca54c2979dd', + '0xe962a94241620a73bd6557227002e22b2a20ed7ffbfd5ba0eb725c78e64b58a4', + '0xd4a3ab722b0c05cad0f4c4604cff9f35ea59cf8ed335777454af95f13a74f60e', + '0xe88592652967bc455de4294249ed76d427de85d01d64e001bd6990ad3620663b', + '0x48bc00f261aea636e95e8aea1d286d3b65cd5751b551ef8cf46a8bd3f8501667', + '0xf7e7e46654be9066c538cf7606c53f5fb0f5d7db9a8cc574af44709d53c10423', + '0x6b2e6b8222d126330c72dcc72a7b1866f9eede2809b79cf584527b4c81a301c5', + '0xf1cc2395a67a1ccf59ab0714eb66f4d707706a6613301acc43b505389d82f7ee', + '0x9f0eb633305494f773a558bd65f4f4b64e4564c8d81edd8bc6d738a29fd30c2e', + '0xdb08cc24e61b99da4ab7a510ea73b541cb48be6b48faeb0d4e0b7695dc9e0721', + '0xa68bba1ca522b9b05b191dea0e10340f1d48295a80c805c27200615962b4f1e7', + '0x19eccf6dcaf0eece2d597a4cb80bb4a21e5dff7c3ba47170ed225003a3816e89', + '0xe52217d4c4c1e164e262524fb35ef5b55664493e1dd57f6126234fe3f0cc1745', + '0x0d0e96c57eafbd95991101135eacc42cd1730e15e2184d24cd98edb78e382825', + '0x32035926d5ce8f7bd1f4a99d52d3c2587ded1194316caf9d1bf5d1c5b61e0bb8', + '0x9accdc963000adb5d008ee83c78c57a589003c7bef8cfc8df117e4ad83311740', + '0x7f4b91435297e8696cdd0f47cc9964edd7361d1b046658a9e439f7c42aacb248', + '0x725762297de1229711061522e58feb79ad2640f8ed04c1b46d08de75915df6de', + '0x9cea6085ee816562d64b44603162be193307b42708a4ff73f478106dc628bfd8', + '0xe272476bde4fdbb5ecc4b63844232e2fdd1fc47cd823780befb8aacf9223b56b', + '0x5f24f11ac42d09e4c36226052e96926250fb39af0f1a56285e4128b21203f7cc', + '0x3a9cefc0b74164bf5b86cc02ba3140116a86fcf5a801b11d732448c32903bb60', + '0x0bcd8f04122d9da6a2af177cef285048f67427e3d270b3f6b9727c1ccbd199c1', + '0xd8388989574a90579a16191f20409f823497808d3ff4aef1c8883fa95657f33b', + '0x814c6241e2ce1fca2aaa19b219421c9b0a42506204b06e790c65a730d7990758', + '0x05158805c28d50b451183ce02d0e648bf201cc23d249c7d018539c120c1f4d73', + '0x0574723db18d038f4e7a72af88bf78c015f349282e40d30b8d4706839415b246', + '0xb0f1d693f1b69649f0a09076d25a3ea75ec74d3cc8e2c1a1e9d7a2f4cabfac02', + '0x429e98b4b32321a40ffcf08ca0cfb18a5ce1b3e7e4f087f31aca3428c8e38e2a', + '0x06971a89dd3959947803403bee6a99950aecad0b1af4267c677db1b6aa9a034c', + '0xc4e75b9827971660d218e37f826ff1c9af300fd3fc04f05da701cbd319e0579f', + '0x3c2937c6efcba2e17ebe53197f34faca9561e89fbcf80fe710eaf9f00b3af2d5', + '0x09c10e10a865bbf7f7f77036f659d567eb03aa359cb3bd4c847e9386c1baaef5', + '0xba3524c519f6ac5ec90810d995e6115c47bff05cc31144ca04885eba8323f29f', + '0xc1e6facd3624368e16f8a4c124c5642f32aa39f6659397f352e72f3a873dea5f', + '0xef16b6e9f58ab9ce8448a31db347aef47abdd913aff9be134e858d33697f3ac5', + '0xcfd4bb8b7ed1e1c61553f49f5d818a0589e37b720aad8cb227fa615bc1c1b462', + '0xac241ca93c28e2cde02c3e0202bb970fc9b33e79ca49d1b723e1b69dfc001c69', + '0x09bcf479b6cfb96de663c8f0675912868bd81b685944432a9e173c606021ec64', + '0xae67c6d1b4fb378126b565a30a309b6d4e3889e65066cfbc36b278c9e77f062b', + '0xb9026abcc1cbd57615e05c4e4480a74fd6a0715d9a6ca781ad6e40e8a2b120ce', + '0x2f321a6e6560e052c3d6853f5e43446ed38e374b764d49f8715426762bf30a74', + '0x477966b45a4de0160b4d40c71f21c1d255afaab4a9dc182d8bf02e439e0e4292', + '0x6e69e6abea103589873cb985f3f0a46b3c3722e707bd44503edbfa92c023359e', + '0x57fa0fb9ed02f6639e39f8cd22d9bb40f9979b503bf2a934abe138f522e731a1', + '0xd12c73a5f8d32e85a46547ce7f6e7b9a4edd49f8623445710a54c38e47de77bd', + '0xa072dd42158364d80191fa0dcbcaf4a8b60644002f6d36eb90c0f47d726781a2', + '0x4d48f6750b38682d032055adbbe8bc776b075fef3759f74cadfefc43c8fa1fce', + '0xd340958601188c379b674e10b1fdc5eb4f80091d980bf9949ace31da7c09b97f', + '0xef91fc8b2eb06b2cc48f0b1b5bbe852aa403b86a6ce328a0649cbb50bf725987', + '0x3d9ee5cf464f2cfcb0bbf577f68e6687095834f8e30e3d2bd2d5d65493058d6a', + '0xc839fa7e818d90429a5466011a1f9143a31386d202dea3c04f33dd74d6af5a86', + '0x1068e479ca764893cc04758f5046f24285b2f197ba66e8abd148dc768e8bf7cb', + '0x4ecdb6bacc8de656c7324fbc4c0bcfceb68cc2af051c0039153d3c2859d3e536', + '0xb45f881916b9ab619d95f5445fbe0d967289d62816e43511cf64e3efea40e100', + '0xa83bfc3c79660b75ab5da1a73491077e889d6a2e409e845a43b1d73adc4a54fd', + '0xdac4afa56053527a664d7dc3ac911dcc257599cb14a8365a4b101c98e9989054', + '0x44e6d8a3046f2d5ab0eef4b7e9042973393ad731f9af7d2b6643f7c2e73a4551', + '0xedaddf0d415f2cf2fdf0d8ea7427fdc8f424df7aba5e400591ce09f9fb7e0e99', + '0xeaac1fba4f6fec3a2bb34a05f4222856f1f45566a0065075edba6c80b129494e', + '0x36c087c869e67167931d701c531aa36438e216c85519e69aad91313f77420608', + '0x5470bc2ebfacf5e247c05c73ed3a7e92f8668f946d6b20b47fe48e5643eb2e5d', + '0xd7f04c73fffe2f2b85f2a2f0939c638558dee6dad365798c481aed71ba4cfa5d', + '0x119de40139cb5ecc7f4735a5b4eb1f8855d1d08db54bd1583a16719eca2fddf4', + '0x5931a37f0a3e2e8e8faff34d5810ecfffb5b038f9fef5a942706723046fe5060', + '0x19b625cff7ae6fbd120bdf8702747aef144ecffc1e4f8788ecfa47c527bd9ea2', + '0x9accb92b1cadc8592f4c8dd7061b68ae27fa742a0679a49c42d2e68af9daa89f', + '0xd018632c946f26729f624162690c553dc38f03c772a8863d1c4b7d123eaa6950', + '0x6f223f98880538b2af75e4754a871e251f6a1ffe41fcd61346e58d3405239812', + '0x6e7c5c56bbf1cafc5d8e2242249c16a25fa725fc9e6f740135d3bb798f833b4d', + '0x9f3897eff0f8e01ca18187493bfeab1793b6f78a9c08af16e4ca98b9f0894710', + '0x32556cb742c4ca617d8eca345bb70ff0bfee095200893c1cd6d9478b2d4dd0ad', + '0xa9f03e79a7cd6e1b5c898fe2ecfb6cb5f5c1d05bd3a5ec0c6bcd83da66fe3c65', + '0x7a11513b5ee188b5c1adb59bec07153a6e1d3b323cfe823c8f6b39fdc85ddf24', + '0x9706adc411f056b1b4693b139bdb149cebe0bafd3e4896f8c5c56a620f6b5d3a', + '0x044bba94a72055cf10576c7c695c9c7d3305a28d545f03a9ba29d819730b1493', + '0x0551fd903c7b64e061041f3054f371c5df2157773bb1b6ddb4c247a8d6afcfe8', + '0xa00c4205dcb2e5c4dfb6b20fc81d6b0f4ec2e1d79598100d0d5f9dfd6a4dbaf3', + '0x82a944140e659ac2a06b5861a9257672ba2067f23bfe0ec4c5bebc0cd81f1f6b', + '0x2cf0c38c0a45f1ba5a6d677744747701d0d07e49dcf24e56dbfde92bfef68441', + '0xb28146d02fed330c74aaf8f9d4d182d9260de7971f33ecbf4a7c1a094d800886', + '0xa7aaa83cd091152a6b626493b74b873411453e8ef277445ba8d46862fac667f1', + '0xa99ac56d54ddf28c30ae3ef129369246cb2bae89fc54d943611e560ba63af475', + '0x1a362ca5ae721ffd33ad5d1c2494d95137d46f38e66d5de5b16786f5a2c2a664', + '0xbcce30923a817b2fd17200ba8a9a32d88a1ac55b64524dd2738e2799b60b7649', + '0x74f3691caddb42105cc7ffdfd79d506b2c73a7997647d0f377209f7f57a89ed6', + '0x0792576ff6a75321ab3448b9b6fbb85fcc2b53a0f797e5a5ea0a5ab79cba4806', + '0x55a8e154093a8140c9f01bc33b4e0ad8c497fdadcbdda464c261a4862fe3218c', + '0x44ab518257f27d6c9fbfd5dd8f127d335a9830dfcb84592f2c8efec709b9b2ef', + '0xd6216d02ac29224917cdc34e51eade6d00060c9ca073199ff1c42e29f99285b7', + '0x8fb9a01c83eba3f174910c330aadff7ff7b2e26a8c1bbbebe04489acf4913240', + '0xfc19372d1349fee34a5db2063f4618ef4ec3de1242eeba2d79153297d0449366', + '0x58f853d88f8f3fb250c9b8083002ee75b527c528cb9fa4fd11d07108c9720d7c', + '0xa85722e5bb4bf4e42dc47013b0c0837f6705830507207849a3624afd7f9f1832', + '0xac28a06292aad999f9c9de082e6b3c5c94bb9453fadfd03bbed57fb7ddd36aba', + '0xef385aa4da8085065941802f853c30de86bda7c0751c544e50163b9af744b562', + '0x5ab408f4c17762892004285fdb2095e2d97b50661fa770d0ba92bee534c92924', + '0x73bf6c09fa9f71001139184853eee6695e0b8660b4385cf44546669ac40ddb2b', + '0xbf08e2850e0800f24241167af75556b3de37552c6e812f6f88065eb248286386', + '0xdebfbf5d933deb047f9bb23f7f3f6984fa71c0310a0f6e93f25b0216c59bb126', + '0xbdb45a48f1badccb10496d9b1fd89cf2a5f127ba7fb51572d6d893202b0d22f9', + '0x41a0384444b998a2b067c0dec4d8b163649d2a73b28f3c87640aed1c027716e5', + '0x2bd541194105a0652287f4e5ab9896a3dbc95aeb9eae3a2604f8142385b6a7dc', + '0x58c0a2ee23f506f3cfe911009a316775037d68984da8256947d3611ed7429050', + '0x799f185a61133549f988bb545467d84f28afe144f8f867eec10dc09072dbaf03', + '0xaecc5d9770d2403e38b5dfcd646c434962fc8f35bf7d3a5cd721c688c92890aa', + '0xa34b345b4e8e424c14dccda06e2c303d2db72a4bdea434f794626767a4cdb1d3', + '0x3fb8ba04fc57038208eeae7b302a586319a29020f4daa6f27c61c2ff34e70b45', + '0x3a8bb258775e0370e7ecc30515106790b9dc47412bc2ce6c7961e79821832ce0', + '0x4149b4e2525af0ffd0c7d30e2676838353591abf011721158d78824a8d212964', + '0x0a5ec8da07f59df12f678a0259d165aa7c577e176eff9e4e3e7af104ea38b361', + '0xe01335e25f32678d411109e8c5d166bb9ac05f333987eda9a22b078f6cd79494', + '0xda1b062ab2f0746636af3e9ccffe8d87784835b99d1b7603d26e9f512470f50c', + '0xbf9b14b37570148cff331b946c402534dac5c2870b42b4e78b29787c659f1e01', + '0xb608e097e82ad20f0a0d2666b267ea467a7b2216b86bb50eea145ab4e7536426', + '0x578e3074e4dde36f81bf4f6ea524a5cf00dd20c2091463479d41ceea2096dec0', + '0xbad1c6ce85e7695cf7d6cd1c8b2e65a83cfe0adf37e9052559b4905c537a245d', + '0xe1a32fddb412ddea95850ad652cf644e137eede1d08a96ef10c1a26db8b6e22b', + '0x3ab89590df7e198ea14f96e71ad82eb50676c5d920a7c400f8dd957a554506a8', + '0x6dd0b06d8b9d6f89a78da4e10588b66eb0c28e8daf2b915c1d5ba15650b94a2e', + '0x8eb0ec04f2a0f8045cd987ab4aeb87c8ce3c2ec40042e385e36b92eefd7abf06', + '0x3e8a4061daf3d2496f5b2ceb7d05e2a12b327071f3ad28129e0dcdbec69a426f', + '0x177b7d7f9a2a0f9c448b135a205e7b688a430c88b333ba3300630473bdc1fcb9', + '0xfd8260a6cddb08ef3def644fd9d7eccd3805d0b91372745f28eff6b7cbb70483', + '0x030bb3bad5c6f66066a1ade5c5af0da7e74e2397757e407a3672160c6b511a12', + '0xda8f4909203991c657b95d1f5d2488a80844c2ef9d62127248bcf5a4ecc06545', + '0xe4261c194ba8eb412642f8f64c33d49de8c24bc54146a3c8f0585309a4d0c778', + '0xd7a1e40b425353d103b460c04c0b07367de146194ae04aa53b572a652824bbb5', + '0x7524d77df781970f29f481df2a2ee575205c7c7a6b336b9aaf8d2d04afefd4f4', + '0x89aadb2bc85a8ae14c309c8ccd8a00180d3d6244b03884fc7b983b164a109cab', + '0x4c56148f8c21bedeeb20f04a6af31945ff68290cc8d55ea86960f3de9faf09ea', + '0xf7fe0b7f4d8932de565e1742bbc5ad1496f32a6ec075837e3faa07bccdcd2479', + '0x2c479667bebdc88b8f349d3a3db7700a899170abb9155f4d526bc3b6521b32f3', + '0xf77a6ab9e4882f4be2b171e98c80835cb188b67425d1fc3ce73ad84375ad97f4', + '0x24b3c86cecb45ad7fc9bca0dcddba7cf41f6a3da7d64bfb85401f412ed4629d6', + '0xee231d36c90f6dabcd1ca51472ae392fb93918e4ce7394f4888a02f6850c6166', + '0x6ba4c80ab2b70146b6a5997b4922be4e83c5e028b0eb2dd431646754fb764b3d', + '0xa6f71d865e8d47fb804da2c3f2a42a8e6f517adab6ade593e2298c145603fc81', + '0x7369e84f16bd0afe51dc906a93fc5ab58b969d622929492b63c54a51d6eef36f', + '0x96a35de5c5ed9c7e2874d67d8717e90eddd65bef90250f79d890539e515e55de', + '0x3a5bd62ff9a0a0ec40f09d0020420978560472ff9ce83f409ed12ff10fa19a21', + '0x6d5c2be9fa4b0ba05ab8ed6b1921b29cd0f1882766af99d71e25c08320c54305', + '0x29d90a8a5ceafc749e99641cb88f7c1f83b9c7f95d0a0b64b474a1823d6ace07', + '0x36a8fbb1fb6baeb38b5606d3b2749ebadbe471927001b3e7d8256d073dc9b9f4', + '0xe31cee3cd95a961a7c7c71934efbf4c194de0a6789e56314865e4d181bc8d0ab', + '0xbf9377752ad013e8e5edab9ef391302eb7bda09ec84986ca27d41cedbb87d5ab', + '0x79c4f4d27994953ff8a57859f0dfdfacb092fef2e8d12fac3c4797b28084298f', + '0x22502748767ae69add877804f8c0b3b6284c381708ae2297a02b615b2487a6ce', + '0xf1c33ded14735e52d535858699949aad648c14abde589b53249e22483cd09279', + '0xc33e968c21ef92e26eb5a6014eca85b99bf8a57e1d6ee0a6d1ad4ce43b7c062d', + '0x3553e67b438920909537930fb72a22e1bc89b98ec1e90458fa544ba468534c15', + '0x0242bf1904dc28b2e785a6da65971a02fe23d91aee379d56cafdce4b9a8fea28', + '0xda2717d3e4c6f4465d55972a9641abbefb8963aab871582429c5fb8510042210', + '0xdafde42a0d5d24c04a44bc61da1f1ba97d41fad7207d3e2a280fd1f66cc61731', + '0x437d9c644114b1d9d6a7909e4fe92198b25697da177ec9d66517a07a82899e07', + '0xdc46ee3a5fbcacda94f6d134125860d53c8f91b34e2056bcb11ec92571761c6f', + '0xa629243ce5a3e2ba64b7b528acae3eac6ea3a0ff3b128d0a22f8975ce66dd410', + '0xe5cf7cd5da89a69aa9bba29019181b252f63d24bd71e310217339a0aaba1cca4', + '0x764d42f689832afc94d3a09ffcf19f0ca61b5fcfbf5444cd22574bfbb34eceeb', + '0x4b7d6d36cdce3f71d5f6e1be747adc31051539435bc60a22c6745e6b92efb9bc', + '0xfc1949a19004999e3d2dff0c2d40273f66fcb12252f62541978d70cb17d337d8', + '0x48f18510848adb85520f4d9556b2929043827037c229b752ce0fef1c085694d9', + '0x8f462ad4ab82152cb0ee0931e30835414c240ae5294c9a8ba219abc3c096a634', + '0x51ce6119a4537d645720c22cbf90a24b7297f564da95587af5c7b1a35299e1e5', + '0xa9eabc7aa5533b33a838aad2aeabf33efaa0cfc75d112316c30245a3ddc5dba1', + '0x45fc89947a696ca7d2fd7ff71cea6a1df18c9dc2f03f8eda245ce8ab7a35658a', + '0x2eb9a7ca384c0344f598fa90cacf3265ad1bec108241b58249252912e0408986', + '0x80b864e948b5bbc7e71e3d662bc3038bc37e6dca21c3777c1c511bbd6165181e', + '0xc07e39f09dded2e0caa1e8eabab200f98a43fd02ebe1ef3593c27875c53092fa', + '0xc58ed564835acfba5cdb526dc4773bff6c9ead95f9b7aa1760c46767f597fcc7', + '0x6f2e64d8c7a00d82721689f0621b806ef96a76293c42e0afaccdec5092bd2dd4', + '0xdf2560107d05033cd20145db53c9bd9cd38450d8c680b8f41ff4b03aa7acd49a', + '0xe13cb45fdca4db232c5c49dbec0557998870c1726c433e340e7e5c8a32f7d374', + '0x031296bcb01da02399e37cc381f87b14f1c6678273b5e5557315efa1e04753a7', + '0xca3b3f46079b8debbbcea54d38fd8b481671b1699fe6142d37852592c1c6db64', + '0x1bc7545eabd83cf0f59b848a2adf269bd13eb70e9ede9a38272ea89b1446be99', + '0xd0aee813335ddd258d591ebfe252d39d87be23d079aa37e80edbc1ee00e4eb0e', + '0x3d101ef1220b9fe9b5692e2fa67bcd5b578291481a598096f1fa99dc277755f2', + '0x665ad0fe4e94ea2d254be7e965adecb5d5b2dc170b37c8f74df7d6749d13fde0', + '0x627235f924b2d61d6c4eb9b4b0c48abdabce092f0cff368e2a3873096a5cd6ae', + '0xe960b6d5610e23d0585c6ddc515b9f6b9cd95337d9279a5821b60499edfdf1d7', + '0x39dbbb4c54dc114bf7f6dd1534c76359c8c33c925b37980f9d3a8b232aecf0d0', + '0x93306011c95061bbeb11de9e72d7add21b78e41b143d8136d63088c5673e15bf', + '0x4bb5a127686a32bccb354ed4b029e525ddcdb00a5eb5561285e19c8f3102f753', + '0xa00d35e61c5fb33333b9c7404a09c71d01694a9309a8b862ade4ecf55ed87f49', + '0x3bd582c7e75816329613a71c930af381be11761ba36d0e4e8251c5a0c4fe32a2', + '0xe8e1dab5cbbaf4000f4b203fe99145f61909d5feea32ab9cba1e300bae7fa99f', + '0x78dd130d7b6e0ff5b5a9bb11ca57dfaea7c6d11c13a54537592ac9d0ee1e1013', + '0x88e68d58e3f5917bb9592c1a7b89c9852361f9ffa0854dd0119775e4be8a739f', + '0x9e2b6c91c28d1b3f826da6936e9b38a90e82e17d9a4d31734a98a8e98b4a0d95', + '0xf2231dc224d9eb57f72eb7b198ab8c5be14a052723d64b91bd86f412b7fddc1f', + '0x62c5a27756665f34f3f88ee0c9d63866ce471a13e3dcf3a759db0dce9fdf143f', + '0x5b5d51ba64f42e6d60195e9cbe51363659cd84baf9865bbd7f027ab65f575c5e', + '0xaeeb5b59aa84f3c66e875809d24b62aad131d1013ac50b3fa89606f2473addee', + '0xca512c0cfbd93ed5c31aa09af1f2cf5edf075a90a5e058726f558d683d6d4ab1', + '0x9a6b45605c4625c8e5aca57e556890682944954ed1e4baf9293ce61a69df14c0', + '0x3c8a0d1742b4b49fe9e91256db8664ccc7e92f8e6d1ddc7c1b5ff6103f8cd9ac', + '0xe5d635d341fdb6dbec17afb5f0cca7a77e13e8430dd59a04aa4105bccde17cec', + '0xa5da3b4e409f69a7156258291da7952dbb9ba73231ee5158f51dee3446381e1f', + '0x6765b2c49263bad9a72866379f6da1942739f39a5fb70bb0b85c76c64443bc81', + '0xcc70556cc125837c2b60cb6109f0223c081240a070de9f70b203a81a56149877', + '0xd8d1634f52badd619adf824b5e9ecad0f152b6fde514fd30cd4a12ebf2c77344', + '0x4303a5e776df08cf2caf1cf001e809fa406abb8bd4e30efe315bb2ea9d75a590', + '0x31b5d1700b3ae3972685dfc6e85f0ae2ca5cafe451116aaa052fb8b23483ecec', + '0xa25f01d667d135d49263718f469691cb4030e85a065990cb0aa407a045447779', + '0x5ef4dfedc64cca17daf38cb0c34e38b02557672309f7136c0b75f2d6de3240c6', + '0x8461f9771a2affcf8b70fe6738c129b953ebc450329d49becc2de0ca4b23f750', + '0x9d8fbf1a43e3d59eb4bb99598b47bfdb81ef25dfe6c5d80cc44092345f338c11', + '0x434acecdd544aaf22dcad1f21bc60386e433187c71fa3e0381c32abab77186bb', + '0x2a77c426c095a341419146189f4deb2d74d15381f35df93e1f8448639ef18f03', + '0x8746be7d7de5e656a381d16b8a1cb0c2f45bcd514a02ac80884e1af6e91d5246', + '0x5edc07b6d5b5a57ac0faec9cb283954189565dce02c26ad08211d9ffa56c84bb', + '0x792b3dcc3094f7405ec61da02f977c2b2b5194bdfa62907a277923c855c2caca', + '0x19620712734dc5664fff560537d6f6df0a7a43cc8532bb396b1a2c74edc55cde', + '0x4158e643cebe417933e93ffb349c059c98438bddb71179055ef1e49ef4897edb', + '0xb980d5406ed9105c602c89c73f3d2de224ea6041240428135adfe5e1465e398e', + '0xf96a354b6f3ca498b8f25bf3c76bcfbbae78c051ad6596882811e4053406316c', + '0x3b2c58a1b3bb93c17331f9172b9f2b494d0c5d35b594d30323807aef5efd9d33', + '0xa12a581db2f673a7dce27ea514a182898e0329235a44387843a1e98dfac83a6a', + '0xc81db8366061da9c83c6522e6f57e450bc3136f72bb3cde175956cac5d3c5988', + '0xf8b4e0c0ad46c9c8bbe34d198faee7c37f6c8b4635ed7db14cb9084db85c5f3e', + '0x49fde0b19ce6eb03e3d592f8a599aed33ab3ae5811834af37bfe99ab9476d1ff', + '0x18555b454eb9e11538fc14049fcdb69445c5bdf25d350b4cc73b8418c89488a2', + '0x5a95135d540460d78e60234e4ea4a3fbfe0dc6530ed20bb6551f391c58364e26', + '0x83f3f224e2c77b457ceae501bb237727141feb03ecb34789a3decffa667b8f8e', + '0x53942c6b953f3903b1bae1e55131e2b9f409e2e900a919f4baee384471c5fb60', + '0x6b75634d1f134e27f87cf38db4442c5c19a037e00c1c687e125aed9e7f7a2467', + '0x0054e0f24689fe8758a821a00a05653acac76a92d3542d3f6fa3e01a7fd3d399', + '0x6c08147c1a4a4be7b8e689721589287b05cf161c62607e9c6bd91bf3c8cb249a', + '0x16276cc50334e3d3ede3d83282450ea5706de229f3a910d78b28e1d32fcfeba9', + '0x3a6084a56efe718c411f1cad1d19e9d477eda8091321f64112465e5164790279', + '0x07f1c9d7ee59bb31df91ebe5f5cb89284e23117b6e5d3648095b695cb77d92aa', + '0x3a177ce6a9854aca88e7f8fb3f3241f2fed96a8e3dfc58e48058e62e6fbf4dd5', + '0x3732818df6f504e949ac4111daebdf66df47d2dca4fcad53c3aa5d3828249d37', + '0x995ad10e9ff9e2c06064b2918d0f279981e4e6e60fc2a44062e290fdf6792b33', + '0xeb31b019502f21d165d30e95a3197a7bbbebb34780fbd165523e273500c470e1', + '0x4e82240bba88f57d595f0015cbf7b47c46a3f3976535e5638870763e7e469948', + '0xc05545c94df8c7b04e6bef4823a7d433691158dcb96924410a28c7deb3dd0708', + '0xa915c9bb26a053325d3aac7b238ce91706eb25bfe3dcfa38317534a32759dc22', + '0xbd9546a4b8104aa31f0cb13ca5c5908853f8dbf234eeac1ff046c8961dee9e98', + '0x4a26911c649c3de55373c48c9a48a848ac9fba67ebdffd307e45f991ddf40e6e', + '0xc5906a5edbc4d7e86199c5525600e939406b6f594b1020656f039fe0d8fc7f9c', + '0xdec58969142c3b766808ee5e47de94ec2bcbff37da8a06d971e20795006bc252', + '0x3a3b39dc3b38739e5571fa44c5280698458facc98a28bc96188e7c35e91a1a38', + '0x3aa93ba61124dcf6bd19c16c61b7c62711ce5955f001bbc67fe75a93e092e35e', + '0x067007537b503c3414f9f8e328a1b7353110b14861e374c6d42e432e5b5472a6', + '0x0f8c5a40023026f8e7a58c9d59ccc40e1a745c8732edcf1d5e2e1abaff7aca4e', + '0xe2761e88f38d536a7473ecf27998874f5857e44f3c06becf7747fea09216d5e7', + '0x1654b70c6582eb553f81f6c8d3917c0cf2bc6a1e06dde3ae64d966a58e13bce6', + '0xb5089766b015ca66263695ae50e705ee917ff93c8799a3db8338cb12ff84a3da', + '0xce001c26dc604b83a240fb194ca3435bf4b4825a31bba5183f1bfb77492003b9', + '0x50a9da4d14d8f97c6a166c5e0e256ecfed81a4afc005f67dffb52f85d3f528f3', + '0x967c021c09e23dd777a250f167518ebebd232ea2e2a63173075f631563a44978', + '0xa3f8fe7cab9ebf3bbf6711704f65afaec9cb75744bd098770a70835a6ba5eed8', + '0x44741405982c9a87d3758930b4b51a649700ceccfce0ff68642f810e280c3392', + '0xbc4777bdce57f572db079910be82cbe6cbb8a751c2f1eaf3ce8406678919f107', + '0x473325dd009f7ce64169c7b4d220dd473723f404a549ea2ea1fb19d540815a94', + '0xda841a8bf1d8505c585d045aff3c0f2e8bed6af0933bb820395c20942bbd30bf', + '0x7da907b1f5413c4a1ddaa76b4f52319f30978211002d26435eb042ba76f1276f', + '0x702053a4f7d00b5347d9e51fe2848fd1925d49c6ad85a06053ecac15dac6cdbe', + '0x9686c57251c11661439d2e6c1c8cbc07b043a1f82e0eff4fc02e191a7b62653a', + '0x224efd67d481074c83f05955c0a956b8f73de135b1b125d0d75b655cd94ff48b', + '0x9ee1cce0ad17812f92d4dbc5f6706514a80ab55d572d2dc26a3d3b052bfd8e46', + '0x5f808f1dea6a5c868b7d1351b443a6bbfdccdd507793fb95b1cee0f8e8f9aaa5', + '0xd2f672257722f5e42961663056c7b340430c37bf868fc440e81ff4594b8d4ea2', + '0x721f30048d29db878f3f43ae8ab5a67bfd5026e232a59d425d504a8f2b3ba8e1', + '0x75803567400aab20282d7c35c73f45716c526331a77cb49b77f5841e5d4e3e70', + '0x1e7a9e0492fe9a198047f02699eef05cb19641e9eaa0e95dd9206a91094ab12b', + '0xf3ae4b5bffbc8cb58315385d6f0fdc2d133a5bbc1676b9c9d19baf584f76e775', + '0x3d52c0c958ff238520aad6d62f44b45d4adb87db030e7df0068fddeb16b9f876', + '0x3033ef7c1df0025319d82fe1dbcf8ca2f2588e4f7a2fad524ad488f623e022ea', + '0x242dc765560a67f4e4044e08178e753d1902fb704ac7f1915d669868851446fa', + '0xaab360be2560e4fd5dd5e4e1e028c5c9bb6f7ac7c5acb01e0d022316c5eff493', + '0xaffebb9305c90e982801d9b90d15d88bdde28de49d9a9911d248255faf1702c9', + '0xa7fce71f799dffba8957d5be1908b598cad9edcc277b24a2dd79ee91425d20e1', + '0x637421d6411b1d2aaf7a5197b5ade4beec6b5beaf87bdfb15012133e3c5f1755', + '0x208fc72529cccdf41694ee6557c16f2b42b369f9cc9593504c0c30304a4c44d9', + '0x49f90e5f89f5e138e3257e76b3491d56d8c870952c2951f55e75ac6515a1034f', + '0x6a5fde5a6dd419c478ab08d090e29d6500ec00b7f966ce48f882d4784d7e4aba', + '0x3cfd5a8169d1b70d689aae73c32f632f77032cc1349850e720da703000a70106', + '0x8218739d7faf35439a3c424cdbd57566316c4cf18bad6fcb229cfcf2f0d280fe', + '0x11f998cc78b0a11eedec5c2721f719e5c4fa444a48deb43802bd80cf9e7ca1b6', + '0x378baca4e2d3b1772db988f4db5ef269535fd1134f606f35a98ad4a46ca2ecea', + '0xea2606990ac2f8d863ff49e9d58e25a158ef09bf6fe84bc9e3690d79c4550023', + '0x5412f1267dd06eea95f5d5917613c6e610870c2c65879ee2609eb13129448324', + '0x51696ea2dcbdb4de89f0db26c015c7e3c991d511faab126c5221177109f893fd', + '0x0415dde0c24a77334ad9c29821c3de0fe5859c61f09f413cc00f4212b10b4419', + '0xe2bb9751cb41fcc05ba942444073c341538701dc0ec1c233eb41b7b01d7bb1b0', + '0x87c44b99ed51fbdb22f0d529aeb45bcd40feac801811f5a73417f580dfa599f9', + '0x9400167d781eb70192983a003af8eda27c7abcf98e26d1999baf0b113717b031', + '0x07fab0635e408994ab7cbef0fd59a5a1f7c4dcfc11e04b469db040be565089e9', + '0x21129a4585401f3bbe29461e31493e403dcb1dc4b0f3d1616b30f96ed50dc072', + '0x76a682a8e2e55751de91635b6ae4aa16edcf5c92d9bff8ba38074bfe579f3d1b', + '0xce610adb504b9669f221ee951d7125c35a98955e935e8e8189aa097b4332fc74', + '0xc1da492066f1911ec123dcc3b90fd8742ae80629a8d2be1f071ee439df0fad36', + '0x0aeb5abc5ac25e53b4ce1acd8ee31944a671c0bf999788dfd66026bfd824c35c', + '0x0ddd552f60d99917bdb295310a07b18d4979b63be40babcc3882c509b89fcad5', + '0xe9fc62fde021320a620ea3cc229f6d81d7ef3bbdfac638e6a7fa5a9efa53f7fd', + '0x115d56f83d033d0342ad29c5e611f0aaf0fda946ea8df88557c54cc4c54d596d', + '0xe57a719daff2a6ad5cfa3cbcfbd6fa2c240451f03e31606bc1f447dc2e05a7c1', + '0x28edc89d848fb380f2d77ac24a5d51a3ffadbac384750c493dae7900d0408fa6', + '0x9d45ead11495b1dc370afa9437a9ec8cf04c668dbca71381cb0c17d8d413b7e3', + '0xa97f3acff860ff73cec3488946117cb6e7ff1582c715eab2b2ac784e76881007', + '0x188a67b979160d3c690aa44b2a9c61f3749866fa446a8eb08c5efad429328104', + '0xd5a127ff4828f9409819ef9d3c68ddb2542a86cb5e4b26ecb1ee3175f957f056', + '0xb1801e611dd2595d7eea1460a1987696af1acfcbe595e4e49683c02265360596', + '0xa2973d1088e1aeb252a119312149e165105eb2613da1373c2094bcfa84307cb7', + '0xdd9505703e13a93c326c1bcb985df44681f45edb36a28f2778a8dd19d3be4862', + '0x9d659b3d8cbb302a03407abc87816b9a35800f60d8ba57c1edd36bf5bea9a449', + '0x01d91571acfe7280b9c4fa250c1b17e56667342e8a7ef6a82ad30ae4a125a82d', + '0x52b5506e3ebde360635b1c2a8cef1a0ced68398867a9b775483097b25c70e900', + '0x366af927a2447f65d3eaaadb61d1db4aa066e8dcec0cd37a356f059b15104762', + '0x28225c34d9733d24ba72aa788de08f8c0aa455c5e332b242474ccac323172b92', + '0x2aef043594846f9575f4bf802de495a878b28858688b9b1db341e8cd8e536699', + '0x2fc3b1757b7c316ee2b59860b35101a129048b62c8a1d387451e46c612ad7259', + '0x69bffc6033df35d200d915fe4394381b855939aba7251edfeaadabb91767f1cb', + '0x4262bdd739dd0bc51cd2554482cde22d6e3a3440fc99392ed14a669b0854f4a9', + '0x0771289859d1f94591e4020ab7d8f02e7946800590934860230dcf59e581f9ee', + '0x8ff8a593d0a68428e66feaf570a22a36956ac285e2c688700e77a593a9edc61d', + '0x2a1af1a05d6a6c55e54e1a8329c4f91acb150025ba193d6da9f8188b2594947f', + '0x9d1f43be39fc57f261f1e27bdde1cec817eb8d4cb6b49d04a0920b08faf0aaeb', + '0x686e16c3cc1027bd7ed634c09c4f3605001d7aae32f38dec0fe42eb417d283ba', + '0x493d40f340fdf70a2d967f7af4d76bb3ce5b2166a70a7bc2bbf58d1fc461b686', + '0x7da118149f80b412dae2aeb1e23f975f128c299505da315c3f637ae88f01da7b', + '0x372705225cb4139530166ed9cd4c013b53ba8b4efcb7bb05a545e59ec634e962', + '0x69d7658d4a356090383cc706814156ca12d6fde655ff66ba87694f6ea314510b', + ], + root: '0xe1108bc2d965f0c397fa2a98a7e107a7a319ddc2f1c005f5a8f99ce598b34b58', +}; diff --git a/src/contracts/deposits-registry/sanity-checker/integrity-checker/deposit-tree/deposit-tree.spec.ts b/src/contracts/deposits-registry/sanity-checker/integrity-checker/deposit-tree/deposit-tree.spec.ts new file mode 100644 index 00000000..67152621 --- /dev/null +++ b/src/contracts/deposits-registry/sanity-checker/integrity-checker/deposit-tree/deposit-tree.spec.ts @@ -0,0 +1,196 @@ +import { digest2Bytes32, fromHexString, toHexString } from '../../../crypto'; +import { DepositTree } from './deposit-tree'; +import { + depositDataRootsFixture20k, + depositDataRootsFixture10k, + dataTransformFixtures, +} from './deposit-tree.fixture'; +const MOCK_DEPOSIT_COUNT = 0n; +describe('DepositTree', () => { + let depositTree: DepositTree; + + beforeEach(() => { + depositTree = new DepositTree(); + }); + + test('should initialize zero hashes correctly', () => { + expect(depositTree.zeroHashes[0]).toEqual(DepositTree.ZERO_HASH); + for (let i = 1; i < DepositTree.DEPOSIT_CONTRACT_TREE_DEPTH; i++) { + expect(depositTree.zeroHashes[i]).not.toEqual(undefined); + } + }); + + test('should correctly insert a node and update the tree', () => { + const initialNodeCount = depositTree.nodeCount; + const node = new Uint8Array(32).fill(1); // Example node hash + depositTree.insert(node, MOCK_DEPOSIT_COUNT); + expect(depositTree.nodeCount).toBe(initialNodeCount + 1n); + }); + + test('should detect problem with deposit count while inserting new node', () => { + const initialNodeCount = depositTree.nodeCount; + const node = new Uint8Array(32).fill(1); + const SOME_UNREAL_DEPOSIT_COUNT = 100n; + const isInserted = depositTree.insert(node, SOME_UNREAL_DEPOSIT_COUNT); + expect(depositTree.nodeCount).toBe(initialNodeCount); + expect(isInserted).toBeFalsy(); + }); + + test('should handle detailed node data correctly', () => { + const originalTree = new DepositTree(); + const nodeData = { + wc: '0x123456789abcdef0', + pubkey: '0xabcdef1234567890', + signature: '0x987654321fedcba0', + amount: '0x0100000000000000', + }; + originalTree.insert(DepositTree.formDepositNode(nodeData), 0n); + expect(Number(originalTree.nodeCount)).toBe(1); + + const oldDepositRoot = originalTree.getRoot(); + const cloned = originalTree.clone(); + + cloned.insert( + DepositTree.formDepositNode({ ...nodeData, wc: '0x123456789abcdef1' }), + 1n, + ); + + expect(cloned.getRoot()).not.toEqual(oldDepositRoot); + expect(cloned.getRoot()).not.toEqual(originalTree.getRoot()); + expect(originalTree.getRoot()).toEqual(oldDepositRoot); + + const freshTree = new DepositTree(); + + freshTree.insert(DepositTree.formDepositNode(nodeData), 0n); + freshTree.insert( + DepositTree.formDepositNode({ ...nodeData, wc: '0x123456789abcdef1' }), + 1n, + ); + + expect(cloned.getRoot()).toEqual(freshTree.getRoot()); + }); + + test('branches from cloned tree do not linked with original tree', () => { + const originalTree = new DepositTree(); + const nodeData = { + wc: '0x123456789abcdef0', + pubkey: '0xabcdef1234567890', + signature: '0x987654321fedcba0', + amount: '0x0100000000000000', + }; + + originalTree.insert( + DepositTree.formDepositNode({ ...nodeData, wc: '0x123456789abcdef1' }), + 0n, + ); + originalTree.insert( + DepositTree.formDepositNode({ ...nodeData, wc: '0x123456789abcdef1' }), + 1n, + ); + + originalTree.branch[0][0] = 1; + const clone = originalTree.clone(); + originalTree.branch[0][1] = 1; + + expect(clone.branch[0][1]).toBe(142); + expect(originalTree.branch[0][1]).toBe(1); + }); + + test('clone works correctly', () => { + const nodeData = { + wc: '0x123456789abcdef0', + pubkey: '0xabcdef1234567890', + signature: '0x987654321fedcba0', + amount: '0x0100000000000000', + }; + depositTree.insert( + DepositTree.formDepositNode(nodeData), + MOCK_DEPOSIT_COUNT, + ); + expect(Number(depositTree.nodeCount)).toBe(1); + }); + + test('should clone the tree correctly', () => { + depositTree.insert(new Uint8Array(32).fill(1), MOCK_DEPOSIT_COUNT); + const clonedTree = depositTree.clone(); + expect(clonedTree.nodeCount).toEqual(depositTree.nodeCount); + expect(clonedTree.branch).toEqual(depositTree.branch); + expect(clonedTree).not.toBe(depositTree); + }); + + test('branch updates correctly after multiple insertions', () => { + const node1 = new Uint8Array(32).fill(1); // First example node + depositTree.insert(node1, MOCK_DEPOSIT_COUNT); // First insertion + + expect(depositTree.branch[0]).toEqual(node1); + + const node2 = new Uint8Array(32).fill(2); // Second example node + depositTree.insert(node2, MOCK_DEPOSIT_COUNT + 1n); // Second insertion + + // Now, we need to check the second level of the branch + // This should use the same hashing function as used in your actual code + const expectedHashAfterSecondInsert = digest2Bytes32( + depositTree.branch[0], + node2, + ); + expect(depositTree.branch[1]).toEqual(expectedHashAfterSecondInsert); + }); + + test('should throw error on invalid NodeData', () => { + const invalidNodeData = { + wc: 'xyz', + pubkey: 'abc', + signature: '123', + amount: 'not a number', + }; + expect(() => DepositTree.formDepositNode(invalidNodeData)).toThrowError(); + }); + + test.each(dataTransformFixtures)( + 'actual validation using data and hash from blockchain', + (event) => { + const depositDataRoot = DepositTree.formDepositNode({ + wc: event.wc, + pubkey: event.pubkey, + signature: event.signature, + amount: event.amount, + }); + + expect(toHexString(depositDataRoot)).toEqual(event.depositDataRoot); + }, + ); + + test('hashes should matches with fixtures (first 10k blocks from holesky)', () => { + depositDataRootsFixture10k.events.map((ev, index) => + depositTree.insert(fromHexString(ev), BigInt(index)), + ); + + expect(Number(depositTree.nodeCount)).toEqual( + depositDataRootsFixture10k.events.length, + ); + expect(depositTree.getRoot()).toEqual(depositDataRootsFixture10k.root); + }); + + test('hashes should matches with fixtures (second 10k blocks from holesky)', () => { + depositDataRootsFixture10k.events.map((ev, index) => + depositTree.insert(fromHexString(ev), BigInt(index)), + ); + + expect(Number(depositTree.nodeCount)).toEqual( + depositDataRootsFixture10k.events.length, + ); + expect(depositTree.getRoot()).toEqual(depositDataRootsFixture10k.root); + + depositDataRootsFixture20k.events.map((ev, index) => + depositTree.insert( + fromHexString(ev), + BigInt(depositDataRootsFixture10k.events.length + index), + ), + ); + expect(Number(depositTree.nodeCount)).toEqual( + depositDataRootsFixture10k.events.length + + depositDataRootsFixture20k.events.length, + ); + expect(depositTree.getRoot()).toEqual(depositDataRootsFixture20k.root); + }); +}); diff --git a/src/contracts/deposits-registry/sanity-checker/integrity-checker/deposit-tree/deposit-tree.ts b/src/contracts/deposits-registry/sanity-checker/integrity-checker/deposit-tree/deposit-tree.ts new file mode 100644 index 00000000..94394b4e --- /dev/null +++ b/src/contracts/deposits-registry/sanity-checker/integrity-checker/deposit-tree/deposit-tree.ts @@ -0,0 +1,137 @@ +import { + DepositData, + digest2Bytes32, + fromHexString, + parseLittleEndian64, + toLittleEndian64BigInt, +} from '../../../crypto'; +import { ethers } from 'ethers'; +import { NodeData } from '../../../interfaces'; + +const ZERO_HASH_HEX = + '0x0000000000000000000000000000000000000000000000000000000000000000'; +const ZERO_HASH_ROOT_HEX = '0x000000000000000000000000000000000000000000000000'; + +export class DepositTree { + static DEPOSIT_CONTRACT_TREE_DEPTH = 32; + static ZERO_HASH = fromHexString(ZERO_HASH_HEX); + zeroHashes: Uint8Array[] = new Array(DepositTree.DEPOSIT_CONTRACT_TREE_DEPTH); + branch: Uint8Array[] = []; + nodeCount = 0n; + + constructor() { + this.formZeroHashes(); + } + + /** + * Initializes the zero hashes used in the tree. + */ + private formZeroHashes() { + this.zeroHashes[0] = DepositTree.ZERO_HASH; + for ( + let height = 0; + height < DepositTree.DEPOSIT_CONTRACT_TREE_DEPTH - 1; + height++ + ) { + this.zeroHashes[height + 1] = digest2Bytes32( + this.zeroHashes[height], + this.zeroHashes[height], + ); + } + } + + /** + * Forms the branch of the tree needed to update the root when a new node is inserted. + * @param {Uint8Array} node - The node's data to be inserted. + * @param {bigint} depositCount - The sequential index of the deposit, representing the total deposits. + * @returns {Uint8Array[] | undefined} The updated branch of the tree after inserting the node. + */ + private formBranch( + node: Uint8Array, + depositCount: bigint, + ): Uint8Array[] | undefined { + let size = depositCount; + for ( + let height = 0; + height < DepositTree.DEPOSIT_CONTRACT_TREE_DEPTH; + height++ + ) { + if (size % 2n === 1n) { + this.branch[height] = node; + return this.branch; + } + + node = digest2Bytes32(this.branch[height], node); + + size /= 2n; + } + } + + /** + * Inserts a new node into the tree using an already computed hash. The insertion only proceeds + * if the deposit count provided is the next sequential number expected (one more than the current node count). + * @param {Uint8Array} node - The hash of the node to be inserted, represented as a Uint8Array. + * @param {bigint} depositCount - The sequential count of the deposit event from the blockchain, + * expected to be one more than the current highest node count. + * @returns {boolean} Returns true if the node was successfully inserted, false otherwise. + */ + public insert(node: Uint8Array, depositCount: bigint): boolean { + if (depositCount !== this.nodeCount) { + return false; + } + this.nodeCount++; + this.formBranch(node, this.nodeCount); + return true; + } + + /** + * Computes and returns the root hash of the deposit tree. + * @returns {string} The computed root hash of the tree. + */ + public getRoot() { + let node = DepositTree.ZERO_HASH; + let size = this.nodeCount; + for ( + let height = 0; + height < DepositTree.DEPOSIT_CONTRACT_TREE_DEPTH; + height++ + ) { + if (size % 2n === 1n) { + node = digest2Bytes32(this.branch[height], node); + } else { + node = digest2Bytes32(node, this.zeroHashes[height]); + } + size /= 2n; + } + const finalRoot = ethers.utils.soliditySha256( + ['bytes', 'bytes', 'bytes'], + [node, toLittleEndian64BigInt(this.nodeCount), ZERO_HASH_ROOT_HEX], + ); + return finalRoot; + } + + /** + * Creates a clone of the current tree instance, copying the branch structure and node count. + * @returns {DepositTree} A new DepositTree instance with the same state. + */ + public clone() { + const tree = new DepositTree(); + tree.branch = this.branch.map((array) => Uint8Array.from(array)); + tree.nodeCount = this.nodeCount; + return tree; + } + + /** + * Forms the deposit node from the given NodeData structure. + * @param {NodeData} nodeData - Detailed data of the deposit, including public key, withdrawal credentials, signature, and amount. + * @returns {Uint8Array} The hashed node as a Uint8Array. + */ + static formDepositNode(nodeData: NodeData): Uint8Array { + return DepositData.hashTreeRoot({ + withdrawalCredentials: fromHexString(nodeData.wc), + pubkey: fromHexString(nodeData.pubkey), + signature: fromHexString(nodeData.signature), + amount: parseLittleEndian64(nodeData.amount), + }); + } +} diff --git a/src/contracts/deposits-registry/sanity-checker/integrity-checker/deposit-tree/index.ts b/src/contracts/deposits-registry/sanity-checker/integrity-checker/deposit-tree/index.ts new file mode 100644 index 00000000..943e7a74 --- /dev/null +++ b/src/contracts/deposits-registry/sanity-checker/integrity-checker/deposit-tree/index.ts @@ -0,0 +1 @@ +export * from './deposit-tree'; diff --git a/src/contracts/deposits-registry/sanity-checker/integrity-checker/index.ts b/src/contracts/deposits-registry/sanity-checker/integrity-checker/index.ts new file mode 100644 index 00000000..06b7860a --- /dev/null +++ b/src/contracts/deposits-registry/sanity-checker/integrity-checker/index.ts @@ -0,0 +1,2 @@ +export * from './integrity-checker.service'; +export * from './integrity-checker.module'; diff --git a/src/contracts/deposits-registry/sanity-checker/integrity-checker/integrity-checker.module.ts b/src/contracts/deposits-registry/sanity-checker/integrity-checker/integrity-checker.module.ts new file mode 100644 index 00000000..cfa2c139 --- /dev/null +++ b/src/contracts/deposits-registry/sanity-checker/integrity-checker/integrity-checker.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { DepositIntegrityCheckerService } from './integrity-checker.service'; + +@Module({ + providers: [DepositIntegrityCheckerService], + exports: [DepositIntegrityCheckerService], +}) +export class DepositIntegrityCheckerModule {} diff --git a/src/contracts/deposits-registry/sanity-checker/integrity-checker/integrity-checker.service.ts b/src/contracts/deposits-registry/sanity-checker/integrity-checker/integrity-checker.service.ts new file mode 100644 index 00000000..752909c0 --- /dev/null +++ b/src/contracts/deposits-registry/sanity-checker/integrity-checker/integrity-checker.service.ts @@ -0,0 +1,168 @@ +import { Inject, Injectable, LoggerService } from '@nestjs/common'; +import { RepositoryService } from 'contracts/repository'; +import { WINSTON_MODULE_NEST_PROVIDER } from 'nest-winston'; +import { DepositTree } from './deposit-tree'; +import { + VerifiedDepositEvent, + VerifiedDepositEventsCache, +} from '../../interfaces'; +import { DEPOSIT_TREE_STEP_SYNC } from './constants'; +import { toHexString } from 'contracts/deposits-registry/crypto'; + +@Injectable() +export class DepositIntegrityCheckerService { + private finalizedTree = new DepositTree(); + constructor( + @Inject(WINSTON_MODULE_NEST_PROVIDER) private logger: LoggerService, + private repositoryService: RepositoryService, + ) {} + + /** + * Initializes the deposit tree with an initial cache of verified deposit events. + * @param {VerifiedDepositEventsCache} initialEventsCache - Cache of verified deposit events to initialize the tree. + */ + public async initialize(initialEventsCache: VerifiedDepositEventsCache) { + await this.putEventsToTree(this.finalizedTree, initialEventsCache.data); + } + + /** + * Inserts a list of finalized verified deposit events into the deposit tree and returns the updated tree. + * @param {VerifiedDepositEvent[]} eventsCache - Array of verified deposit events to be added to the tree. + * @returns {Promise} The updated deposit tree after adding the events. + */ + public async putFinalizedEvents( + eventsCache: VerifiedDepositEvent[], + ): Promise { + await this.putEventsToTree(this.finalizedTree, eventsCache); + return this.finalizedTree; + } + + /** + * Inserts a list of latest verified deposit events into a clone of the deposit tree and returns the cloned tree. + * @param {VerifiedDepositEvent[]} eventsCache - Array of verified deposit events to be added to the cloned tree. + * @returns {Promise} The cloned and updated deposit tree after adding the events. + */ + public async putLatestEvents( + eventsCache: VerifiedDepositEvent[], + ): Promise { + const clone = this.finalizedTree.clone(); + await this.putEventsToTree(clone, eventsCache); + return clone; + } + + /** + * Checks the integrity of the latest deposit root against the blockchain deposit root for a given block number. + * latest is the tag against which the state relative to the blockchain is stored + * @param {number} blockNumber - Block number to check the deposit root against. + * @param {VerifiedDepositEvent[]} eventsCache - Latest events to verify against the deposit root. + * @returns {Promise} A promise that resolves if the roots match, otherwise throws an error. + */ + public async checkLatestRoot( + blockHash: string, + eventsCache: VerifiedDepositEvent[], + ): Promise { + const tree = await this.putLatestEvents( + eventsCache.sort((a, b) => a.depositCount - b.depositCount), + ); + + return this.checkRoot(blockHash, tree); + } + + /** + * Checks the integrity of the finalized deposit root against the blockchain deposit root for a given block number. + * finalized is the tag against which the state relative to the blockchain is stored. + * @param {string | number} tag - Block Tag to check the deposit root against. + * @returns {Promise} A promise that resolves if the roots match, otherwise throws an error. + */ + public async checkFinalizedRoot(blockHash: string): Promise { + return this.checkRoot(blockHash, this.finalizedTree); + } + + /** + * A private helper method to compare the local deposit tree root with the remote deposit root from the blockchain. + * @param {string | number} tag - Block Tag associated with the deposit root to verify. + * @param {DepositTree} tree - Deposit tree to use for comparison. + * @returns {Promise} A promise that resolves if the roots match, otherwise logs an error and throws. + */ + private async checkRoot(blockHash: string, tree: DepositTree) { + const localRoot = tree.getRoot(); + const remoteRoot = await this.getDepositRoot(blockHash); + + if (localRoot === remoteRoot) { + this.logger.log('Integrity check successfully completed', { + blockHash, + }); + return true; + } + + this.logger.error( + 'Deposit root is different from deposit root from the network', + { localRoot, remoteRoot }, + ); + + return false; + } + + /** + * Inserts verified deposit events into the provided deposit tree and logs progress periodically. + * @param {DepositTree} tree - Deposit tree to insert events into. + * @param {VerifiedDepositEvent[]} eventsCache - Events to insert into the tree. + */ + public async putEventsToTree( + tree: DepositTree, + eventsCache: VerifiedDepositEvent[], + ) { + for (const [index, event] of eventsCache.entries()) { + const insertionIsMade = tree.insert( + event.depositDataRoot, + BigInt(event.depositCount), + ); + + if (!insertionIsMade) { + const { + depositCount, + depositDataRoot, + index: eventIndex, + blockHash, + blockNumber, + } = event; + + this.logger.warn( + 'Problem found while forming deposit tree with event', + { + depositCount, + depositDataRoot: toHexString(depositDataRoot), + blockHash, + blockNumber, + eventIndex, + depositCountInTree: Number(tree.nodeCount), + }, + ); + + throw new Error('Problem found while forming deposit tree with event'); + } + + if (index % DEPOSIT_TREE_STEP_SYNC === 0) { + await new Promise((res) => setTimeout(res, 1)); + + this.logger.log('Inserting verified deposit events', { + processed: index, + remaining: eventsCache.length - index, + }); + } + } + } + + /** + * Retrieves the deposit root from the blockchain for a specific block. + * @param {BlockTag | undefined} blockTag - Specific block number or tag to retrieve the deposit root for. + * @returns {Promise} Promise that resolves with the deposit root. + */ + public async getDepositRoot(blockHash: string): Promise { + const contract = await this.repositoryService.getCachedDepositContract(); + const overrides = { blockTag: { blockHash } }; + const depositRoot = await contract.get_deposit_root(overrides as any); + + return depositRoot; + } +} diff --git a/src/contracts/deposits-registry/sanity-checker/sanity-checker.module.ts b/src/contracts/deposits-registry/sanity-checker/sanity-checker.module.ts new file mode 100644 index 00000000..a1f0ab8d --- /dev/null +++ b/src/contracts/deposits-registry/sanity-checker/sanity-checker.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { BlockchainCheckerModule } from './blockchain-checker'; +import { DepositIntegrityCheckerModule } from './integrity-checker'; +import { DepositRegistrySanityCheckerService } from './sanity-checker.service'; + +@Module({ + imports: [BlockchainCheckerModule, DepositIntegrityCheckerModule], + providers: [DepositRegistrySanityCheckerService], + exports: [DepositRegistrySanityCheckerService], +}) +export class DepositRegistrySanityCheckerModule {} diff --git a/src/contracts/deposits-registry/sanity-checker/sanity-checker.service.ts b/src/contracts/deposits-registry/sanity-checker/sanity-checker.service.ts new file mode 100644 index 00000000..207ffd1b --- /dev/null +++ b/src/contracts/deposits-registry/sanity-checker/sanity-checker.service.ts @@ -0,0 +1,147 @@ +import { Inject, Injectable, LoggerService } from '@nestjs/common'; +import { WINSTON_MODULE_NEST_PROVIDER } from 'nest-winston'; +import { + VerifiedDepositEvent, + VerifiedDepositEventsCache, +} from '../interfaces'; +import { BlockchainCheckerService } from './blockchain-checker/blockchain-checker.service'; +import { DepositIntegrityCheckerService } from './integrity-checker'; +import { toHexString } from '../crypto'; +@Injectable() +export class DepositRegistrySanityCheckerService { + constructor( + @Inject(WINSTON_MODULE_NEST_PROVIDER) private logger: LoggerService, + private blockchainSanityChecker: BlockchainCheckerService, + private depositsIntegrityChecker: DepositIntegrityCheckerService, + ) {} + + public async initialize(initialEventsCache: VerifiedDepositEventsCache) { + await this.depositsIntegrityChecker.initialize(initialEventsCache); + } + + private async indexEventsChunk(events: VerifiedDepositEvent[]) { + return await this.depositsIntegrityChecker.putFinalizedEvents(events); + } + // putLatestEvents + private async checkFreshEvents( + blockHash: string, + events: VerifiedDepositEvent[], + ) { + return await this.depositsIntegrityChecker.checkLatestRoot( + blockHash, + events, + ); + } + + private findReorganization( + blockNumber: number, + blockHash: string, + events: VerifiedDepositEvent[], + ) { + const event = this.blockchainSanityChecker.findReorganizedEvent( + events, + blockNumber, + blockHash, + ); + + if (event) { + this.logger.error('Reorganization found in deposit event', { + blockHash: event.blockHash, + blockNumber: event.blockNumber, + depositDataRoot: toHexString(event.depositDataRoot), + }); + return true; + } + return false; + } + + public verifyCacheBlock( + cachedEvents: VerifiedDepositEventsCache, + currentBlock: number, + ) { + const isCacheValid = this.blockchainSanityChecker.validateCacheBlock( + cachedEvents, + currentBlock, + ); + + const blocks = { + cachedStartBlock: cachedEvents.headers.startBlock, + cachedEndBlock: cachedEvents.headers.endBlock, + currentBlock, + }; + + if (isCacheValid) { + this.logger.log('Deposit events cache has valid age', blocks); + } + + if (!isCacheValid) { + this.logger.error( + 'Deposit events cache is newer than the current block', + blocks, + ); + } + + return isCacheValid; + } + + public async addEventGroupToIndex( + chunkStartBlock: number, + chunkToBlock: number, + events: VerifiedDepositEvent[], + ) { + if (!events.length) return; + + const tree = await this.indexEventsChunk(events); + + this.logger.log('Deposit events chunk was indexed', { + chunkStartBlock, + chunkToBlock, + depositRoot: tree.getRoot(), + }); + } + + /** + * Verifies the integrity of the latest deposit events. If the last event is absent, + * it checks the validity of the last finalized root using the current block hash. + * Otherwise, it checks for reorganizations and matches the deposit root of the events. + * + * @param {string} currentBlockHash - The hash of the current block being processed. + * @param {VerifiedDepositEvent[]} freshEvents - Array of freshly verified deposit events. + * @returns {Promise} - Returns true if the deposit root matches and no reorganization is found, otherwise false. + */ + public async verifyFreshEvents( + currentBlockHash: string, + freshEvents: VerifiedDepositEvent[], + ) { + const lastEvent = freshEvents[freshEvents.length - 1]; + + // If events list is empty, there is no last event, so validate the finalized root for the current block hash. + if (!lastEvent) { + return this.depositsIntegrityChecker.checkFinalizedRoot(currentBlockHash); + } + + const { blockHash, blockNumber } = lastEvent; + + // Check for a reorganization in the blockchain that might affect the deposit events. + const isReorgFound = this.findReorganization( + blockNumber, + blockHash, + freshEvents, + ); + + // If a reorganization is found, return false as the events might not be in the correct state. + if (isReorgFound) return false; + + // Check if the deposit root of the events matches the expected values. + const isDepositRootMatches = await this.checkFreshEvents( + blockHash, + freshEvents, + ); + + return isDepositRootMatches; + } + + public async verifyUpdatedEvents(blockHash: string) { + return this.depositsIntegrityChecker.checkFinalizedRoot(blockHash); + } +} diff --git a/src/contracts/deposits-registry/store/index.ts b/src/contracts/deposits-registry/store/index.ts new file mode 100644 index 00000000..99322182 --- /dev/null +++ b/src/contracts/deposits-registry/store/index.ts @@ -0,0 +1,3 @@ +export * from './store.constants'; +export * from './store.module'; +export * from './store.service'; diff --git a/src/contracts/deposits-registry/store/store.constants.ts b/src/contracts/deposits-registry/store/store.constants.ts new file mode 100644 index 00000000..3198f854 --- /dev/null +++ b/src/contracts/deposits-registry/store/store.constants.ts @@ -0,0 +1,15 @@ +export const DB_DIR = 'cache'; +export const DB_LAYER_DIR = 'cache:layer'; + +export const DB_DEFAULT_VALUE = 'cacheDefaultValue'; + +export const DEPOSIT_CACHE_DEFAULT = Object.freeze({ + headers: { + version: '-1', + startBlock: 0, + endBlock: 0, + }, + data: [], +}); + +export const MAX_DEPOSIT_COUNT = 2 ** 32; diff --git a/src/contracts/deposits-registry/store/store.fixtures.ts b/src/contracts/deposits-registry/store/store.fixtures.ts new file mode 100644 index 00000000..10bda7c4 --- /dev/null +++ b/src/contracts/deposits-registry/store/store.fixtures.ts @@ -0,0 +1,50 @@ +import { + VerifiedDepositEvent, + VerifiedDepositEventsCacheHeaders, +} from '../interfaces'; + +// Mock for VerifiedDepositEventsCacheHeaders +export const headersMock: VerifiedDepositEventsCacheHeaders = { + startBlock: 1000, + endBlock: 1050, +}; + +// Mock for VerifiedDepositEvent +export const eventMock1: VerifiedDepositEvent = { + pubkey: 'abc123', + wc: '0', + amount: '100', + signature: 'def456', + tx: 'ghi789', + blockNumber: 1001, + blockHash: 'aaa111', + logIndex: 1, + index: '0', + depositCount: 1, + depositDataRoot: new Uint8Array([1, 2, 3, 4, 5]), + valid: true, +}; + +export const eventMock2: VerifiedDepositEvent = { + pubkey: 'xyz123', + wc: '0', + amount: '200', + signature: 'uvw456', + tx: 'rst789', + blockNumber: 1002, + blockHash: 'bbb222', + logIndex: 2, + index: '1', + depositCount: 2, + depositDataRoot: new Uint8Array([6, 7, 8, 9, 10]), + valid: true, +}; + +// Mock for the structure {data: VerifiedDepositEvent[], headers: VerifiedDepositEventsCacheHeaders} +export const cacheMock: { + data: VerifiedDepositEvent[]; + headers: VerifiedDepositEventsCacheHeaders; +} = { + data: [eventMock1, eventMock2], + headers: headersMock, +}; diff --git a/src/contracts/deposits-registry/store/store.module.ts b/src/contracts/deposits-registry/store/store.module.ts new file mode 100644 index 00000000..0bf10e80 --- /dev/null +++ b/src/contracts/deposits-registry/store/store.module.ts @@ -0,0 +1,34 @@ +import { DynamicModule, Module } from '@nestjs/common'; +import { ProviderModule } from 'provider'; +import { DB_DIR, DB_DEFAULT_VALUE, DB_LAYER_DIR } from './store.constants'; +import { DepositsRegistryStoreService } from './store.service'; + +@Module({}) +export class DepositsRegistryStoreModule { + static register( + defaultValue: unknown, + cacheDir = 'cache', + cacheLayerDir = 'deposit-cache', + ): DynamicModule { + return { + module: DepositsRegistryStoreModule, + imports: [ProviderModule], + providers: [ + DepositsRegistryStoreService, + { + provide: DB_DIR, + useValue: cacheDir, + }, + { + provide: DB_LAYER_DIR, + useValue: cacheLayerDir, + }, + { + provide: DB_DEFAULT_VALUE, + useValue: defaultValue, + }, + ], + exports: [DepositsRegistryStoreService], + }; + } +} diff --git a/src/contracts/deposits-registry/store/store.service.spec.ts b/src/contracts/deposits-registry/store/store.service.spec.ts new file mode 100644 index 00000000..3751639b --- /dev/null +++ b/src/contracts/deposits-registry/store/store.service.spec.ts @@ -0,0 +1,134 @@ +import { Test } from '@nestjs/testing'; +import { MockProviderModule } from 'provider'; +import { ConfigModule } from 'common/config'; +import { LoggerModule } from 'common/logger'; +import { DepositsRegistryStoreModule } from './store.module'; +import { DepositsRegistryStoreService } from './store.service'; +import { cacheMock, eventMock1 } from './store.fixtures'; + +const getEventsDepositCount = async ( + dbService: DepositsRegistryStoreService, +) => { + const result = await dbService.getEventsCache(); + const expectedDeposits = result.data.map((event) => event.depositCount); + return expectedDeposits; +}; + +describe('dbService', () => { + const defaultCacheValue = { + headers: {}, + data: [] as any[], + }; + + let dbService: DepositsRegistryStoreService; + + beforeEach(async () => { + const moduleRef = await Test.createTestingModule({ + imports: [ + ConfigModule.forRoot(), + MockProviderModule.forRoot(), + DepositsRegistryStoreModule.register(defaultCacheValue, 'leveldb-spec'), + LoggerModule, + ], + }).compile(); + + dbService = moduleRef.get(DepositsRegistryStoreService); + await dbService.initialize(); + }); + + afterEach(async () => { + try { + await dbService.deleteCache(); + await dbService.close(); + } catch (error) {} + }); + + it('should return default cache', async () => { + const result = await dbService.getEventsCache(); + expect(result).toEqual(defaultCacheValue); + }); + + it('should return saved cache', async () => { + const expected = cacheMock; + + await dbService.insertEventsCacheBatch(expected); + const result = await dbService.getEventsCache(); + + expect(result).toEqual(expected); + }); + + describe('deleteDepositsGreaterThanNBatch', () => { + const testCases = [ + { N: 10, deposits: [9, 10, 11, 12], expectedRemaining: [9, 10] }, + { N: 5, deposits: [3, 4, 5, 6], expectedRemaining: [3, 4, 5] }, + { N: 0, deposits: [0, 1, 2], expectedRemaining: [0] }, + ]; + + it.each(testCases)( + 'should delete deposits where deposit count is greater than %s', + async ({ N, deposits, expectedRemaining }) => { + await dbService.insertEventsCacheBatch({ + headers: { startBlock: 1, endBlock: 100 }, + data: deposits.map((count) => ({ + ...eventMock1, + depositCount: count, + })), + }); + + const insertedDeposits = await getEventsDepositCount(dbService); + expect(insertedDeposits).toEqual(expect.arrayContaining(deposits)); + expect(insertedDeposits.length).toBe(deposits.length); + + await dbService.deleteDepositsGreaterThanNBatch(N); + + const expectedDeposits = await getEventsDepositCount(dbService); + expect(expectedDeposits).toEqual( + expect.arrayContaining(expectedRemaining), + ); + expect(expectedDeposits.length).toBe(expectedRemaining.length); + }, + ); + }); + + describe('clearFromLastValidEvent', () => { + const testCases = [ + { lastValidCount: 5, deposits: [4, 5, 6], expectedRemaining: [4, 5] }, + { lastValidCount: 1, deposits: [1, 2, 3], expectedRemaining: [1] }, + { lastValidCount: 0, deposits: [0, 1], expectedRemaining: [0] }, + ]; + + it.each(testCases)( + 'should clear deposits starting from depositCount %s', + async ({ lastValidCount, deposits, expectedRemaining }) => { + await dbService.insertLastValidEvent({ + ...eventMock1, + depositCount: lastValidCount, + }); + + const lastEvent = await dbService.getLastValidEvent(); + expect(lastEvent).toBeDefined(); + expect(lastEvent?.depositCount).toBe(lastValidCount); + + await dbService.insertEventsCacheBatch({ + headers: { startBlock: 1, endBlock: 100 }, + data: deposits.map((count) => ({ + ...eventMock1, + depositCount: count, + })), + }); + + const insertedDeposits = await getEventsDepositCount(dbService); + expect(insertedDeposits).toEqual(expect.arrayContaining(deposits)); + expect(insertedDeposits.length).toBe(deposits.length); + + await dbService.clearFromLastValidEvent(); + + const expectedDeposits = await getEventsDepositCount(dbService); + expect(expectedDeposits).toEqual( + expect.arrayContaining(expectedRemaining), + ); + expect(expectedDeposits.length).toBe(expectedRemaining.length); + }, + ); + }); +}); diff --git a/src/contracts/deposits-registry/store/store.service.ts b/src/contracts/deposits-registry/store/store.service.ts new file mode 100644 index 00000000..17723d12 --- /dev/null +++ b/src/contracts/deposits-registry/store/store.service.ts @@ -0,0 +1,287 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { Level } from 'level'; +import { join } from 'path'; +import { + DB_DIR, + DB_DEFAULT_VALUE, + MAX_DEPOSIT_COUNT, + DB_LAYER_DIR, +} from './store.constants'; +import { ProviderService } from 'provider'; +import { + VerifiedDepositEvent, + VerifiedDepositEventsCache, + VerifiedDepositEventsCacheHeaders, +} from '../interfaces'; + +@Injectable() +export class DepositsRegistryStoreService { + private db!: Level; + constructor( + private providerService: ProviderService, + @Inject(DB_DIR) private cacheDir: string, + @Inject(DB_LAYER_DIR) private cacheLayerDir: string, + @Inject(DB_DEFAULT_VALUE) + private cacheDefaultValue: { + data: VerifiedDepositEvent[]; + headers: VerifiedDepositEventsCacheHeaders; + }, + ) {} + + public async initialize() { + await this.setupLevel(); + } + + /** + * Initializes LevelDB with JSON encoding at the cache directory path. + * + * @returns {Promise} A promise that resolves when the database is successfully initialized. + * @private + */ + private async setupLevel() { + this.db = new Level(await this.getDBDirPath(), { + valueEncoding: 'json', + }); + await this.db.open(); + } + + /** + * Fetches and constructs the cache directory path for the current blockchain network. + * + * @returns {Promise} A promise that resolves to the full path of the network-specific cache directory. + * @private + */ + private async getDBDirPath(): Promise { + const chainId = await this.providerService.getChainId(); + const networkDir = `chain-${chainId}`; + + return join(this.cacheDir, this.cacheLayerDir, networkDir); + } + + /** + * Asynchronously retrieves deposit events and headers from the database. + * Iterates through entries starting with 'deposit:' to collect data and fetches headers stored under 'header'. + * Handles errors by logging and returning default cache values. + * + * @returns {Promise<{data: VerifiedDepositEvent[], headers: VerifiedDepositEventsCacheHeaders}>} Cache data and headers. + * @public + */ + public async getEventsCache(): Promise<{ + data: VerifiedDepositEvent[]; + headers: VerifiedDepositEventsCacheHeaders; + lastValidEvent?: VerifiedDepositEvent; + }> { + try { + const stream = this.db.iterator({ gte: 'deposit:', lte: 'deposit:\xFF' }); + + const data: VerifiedDepositEvent[] = []; + + for await (const [, value] of stream) { + data.push(this.parseDepositEvent(value)); + } + const headers: VerifiedDepositEventsCacheHeaders = JSON.parse( + await this.db.get('headers'), + ); + + const lastValidEvent = await this.getLastValidEvent(); + + return { data, headers, lastValidEvent }; + } catch (error: any) { + if (error.code === 'LEVEL_NOT_FOUND') return this.cacheDefaultValue; + throw error; + } + } + + /** + * Retrieves the last valid deposit event from the database. + * This method queries the database for the 'last-valid-event' key to fetch the most recent + * valid event and parses it into a `VerifiedDepositEvent` object. + * + * @returns {Promise} A promise that resolves to the last valid `VerifiedDepositEvent` object + * or `undefined` if no event is found or if the event could not be retrieved (e.g., key does not exist). + * + * @throws {Error} Throws an error if there is a database access issue other than a 'LEVEL_NOT_FOUND' error code. + */ + public async getLastValidEvent(): Promise { + try { + const lastValidEvent = await this.db.get('last-valid-event'); + return this.parseDepositEvent(lastValidEvent); + } catch (error: any) { + if (error.code === 'LEVEL_NOT_FOUND') return undefined; + throw error; + } + } + + /** + * Clears all deposit records from the database starting from the deposit count of the last valid event. + * If no valid event is found, it will clear deposits greater than deposit count zero. + * This method leverages the `deleteDepositsGreaterThanNBatch` method for batch deletion. + * @returns {Promise} A promise that resolves when all appropriate deposits have been deleted. + */ + public async clearFromLastValidEvent(): Promise { + const lastValidEvent = await this.getLastValidEvent(); + + // Determine the starting index for deletion based on the last valid event's deposit count + const fromIndex = lastValidEvent ? lastValidEvent.depositCount : 0; + + // Delete all deposits from the determined index onwards + await this.deleteDepositsGreaterThanNBatch(fromIndex); + } + + /** + * Deletes all deposit records from the database with keys greater than a specified number. + * @param {number} depositCount - The number above which deposit keys will be deleted. + * @returns {Promise} A promise that resolves when the operation is complete. + */ + public async deleteDepositsGreaterThanNBatch( + depositCount: number, + ): Promise { + // Generate the upper boundary key for deletion + const upperBoundKey = this.generateDepositKey(depositCount); + + // Initialize the iterator starting from the upper boundary key + const stream = this.db.iterator({ gt: upperBoundKey, lte: 'deposit:\xFF' }); + + // Initialize an array to hold batch operations + const ops: { type: 'del'; key: string }[] = []; + + // Populate the batch operations array with delete operations + for await (const [key] of stream) { + ops.push({ + type: 'del', + key: key, + }); + } + + // Execute the batch operation if there are any operations to perform + if (ops.length > 0) { + await this.db.batch(ops); + } + } + + /** + * Generates a deposit key string based on a given number. + * The number is checked to ensure it falls within a valid range (from 0 up to MAX_DEPOSIT_COUNT). + * If the number is out of bounds, an error is thrown. + * The method creates a buffer, writes the number to the buffer in big-endian format, + * and returns a deposit key string that includes the hexadecimal representation of the number. + * + * @param {number} number - The number used to generate the deposit key. + * @returns {string} The deposit key in the format 'deposit:XXXX', where 'XXXX' is the hexadecimal representation of the number. + * @throws {Error} If the number is less than 0 or greater than MAX_DEPOSIT_COUNT. + * @private + */ + private generateDepositKey(number: number): string { + if (number < 0 || number > MAX_DEPOSIT_COUNT) { + throw new Error( + `Deposit count is out of the valid range (0 to ${MAX_DEPOSIT_COUNT}) received ${number}`, + ); + } + const index = Buffer.alloc(4); + index.writeUInt32BE(number, 0); + return `deposit:${index.toString('hex')}`; + } + + /** + * Parses a JSON string to a VerifiedDepositEvent, adding a Uint8Array for the depositDataRoot. + * + * @param {string} dataString - The JSON string representing a deposit event. + * @returns {VerifiedDepositEvent} The parsed deposit event. + * @private + */ + private parseDepositEvent(dataString: string): VerifiedDepositEvent { + const data = JSON.parse(dataString); + const depositEvent: VerifiedDepositEvent = { + ...data, + depositDataRoot: new Uint8Array(data.depositDataRoot), + }; + return depositEvent; + } + + /** + * Serializes a VerifiedDepositEvent into a JSON string, converting `depositDataRoot` from Uint8Array to an array. + * + * @param {VerifiedDepositEvent} depositEvent - The deposit event to serialize. + * @returns {string} The serialized JSON string of the deposit event. + * @public + */ + public serializeDepositEvent(depositEvent: VerifiedDepositEvent) { + const { depositDataRoot, ...rest } = depositEvent; + const value = { + ...rest, + depositDataRoot: Array.from(depositDataRoot), + }; + return JSON.stringify(value); + } + + /** + * Inserts a batch of deposit events and a header into the database. + * + * @param {VerifiedDepositEvent[]} events - An array of verified deposit events to be inserted into the database. + * @param {VerifiedDepositEventsCacheHeaders} header - The header information to be stored along with the events. + * @returns {Promise} A promise that resolves when all operations have been successfully committed to the database. + * @public + */ + public async insertEventsCacheBatch(records: { + data: VerifiedDepositEvent[]; + headers: VerifiedDepositEventsCacheHeaders; + }) { + const ops = records.data.map((event) => ({ + type: 'put' as const, + key: this.generateDepositKey(event.depositCount), + value: this.serializeDepositEvent(event), + })); + ops.push({ + type: 'put', + key: 'headers', + value: JSON.stringify(records.headers), + }); + await this.db.batch(ops); + } + + /** + * Inserts a batch of deposit events and a header into the database. + * + * @param {VerifiedDepositEvent} event - Last valid and verified event. + * @returns {Promise} A promise that resolves when all operations have been successfully committed to the database. + * @public + */ + public async insertLastValidEvent(event: VerifiedDepositEvent) { + await this.db.put('last-valid-event', this.serializeDepositEvent(event)); + } + + /** + * Clears all entries from the database. + * + * @returns {Promise} + * @public + */ + public async deleteCache(): Promise { + await this.db.clear(); + } + + /** + * Close the database connection. + * + * @returns {Promise} + * @public + */ + public async close(): Promise { + await this.db.close(); + } + + /** + * Saves deposited events to cache + */ + public async setCachedEvents( + cachedEvents: VerifiedDepositEventsCache, + ): Promise { + await this.deleteCache(); + await this.insertEventsCacheBatch({ + ...cachedEvents, + headers: { + ...cachedEvents.headers, + }, + }); + } +} diff --git a/src/contracts/lido/index.ts b/src/contracts/lido/index.ts deleted file mode 100644 index 9272f996..00000000 --- a/src/contracts/lido/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './lido.module'; -export * from './lido.service'; diff --git a/src/contracts/lido/lido.module.ts b/src/contracts/lido/lido.module.ts deleted file mode 100644 index a430066d..00000000 --- a/src/contracts/lido/lido.module.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Module } from '@nestjs/common'; -import { LidoService } from './lido.service'; - -@Module({ - providers: [LidoService], - exports: [LidoService], -}) -export class LidoModule {} diff --git a/src/contracts/lido/lido.service.ts b/src/contracts/lido/lido.service.ts deleted file mode 100644 index a7cd8c98..00000000 --- a/src/contracts/lido/lido.service.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { RepositoryService } from 'contracts/repository'; -import { BlockTag } from 'provider'; - -@Injectable() -export class LidoService { - constructor(private repositoryService: RepositoryService) {} - - /** - * Returns withdrawal credentials from the contract - */ - public async getWithdrawalCredentials(blockTag?: BlockTag): Promise { - const contract = await this.repositoryService.getCachedLidoContract(); - - return await contract.getWithdrawalCredentials({ - blockTag: blockTag as any, - }); - } -} diff --git a/src/contracts/repository/locator/locator.constants.ts b/src/contracts/repository/locator/locator.constants.ts index e92b36cc..a7109f93 100644 --- a/src/contracts/repository/locator/locator.constants.ts +++ b/src/contracts/repository/locator/locator.constants.ts @@ -8,9 +8,4 @@ export const LIDO_LOCATOR_BY_NETWORK: { [CHAINS.Holesky]: '0x28FAB2059C713A7F9D8c86Db49f9bb0e96Af1ef8', }; -export const getLidoLocatorAddress = (chainId: CHAINS): string => { - const address = LIDO_LOCATOR_BY_NETWORK[chainId]; - if (!address) throw new Error(`Chain ${chainId} is not supported`); - - return address; -}; +export { CHAINS }; diff --git a/src/contracts/repository/locator/locator.service.ts b/src/contracts/repository/locator/locator.service.ts index 8cca35e8..24de4e84 100644 --- a/src/contracts/repository/locator/locator.service.ts +++ b/src/contracts/repository/locator/locator.service.ts @@ -1,11 +1,15 @@ import { Injectable } from '@nestjs/common'; import { LocatorAbi, LocatorAbi__factory } from 'generated'; import { BlockTag, ProviderService } from 'provider'; -import { getLidoLocatorAddress } from './locator.constants'; +import { LIDO_LOCATOR_BY_NETWORK } from './locator.constants'; +import { Configuration } from 'common/config'; @Injectable() export class LocatorService { - constructor(private providerService: ProviderService) {} + constructor( + private readonly providerService: ProviderService, + private readonly config: Configuration, + ) {} private cachedLidoLocatorContract: LocatorAbi | undefined; /** * Returns DSM contract address @@ -51,6 +55,11 @@ export class LocatorService { */ public async getLocatorAddress(): Promise { const chainId = await this.providerService.getChainId(); - return getLidoLocatorAddress(chainId); + + const address = + this.config.LOCATOR_DEVNET_ADDRESS || LIDO_LOCATOR_BY_NETWORK[chainId]; + if (!address) throw new Error(`Chain ${chainId} is not supported`); + + return address; } } diff --git a/src/contracts/repository/repository.constants.ts b/src/contracts/repository/repository.constants.ts index 86bb3789..ca792a64 100644 --- a/src/contracts/repository/repository.constants.ts +++ b/src/contracts/repository/repository.constants.ts @@ -4,3 +4,6 @@ export const DEPOSIT_ABI = 'contract:DepositAbi'; export const STAKING_ROUTER_ABI = 'contract:StakingRouterAbi'; export const LIDO_LOCATOR_ABI = 'contract:LocatorAbi'; export const INIT_CONTRACTS_TIMEOUT = 20_000; +export const CURATED_ONCHAIN_V1_TYPE = 'curated-onchain-v1'; +export const COMMUNITY_ONCHAIN_V1_TYPE = 'community-onchain-v1'; +export const COMMUNITY_ONCHAIN_DEVNET0_V1_TYPE = 'community-staking-module'; diff --git a/src/contracts/repository/repository.mock.ts b/src/contracts/repository/repository.mock.ts index 94d9a7c6..8b2118b5 100644 --- a/src/contracts/repository/repository.mock.ts +++ b/src/contracts/repository/repository.mock.ts @@ -1,4 +1,3 @@ -import { hexZeroPad } from '@ethersproject/bytes'; import { RepositoryService } from './repository.service'; export const mockRepository = async (repositoryService: RepositoryService) => { @@ -8,16 +7,10 @@ export const mockRepository = async (repositoryService: RepositoryService) => { .spyOn(repositoryService, 'getDepositAddress') .mockImplementation(async () => address1); - const mockGetPauseMessagePrefix = jest - .spyOn(repositoryService, 'getPauseMessagePrefix') - .mockImplementation(async () => hexZeroPad('0x2', 32)); - - const mockGetAttestMessagePrefix = jest - .spyOn(repositoryService, 'getAttestMessagePrefix') - .mockImplementation(async () => hexZeroPad('0x1', 32)); - await repositoryService.initCachedContracts('latest'); jest.spyOn(repositoryService, 'getCachedLidoContract'); - return { depositAddr, mockGetPauseMessagePrefix, mockGetAttestMessagePrefix }; + return { + depositAddr, + }; }; diff --git a/src/contracts/repository/repository.service.spec.ts b/src/contracts/repository/repository.service.spec.ts index 59ebed84..2676b5d1 100644 --- a/src/contracts/repository/repository.service.spec.ts +++ b/src/contracts/repository/repository.service.spec.ts @@ -3,15 +3,13 @@ import { Test } from '@nestjs/testing'; import { ConfigModule } from 'common/config'; import { LoggerModule } from 'common/logger'; import { PrometheusModule } from 'common/prometheus'; -import { MockProviderModule, ProviderService } from 'provider'; +import { MockProviderModule } from 'provider'; import { RepositoryService } from 'contracts/repository'; import { RepositoryModule } from './repository.module'; import { LocatorService } from './locator/locator.service'; import { WINSTON_MODULE_NEST_PROVIDER } from 'nest-winston'; import { mockLocator } from './locator/locator.mock'; import { mockRepository } from './repository.mock'; -import { SecurityAbi__factory } from 'generated'; -import { Interface } from '@ethersproject/abi'; describe('RepositoryService', () => { let repositoryService: RepositoryService; @@ -116,74 +114,27 @@ describe('RepositoryService', () => { }); }); - describe('messages prefixes', () => { - let repositoryService: RepositoryService; - let locatorService: LocatorService; - let providerService: ProviderService; + describe('staking router', () => { + let mockGetAddress; beforeEach(async () => { - const moduleRef = await Test.createTestingModule({ - imports: [ - ConfigModule.forRoot(), - MockProviderModule.forRoot(), - LoggerModule, - PrometheusModule, - RepositoryModule, - ], - }).compile(); - - repositoryService = moduleRef.get(RepositoryService); - locatorService = moduleRef.get(LocatorService); - providerService = moduleRef.get(ProviderService); - jest - .spyOn(moduleRef.get(WINSTON_MODULE_NEST_PROVIDER), 'log') - .mockImplementation(() => undefined); + mockGetAddress = mockLocator(locatorService).SRAddr; + await mockRepository(repositoryService); }); - it('getAttestMessagePrefix', async () => { - const expected = '0x' + '1'.repeat(64); - - const mockProviderCall = jest - .spyOn(providerService.provider, 'call') - .mockImplementation(async () => { - const iface = new Interface(SecurityAbi__factory.abi); - const result = [expected]; - return iface.encodeFunctionResult('ATTEST_MESSAGE_PREFIX', result); - }); - - jest - .spyOn(repositoryService, 'getDepositAddress') - .mockImplementation(async () => '0x' + '5'.repeat(40)); - - mockLocator(locatorService); - - await repositoryService.initCachedContracts('latest'); - const prefix = await repositoryService.getAttestMessagePrefix(); - expect(prefix).toBe(expected); - expect(mockProviderCall).toBeCalledTimes(2); + it('should return contract instance', async () => { + const contract = await repositoryService.getCachedStakingRouterContract(); + expect(contract).toBeInstanceOf(Contract); }); - it('getPauseMessagePrefix', async () => { - const expected = '0x' + '1'.repeat(64); - - const mockProviderCall = jest - .spyOn(providerService.provider, 'call') - .mockImplementation(async () => { - const iface = new Interface(SecurityAbi__factory.abi); - const result = [expected]; - return iface.encodeFunctionResult('PAUSE_MESSAGE_PREFIX', result); - }); - - jest - .spyOn(repositoryService, 'getDepositAddress') - .mockImplementation(async () => '0x' + '5'.repeat(40)); - - mockLocator(locatorService); + it('should call getDepositAddress once and cache instance ', async () => { + const contract1 = + await repositoryService.getCachedStakingRouterContract(); + const contract2 = + await repositoryService.getCachedStakingRouterContract(); + expect(mockGetAddress).toBeCalledTimes(1); - await repositoryService.initCachedContracts('latest'); - const prefix = await repositoryService.getPauseMessagePrefix(); - expect(prefix).toBe(expected); - expect(mockProviderCall).toBeCalledTimes(2); + expect(contract1).toEqual(contract2); }); }); }); diff --git a/src/contracts/repository/repository.service.ts b/src/contracts/repository/repository.service.ts index b2a29f76..32dc36be 100644 --- a/src/contracts/repository/repository.service.ts +++ b/src/contracts/repository/repository.service.ts @@ -1,3 +1,4 @@ +import { Block } from '@ethersproject/abstract-provider'; import { Inject, Injectable, LoggerService } from '@nestjs/common'; import { LidoAbi, LidoAbi__factory, LocatorAbi } from 'generated'; import { SecurityAbi, SecurityAbi__factory } from 'generated'; @@ -26,9 +27,6 @@ export class RepositoryService { string, LidoAbi | LocatorAbi | SecurityAbi | StakingRouterAbi > = {}; - // store prefixes on the current state of the contracts. - // if the contracts are updated we will change these addresses too - private cachedDSMPrefixes: Record = {}; private permanentContractsCache: Record = {}; /** @@ -39,13 +37,13 @@ export class RepositoryService { // order is important: deposit contract depends on dsm await this.initCachedDSMContract(blockTag); await this.initCachedDepositContract(blockTag); - await this.initCachedStakingRouterAbiContract(blockTag); + await this.initCachedStakingRouterContract(blockTag); } /** * Init cache for each contract or wait if it makes some error */ - public async initOrWaitCachedContracts() { + public async initOrWaitCachedContracts(): Promise { const block = await this.providerService.getBlock(); try { await this.initCachedContracts({ blockHash: block.hash }); @@ -151,15 +149,6 @@ export class RepositoryService { DSM_ABI, SecurityAbi__factory.connect(address, provider), ); - - // prune dsm prefixes - this.cachedDSMPrefixes = {}; - - // re-init dsm prefixes - await Promise.all([ - this.getAttestMessagePrefix(), - this.getPauseMessagePrefix(), - ]); } /** @@ -180,7 +169,7 @@ export class RepositoryService { /** * Init cache for SR contract */ - private async initCachedStakingRouterAbiContract( + private async initCachedStakingRouterContract( blockTag: BlockTag, ): Promise { const stakingRouterAddress = @@ -194,27 +183,6 @@ export class RepositoryService { ); } - /** - * Returns a prefix from the contract with which the deposit message should be signed - */ - public async getAttestMessagePrefix(): Promise { - if (this.cachedDSMPrefixes.attest) return this.cachedDSMPrefixes.attest; - const contract = await this.getCachedDSMContract(); - this.cachedDSMPrefixes.attest = await contract.ATTEST_MESSAGE_PREFIX(); - return this.cachedDSMPrefixes.attest; - } - - /** - * Returns a prefix from the contract with which the pause message should be signed - */ - public async getPauseMessagePrefix(): Promise { - if (this.cachedDSMPrefixes.pause) return this.cachedDSMPrefixes.pause; - const contract = await this.getCachedDSMContract(); - this.cachedDSMPrefixes.pause = await contract.PAUSE_MESSAGE_PREFIX(); - - return this.cachedDSMPrefixes.pause; - } - /** * Returns Deposit contract address */ diff --git a/src/contracts/security/security.service.spec.ts b/src/contracts/security/security.service.spec.ts index e00cb734..8e7620d3 100644 --- a/src/contracts/security/security.service.spec.ts +++ b/src/contracts/security/security.service.spec.ts @@ -4,11 +4,10 @@ import { ConfigModule } from 'common/config'; import { LoggerModule } from 'common/logger'; import { MockProviderModule, ProviderService } from 'provider'; import { WalletService } from 'wallet'; -import { SecurityAbi__factory, StakingRouterAbi__factory } from 'generated'; +import { SecurityAbi__factory } from 'generated'; import { RepositoryModule, RepositoryService } from 'contracts/repository'; import { LocatorService } from 'contracts/repository/locator/locator.service'; import { Interface } from '@ethersproject/abi'; -import { BigNumber } from '@ethersproject/bignumber'; import { hexZeroPad } from '@ethersproject/bytes'; import { WINSTON_MODULE_NEST_PROVIDER } from 'nest-winston'; import { LoggerService } from '@nestjs/common'; @@ -32,8 +31,6 @@ describe('SecurityService', () => { let repositoryService: RepositoryService; let walletService: WalletService; let loggerService: LoggerService; - let mockGetAttestMessagePrefix: jest.SpyInstance, []>; - let mockGetPauseMessagePrefix: jest.SpyInstance, []>; beforeEach(async () => { const moduleRef = await Test.createTestingModule({ @@ -58,28 +55,7 @@ describe('SecurityService', () => { mockLocator(moduleRef.get(LocatorService)); - const repo = await mockRepository(repositoryService); - mockGetAttestMessagePrefix = repo.mockGetAttestMessagePrefix; - mockGetPauseMessagePrefix = repo.mockGetPauseMessagePrefix; - }); - - describe('getMaxDeposits', () => { - it('should return max deposits', async () => { - const expected = 10; - - const mockProviderCall = jest - .spyOn(providerService.provider, 'call') - .mockImplementation(async () => { - const iface = new Interface(SecurityAbi__factory.abi); - const result = [BigNumber.from(expected).toHexString()]; - return iface.encodeFunctionResult('getMaxDeposits', result); - }); - - const maxDeposits = await securityService.getMaxDeposits(); - expect(typeof maxDeposits).toBe('number'); - expect(maxDeposits).toBe(expected); - expect(mockProviderCall).toBeCalledTimes(1); - }); + await mockRepository(repositoryService); }); describe('getGuardians', () => { @@ -139,26 +115,30 @@ describe('SecurityService', () => { it('should add prefix', async () => { const prefix = hexZeroPad('0x1', 32); const depositRoot = hexZeroPad('0x2', 32); - const keysOpIndex = 1; + const nonce = 1; const blockNumber = 1; const blockHash = hexZeroPad('0x3', 32); const args = [ depositRoot, - keysOpIndex, + nonce, blockNumber, blockHash, TEST_MODULE_ID, ] as const; + const mockGetAttestMessagePrefix = jest + .spyOn(securityService, 'getAttestMessagePrefix') + .mockImplementation(async () => hexZeroPad('0x1', 32)); + const signDepositData = jest.spyOn(walletService, 'signDepositData'); const signature = await securityService.signDepositData(...args); - // 1 — repository, 2 — signDepositData - expect(mockGetAttestMessagePrefix).toBeCalledTimes(2); + + expect(mockGetAttestMessagePrefix).toBeCalledTimes(1); expect(signDepositData).toBeCalledWith({ prefix, depositRoot, - keysOpIndex, + nonce, blockNumber, blockHash, stakingModuleId: TEST_MODULE_ID, @@ -174,18 +154,23 @@ describe('SecurityService', () => { }); }); - describe('signPauseData', () => { + describe('signPauseDataV2', () => { it('should add prefix', async () => { const blockNumber = 1; + const blockHash = '0x'; + + const mockGetPauseMessagePrefix = jest + .spyOn(securityService, 'getPauseMessagePrefix') + .mockImplementation(async () => hexZeroPad('0x2', 32)); - const signPauseData = jest.spyOn(walletService, 'signPauseData'); + const signPauseData = jest.spyOn(walletService, 'signPauseDataV2'); - const signature = await securityService.signPauseData( + const signature = await securityService.signPauseDataV2( blockNumber, + blockHash, TEST_MODULE_ID, ); - // 1 — repository, 2 — signDepositData - expect(mockGetPauseMessagePrefix).toBeCalledTimes(2); + expect(mockGetPauseMessagePrefix).toBeCalledTimes(1); expect(signPauseData).toBeCalledWith({ blockNumber: 1, prefix: @@ -203,28 +188,42 @@ describe('SecurityService', () => { }); }); - describe('isDepositsPaused', () => { - it('should call contract method', async () => { - const expected = true; + describe('signPauseDataV3', () => { + it('should add prefix', async () => { + const blockNumber = 1; + const blockHash = '0x'; - const mockProviderCalla = jest - .spyOn(providerService.provider, 'call') - .mockImplementation(async () => { - const iface = new Interface(StakingRouterAbi__factory.abi); - return iface.encodeFunctionResult('getStakingModuleIsActive', [ - expected, - ]); - }); + const mockGetPauseMessagePrefix = jest + .spyOn(securityService, 'getPauseMessagePrefix') + .mockImplementation(async () => hexZeroPad('0x2', 32)); - const isPaused = await securityService.isDepositsPaused(TEST_MODULE_ID); - expect(isPaused).toBe(!expected); - expect(mockProviderCalla).toBeCalledTimes(1); + const signPauseData = jest.spyOn(walletService, 'signPauseDataV3'); + + const signature = await securityService.signPauseDataV3( + blockNumber, + blockHash, + ); + expect(mockGetPauseMessagePrefix).toBeCalledTimes(1); + expect(signPauseData).toBeCalledWith({ + blockNumber: 1, + prefix: + '0x0000000000000000000000000000000000000000000000000000000000000002', + }); + expect(signature).toEqual( + expect.objectContaining({ + _vs: expect.any(String), + r: expect.any(String), + s: expect.any(String), + v: expect.any(Number), + }), + ); }); }); - describe('pauseDeposits', () => { + describe('pauseDepositsV2', () => { const hash = hexZeroPad('0x1', 32); const blockNumber = 10; + const blockHash = '0x'; let mockWait; let mockPauseDeposits; @@ -234,27 +233,30 @@ describe('SecurityService', () => { beforeEach(async () => { mockWait = jest.fn().mockImplementation(async () => undefined); - const repo = await mockRepository(repositoryService); - mockGetPauseMessagePrefix = repo.mockGetPauseMessagePrefix; + await mockRepository(repositoryService); + mockGetPauseMessagePrefix = jest + .spyOn(securityService, 'getPauseMessagePrefix') + .mockImplementation(async () => hexZeroPad('0x2', 32)); mockPauseDeposits = jest .fn() .mockImplementation(async () => ({ wait: mockWait, hash })); mockGetContractWithSigner = jest - .spyOn(securityService, 'getContractWithSigner') + .spyOn(securityService, 'getContractWithSignerDeprecated') .mockImplementation( - async () => ({ pauseDeposits: mockPauseDeposits } as any), + () => ({ pauseDeposits: mockPauseDeposits } as any), ); - signature = await securityService.signPauseData( + signature = await securityService.signPauseDataV2( blockNumber, + blockHash, TEST_MODULE_ID, ); }); it('should call contract method', async () => { - await securityService.pauseDeposits( + await securityService.pauseDepositsV2( blockNumber, TEST_MODULE_ID, signature, @@ -262,26 +264,267 @@ describe('SecurityService', () => { expect(mockPauseDeposits).toBeCalledTimes(1); expect(mockWait).toBeCalledTimes(1); - // mockGetPauseMessagePrefix calls 3 times because - // we have more than one call under the hood - // 1 - repository, 2 — signPauseData, 3 — pauseDeposits - expect(mockGetPauseMessagePrefix).toBeCalledTimes(3); + expect(mockGetPauseMessagePrefix).toBeCalledTimes(1); expect(mockGetContractWithSigner).toBeCalledTimes(1); }); it('should exit if the previous call is not completed', async () => { await Promise.all([ - securityService.pauseDeposits(blockNumber, TEST_MODULE_ID, signature), - securityService.pauseDeposits(blockNumber, TEST_MODULE_ID, signature), + securityService.pauseDepositsV2(blockNumber, TEST_MODULE_ID, signature), + securityService.pauseDepositsV2(blockNumber, TEST_MODULE_ID, signature), ]); expect(mockPauseDeposits).toBeCalledTimes(1); expect(mockWait).toBeCalledTimes(1); - // mockGetPauseMessagePrefix calls 3 times because - // we have more than one call under the hood - // 1 - repository, 2 — signPauseData, 3 — pauseDeposits - expect(mockGetPauseMessagePrefix).toBeCalledTimes(3); + expect(mockGetPauseMessagePrefix).toBeCalledTimes(1); + expect(mockGetContractWithSigner).toBeCalledTimes(1); + }); + }); + + describe('pauseDepositsV3', () => { + const hash = hexZeroPad('0x1', 32); + const blockNumber = 10; + const blockHash = '0x'; + + let mockWait; + let mockPauseDeposits; + let mockGetPauseMessagePrefix; + let mockGetContractWithSigner; + let signature; + + beforeEach(async () => { + mockWait = jest.fn().mockImplementation(async () => undefined); + await mockRepository(repositoryService); + mockGetPauseMessagePrefix = jest + .spyOn(securityService, 'getPauseMessagePrefix') + .mockImplementation(async () => hexZeroPad('0x2', 32)); + + mockPauseDeposits = jest + .fn() + .mockImplementation(async () => ({ wait: mockWait, hash })); + + mockGetContractWithSigner = jest + .spyOn(securityService, 'getContractWithSigner') + .mockImplementation( + () => ({ pauseDeposits: mockPauseDeposits } as any), + ); + + signature = await securityService.signPauseDataV3(blockNumber, blockHash); + }); + + it('should call contract method', async () => { + await securityService.pauseDepositsV3(blockNumber, signature); + + expect(mockPauseDeposits).toBeCalledTimes(1); + expect(mockWait).toBeCalledTimes(1); + expect(mockGetPauseMessagePrefix).toBeCalledTimes(1); + expect(mockGetContractWithSigner).toBeCalledTimes(1); + }); + + it('should exit if the previous call is not completed', async () => { + await Promise.all([ + securityService.pauseDepositsV3(blockNumber, signature), + securityService.pauseDepositsV3(blockNumber, signature), + ]); + + expect(mockPauseDeposits).toBeCalledTimes(1); + expect(mockWait).toBeCalledTimes(1); + expect(mockGetPauseMessagePrefix).toBeCalledTimes(1); + expect(mockGetContractWithSigner).toBeCalledTimes(1); + }); + }); + + describe('signUnvetData', () => { + it('should add prefix', async () => { + const nonce = 1; + const blockNumber = 10; + const blockHash = hexZeroPad('0x3', 32); + const stakingModuleId = 1; + const operatorIds = '0x00000000000000010000000000000002'; + const vettedKeysByOperator = + '0x0000000000000000000000000000000000000000000000000000000000000002'; + + const mockGetUnvetMessagePrefix = jest + .spyOn(securityService, 'getUnvetMessagePrefix') + .mockImplementation(async () => hexZeroPad('0x2', 32)); + + const signUnvetData = jest.spyOn(walletService, 'signUnvetData'); + + const signature = await securityService.signUnvetData( + nonce, + blockNumber, + blockHash, + stakingModuleId, + operatorIds, + vettedKeysByOperator, + ); + expect(mockGetUnvetMessagePrefix).toBeCalledTimes(1); + expect(signUnvetData).toBeCalledWith({ + blockNumber, + blockHash, + stakingModuleId, + nonce, + operatorIds, + vettedKeysByOperator, + prefix: + '0x0000000000000000000000000000000000000000000000000000000000000002', + }); + expect(signature).toEqual( + expect.objectContaining({ + _vs: expect.any(String), + r: expect.any(String), + s: expect.any(String), + v: expect.any(Number), + }), + ); + }); + }); + + describe('unvetSigningKeys', () => { + const hash = hexZeroPad('0x1', 32); + + const nonce = 1; + const blockNumber = 10; + const blockHash = hexZeroPad('0x3', 32); + const stakingModuleId = 1; + const operatorIds = '0x00000000000000010000000000000002'; + const vettedKeysByOperator = + '0x0000000000000000000000000000000000000000000000000000000000000002'; + + let mockWait; + let mockUnvetSigningKeys; + let mockGetUnvetMessagePrefix; + let mockGetContractWithSigner; + let signature; + + beforeEach(async () => { + mockWait = jest.fn().mockImplementation(async () => undefined); + await mockRepository(repositoryService); + mockGetUnvetMessagePrefix = jest + .spyOn(securityService, 'getUnvetMessagePrefix') + .mockImplementation(async () => hexZeroPad('0x2', 32)); + + mockUnvetSigningKeys = jest + .fn() + .mockImplementation(async () => ({ wait: mockWait, hash })); + + mockGetContractWithSigner = jest + .spyOn(securityService, 'getContractWithSigner') + .mockImplementation( + () => ({ unvetSigningKeys: mockUnvetSigningKeys } as any), + ); + + signature = await securityService.signUnvetData( + nonce, + blockNumber, + blockHash, + stakingModuleId, + operatorIds, + vettedKeysByOperator, + ); + }); + + it('should call contract method', async () => { + await securityService.unvetSigningKeys( + nonce, + blockNumber, + blockHash, + stakingModuleId, + operatorIds, + vettedKeysByOperator, + signature, + ); + + expect(mockUnvetSigningKeys).toBeCalledTimes(1); + expect(mockWait).toBeCalledTimes(1); + expect(mockGetUnvetMessagePrefix).toBeCalledTimes(1); expect(mockGetContractWithSigner).toBeCalledTimes(1); }); + + it('should exit if the previous call is not completed', async () => { + await Promise.all([ + securityService.unvetSigningKeys( + nonce, + blockNumber, + blockHash, + stakingModuleId, + operatorIds, + vettedKeysByOperator, + signature, + ), + securityService.unvetSigningKeys( + nonce, + blockNumber, + blockHash, + stakingModuleId, + operatorIds, + vettedKeysByOperator, + signature, + ), + ]); + + expect(mockUnvetSigningKeys).toBeCalledTimes(1); + expect(mockWait).toBeCalledTimes(1); + expect(mockGetUnvetMessagePrefix).toBeCalledTimes(1); + expect(mockGetContractWithSigner).toBeCalledTimes(1); + }); + }); + + describe('messages prefixes', () => { + const blockHash = '0x'; + + beforeEach(async () => { + jest + .spyOn(repositoryService, 'getDepositAddress') + .mockImplementation(async () => '0x' + '5'.repeat(40)); + }); + + it('getAttestMessagePrefix', async () => { + const expected = '0x' + '1'.repeat(64); + + const mockProviderCall = jest + .spyOn(providerService.provider, 'call') + .mockImplementation(async () => { + const iface = new Interface(SecurityAbi__factory.abi); + const result = [expected]; + return iface.encodeFunctionResult('ATTEST_MESSAGE_PREFIX', result); + }); + + const prefix = await securityService.getAttestMessagePrefix(blockHash); + expect(prefix).toBe(expected); + expect(mockProviderCall).toBeCalledTimes(1); + }); + + it('getPauseMessagePrefix', async () => { + const expected = '0x' + '1'.repeat(64); + + const mockProviderCall = jest + .spyOn(providerService.provider, 'call') + .mockImplementation(async () => { + const iface = new Interface(SecurityAbi__factory.abi); + const result = [expected]; + return iface.encodeFunctionResult('PAUSE_MESSAGE_PREFIX', result); + }); + + const prefix = await securityService.getPauseMessagePrefix(blockHash); + expect(prefix).toBe(expected); + expect(mockProviderCall).toBeCalledTimes(1); + }); + + it('getUnvetMessagePrefix', async () => { + const expected = '0x' + '1'.repeat(64); + + const mockProviderCall = jest + .spyOn(providerService.provider, 'call') + .mockImplementation(async () => { + const iface = new Interface(SecurityAbi__factory.abi); + const result = [expected]; + return iface.encodeFunctionResult('UNVET_MESSAGE_PREFIX', result); + }); + + const prefix = await securityService.getUnvetMessagePrefix(blockHash); + expect(prefix).toBe(expected); + expect(mockProviderCall).toBeCalledTimes(1); + }); }); }); diff --git a/src/contracts/security/security.service.ts b/src/contracts/security/security.service.ts index 02a70625..78f1ac47 100644 --- a/src/contracts/security/security.service.ts +++ b/src/contracts/security/security.service.ts @@ -2,9 +2,16 @@ import { Signature } from '@ethersproject/bytes'; import { ContractReceipt } from '@ethersproject/contracts'; import { Inject, Injectable, LoggerService } from '@nestjs/common'; import { InjectMetric } from '@willsoto/nestjs-prometheus'; -import { METRIC_PAUSE_ATTEMPTS } from 'common/prometheus'; -import { OneAtTime } from 'common/decorators'; +import { + METRIC_PAUSE_ATTEMPTS, + METRIC_UNVET_ATTEMPTS, +} from 'common/prometheus'; +import { OneAtTime, OneAtTimeCallId } from 'common/decorators'; import { SecurityAbi } from 'generated'; +import { + SecurityDeprecatedPauseAbi, + SecurityDeprecatedPauseAbi__factory, +} from 'generated'; import { RepositoryService } from 'contracts/repository'; import { WINSTON_MODULE_NEST_PROVIDER } from 'nest-winston'; import { Counter } from 'prom-client'; @@ -15,6 +22,7 @@ import { WalletService } from 'wallet'; export class SecurityService { constructor( @InjectMetric(METRIC_PAUSE_ATTEMPTS) private pauseAttempts: Counter, + @InjectMetric(METRIC_UNVET_ATTEMPTS) private unvetAttempts: Counter, @Inject(WINSTON_MODULE_NEST_PROVIDER) private logger: LoggerService, private providerService: ProviderService, private repositoryService: RepositoryService, @@ -35,26 +43,33 @@ export class SecurityService { /** * Returns an instance of the contract that can send signed transactions */ - public async getContractWithSigner(): Promise { + public getContractWithSigner(): SecurityAbi { const wallet = this.walletService.wallet; const provider = this.providerService.provider; const walletWithProvider = wallet.connect(provider); - const contract = await this.repositoryService.getCachedDSMContract(); + const contract = this.repositoryService.getCachedDSMContract(); const contractWithSigner = contract.connect(walletWithProvider); return contractWithSigner; } /** - * Returns the maximum number of deposits per transaction from the contract + * Returns an instance of the deprecated v2 security contract with only the `pause` method. */ - public async getMaxDeposits(blockTag?: BlockTag): Promise { - const contract = await this.repositoryService.getCachedDSMContract(); - const maxDeposits = await contract.getMaxDeposits({ - blockTag: blockTag as any, - }); + public getContractWithSignerDeprecated(): SecurityDeprecatedPauseAbi { + const contract = this.repositoryService.getCachedDSMContract(); + + const oldContract = SecurityDeprecatedPauseAbi__factory.connect( + contract.address, + this.providerService.provider, + ); + + const wallet = this.walletService.wallet; + const provider = this.providerService.provider; + const walletWithProvider = wallet.connect(provider); + const contractWithSigner = oldContract.connect(walletWithProvider); - return maxDeposits.toNumber(); + return contractWithSigner; } /** @@ -88,20 +103,27 @@ export class SecurityService { /** * Signs a message to deposit buffered ethers with the prefix from the contract + * + * @param depositRoot: Root of deposit contract + * @param nonce - Current index of keys operations from the registry contract + * @param blockNumber - The block number, included as part of the message for signing. + * @param blockHash - The block hash, included as part of the message for signing and is used to fetch the pause prefix + * @param stakingModuleId - The staking module ID, included as part of the message for signing. + * @returns Signature for deposit. */ public async signDepositData( depositRoot: string, - keysOpIndex: number, + nonce: number, blockNumber: number, blockHash: string, stakingModuleId: number, ): Promise { - const prefix = await this.repositoryService.getAttestMessagePrefix(); + const prefix = await this.getAttestMessagePrefix(blockHash); return await this.walletService.signDepositData({ prefix, depositRoot, - keysOpIndex, + nonce, blockNumber, blockHash, stakingModuleId, @@ -109,39 +131,81 @@ export class SecurityService { } /** - * Signs a message to pause deposits with the prefix from the contract + * Signs a message to pause deposits, including the pause prefix from the contract. + * + * @param blockNumber - The block number, included as part of the message for signing. + * @param blockHash - The block hash, used to fetch the pause prefix. + * @returns Signature for pausing deposits. */ - public async signPauseData( + public async signPauseDataV3( blockNumber: number, - stakingModuleId: number, + blockHash: string, ): Promise { - const prefix = await this.repositoryService.getPauseMessagePrefix(); + const prefix = await this.getPauseMessagePrefix(blockHash); - return await this.walletService.signPauseData({ + return await this.walletService.signPauseDataV3({ prefix, blockNumber, - stakingModuleId, }); } /** - * Returns the current state of deposits + * Sends a transaction to pause deposits + * @param blockNumber - the block number for which the message is signed + * @param signature - message signature */ - public async isDepositsPaused( + @OneAtTime() + public async pauseDepositsV3( + pauseBlockNumber: number, + signature: Signature, + ): Promise { + this.logger.warn('Try to pause deposits', { pauseBlockNumber }); + this.pauseAttempts.inc(); + + const contract = this.getContractWithSigner(); + + const { r, _vs: vs } = signature; + const tx = await contract.pauseDeposits(pauseBlockNumber, { + r, + vs, + }); + + this.logger.warn('Pause transaction sent', { + txHash: tx.hash, + pauseBlockNumber, + }); + this.logger.warn('Waiting for block confirmation', { pauseBlockNumber }); + + const receipt = await tx.wait(); + + this.logger.warn('Block confirmation received for the pause tx', { + pauseBlockNumber, + txHash: tx.hash, + }); + + return receipt; + } + + /** + * Signs a message to pause deposits, including the pause prefix from the contract. + * + * @param blockNumber - The block number, included as part of the message for signing. + * @param blockHash - The block hash, used to fetch the pause prefix. + * @param stakingModuleId - The staking module ID, included as part of the message for signing. + * @returns Signature for pausing deposits. + */ + public async signPauseDataV2( + blockNumber: number, + blockHash: string, stakingModuleId: number, - blockTag?: BlockTag, - ): Promise { - const stakingRouterContract = - await this.repositoryService.getCachedStakingRouterContract(); + ): Promise { + const prefix = await this.getPauseMessagePrefix(blockHash); - const isActive = await stakingRouterContract.getStakingModuleIsActive( + return await this.walletService.signPauseDataV2({ + prefix, + blockNumber, stakingModuleId, - { - blockTag: blockTag as any, - }, - ); - - return !isActive; + }); } /** @@ -151,28 +215,209 @@ export class SecurityService { * @param signature - message signature */ @OneAtTime() - public async pauseDeposits( + public async pauseDepositsV2( blockNumber: number, - stakingModuleId: number, + @OneAtTimeCallId stakingModuleId: number, signature: Signature, - ): Promise { - this.logger.warn('Try to pause deposits'); + ): Promise { + this.logger.warn('Try to pause deposits', { stakingModuleId, blockNumber }); this.pauseAttempts.inc(); - const contract = await this.getContractWithSigner(); + const contract = this.getContractWithSignerDeprecated(); const { r, _vs: vs } = signature; - const tx = await contract.pauseDeposits(blockNumber, stakingModuleId, { r, vs, }); - this.logger.warn('Pause transaction sent', { txHash: tx.hash }); - this.logger.warn('Waiting for block confirmation'); + this.logger.warn('Pause transaction sent', { + txHash: tx.hash, + blockNumber, + stakingModuleId, + }); + this.logger.warn('Waiting for block confirmation', { + blockNumber, + stakingModuleId, + }); - await tx.wait(); + const receipt = await tx.wait(); - this.logger.warn('Block confirmation received'); + this.logger.warn('Block confirmation received', { + blockNumber, + stakingModuleId, + }); + + return receipt; + } + + /** + * Signs a message to unvet keys for a staking module. + * + * @param nonce - The nonce for the staking module. + * @param blockNumber - The block number at which the message is signed. + * @param blockHash - The hash of the block corresponding to the block number, used to fetch the pause prefix. + * @param stakingModuleId - The ID of the target staking module. + * @param operatorIds - A string containing the IDs of the operators whose keys are being unvetted. + * @param vettedKeysByOperator - A string representing the new staking limit amount per operator. + * + * @returns A signature object containing the signed data. + */ + public async signUnvetData( + nonce: number, + blockNumber: number, + blockHash: string, + stakingModuleId: number, + operatorIds: string, + vettedKeysByOperator: string, + ): Promise { + const prefix = await this.getUnvetMessagePrefix(blockHash); + + return await this.walletService.signUnvetData({ + prefix, + blockNumber, + blockHash, + stakingModuleId, + nonce, + operatorIds, + vettedKeysByOperator, + }); + } + + /** + * Sends a transaction to unvet signing keys for a staking module. + * + * @param nonce - The nonce for the staking module. + * @param blockNumber - The block number at which the message is signed. + * @param blockHash - The hash of the block corresponding to the block number. + * @param stakingModuleId - The ID of the target staking module. + * @param operatorIds - A string containing the IDs of the operators whose keys are being unvetted. + * @param vettedKeysByOperator - A string representing the new staking limit amount per operator. + * @param signature - The signature of the message, containing `r` and `_vs`. + * + * @returns The transaction receipt or `void` if the transaction fails. + */ + @OneAtTime() + public async unvetSigningKeys( + nonce: number, + blockNumber: number, + blockHash: string, + @OneAtTimeCallId stakingModuleId: number, + operatorIds: string, + vettedKeysByOperator: string, + signature: Signature, + ): Promise { + this.logger.warn('Try to unvet keys for staking module', { + stakingModuleId, + blockNumber, + }); + this.unvetAttempts.inc(); + + const contract = this.getContractWithSigner(); + + const { r, _vs: vs } = signature; + const tx = await contract.unvetSigningKeys( + blockNumber, + blockHash, + stakingModuleId, + nonce, + operatorIds, + vettedKeysByOperator, + { + r, + vs, + }, + ); + + this.logger.warn('Unvet transaction sent', { + txHash: tx.hash, + blockNumber, + stakingModuleId, + }); + this.logger.warn('Waiting for block confirmation', { + blockNumber, + stakingModuleId, + }); + + const receipt = await tx.wait(); + + this.logger.warn('Block confirmation received', { + blockNumber, + stakingModuleId, + }); + + return receipt; + } + + /** + * Return the maximum number of operators in one unvetting transaction + */ + public async getMaxOperatorsPerUnvetting( + blockTag?: BlockTag, + ): Promise { + const contract = this.getContractWithSigner(); + + const maxOperatorsPerUnvetting = await contract.getMaxOperatorsPerUnvetting( + { + blockTag: blockTag as any, + }, + ); + + return maxOperatorsPerUnvetting.toNumber(); + } + + public async version(blockTag?: BlockTag): Promise { + const contract = this.getContractWithSigner(); + try { + const version = await contract.VERSION({ + blockTag: blockTag as any, + }); + return version.toNumber(); + } catch (error) { + this.logger.warn( + 'Error while fetching the version; the locator may have returned an outdated version of the DSM contract', + ); + + return 2; + } + } + + /** + * Check if deposits paused + */ + public async isDepositsPaused(blockTag?: BlockTag) { + const contract = await this.repositoryService.getCachedDSMContract(); + + return contract.isDepositsPaused({ blockTag: blockTag as any }); + } + + /** + * Returns a prefix from the contract with which the deposit message should be signed + */ + public async getAttestMessagePrefix(blockHash: string): Promise { + const contract = await this.repositoryService.getCachedDSMContract(); + return await contract.ATTEST_MESSAGE_PREFIX({ + blockTag: { blockHash } as any, + }); + } + + /** + * Returns a prefix from the contract with which the pause message should be signed + */ + public async getPauseMessagePrefix(blockHash: string): Promise { + const contract = await this.repositoryService.getCachedDSMContract(); + return await contract.PAUSE_MESSAGE_PREFIX({ + blockTag: { blockHash } as any, + }); + } + + /** + * Returns a prefix from the contract with which the pause message should be signed + */ + public async getUnvetMessagePrefix(blockHash: string): Promise { + const contract = await this.repositoryService.getCachedDSMContract(); + return await contract.UNVET_MESSAGE_PREFIX({ + blockTag: { blockHash } as any, + }); } } diff --git a/src/contracts/signing-keys-registry/fetcher/fetcher.module.ts b/src/contracts/signing-keys-registry/fetcher/fetcher.module.ts new file mode 100644 index 00000000..9b3831eb --- /dev/null +++ b/src/contracts/signing-keys-registry/fetcher/fetcher.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { StakingRouterModule } from 'contracts/staking-router'; +import { SigningKeysRegistryFetcherService } from './fetcher.service'; + +@Module({ + imports: [StakingRouterModule], + providers: [SigningKeysRegistryFetcherService], + exports: [SigningKeysRegistryFetcherService], +}) +export class SigningKeysRegistryFetcherModule {} diff --git a/src/contracts/signing-keys-registry/fetcher/fetcher.service.ts b/src/contracts/signing-keys-registry/fetcher/fetcher.service.ts new file mode 100644 index 00000000..78e6d994 --- /dev/null +++ b/src/contracts/signing-keys-registry/fetcher/fetcher.service.ts @@ -0,0 +1,86 @@ +import { Inject, Injectable, LoggerService } from '@nestjs/common'; +import { StakingRouterService } from 'contracts/staking-router'; +import { WINSTON_MODULE_NEST_PROVIDER } from 'nest-winston'; +import { ProviderService } from 'provider'; +import { + SigningKeyEvent, + SigningKeyEventsGroup, +} from '../interfaces/event.interface'; + +@Injectable() +export class SigningKeysRegistryFetcherService { + constructor( + @Inject(WINSTON_MODULE_NEST_PROVIDER) private logger: LoggerService, + private providerService: ProviderService, + private stakingRouterService: StakingRouterService, + ) {} + + /** + * Fetches signing key events within a specified block range, with fallback mechanisms. + * If the request failed, it tries to repeat it or split it into two + * + * @param {number} startBlock - The starting block number of the range. + * @param {number} endBlock - The ending block number of the range. + * @returns {Promise} Events fetched within the specified block range + */ + public async fetchEventsFallOver( + startBlock: number, + endBlock: number, + stakingModulesAddresses: string[], + ): Promise { + const fetcherWrapper = (start: number, end: number) => + this.fetchEvents(start, end, stakingModulesAddresses); + + return await this.providerService.fetchEventsFallOver( + startBlock, + endBlock, + fetcherWrapper, + ); + } + + /** + * Fetches signing key events within a specified block range from staking module contracts. + * + * @param {number} startBlock - The starting block number of the range. + * @param {number} endBlock - The ending block number of the range. + * @returns {Promise} Events fetched within the specified block range. + */ + public async fetchEvents( + startBlock: number, + endBlock: number, + stakingModulesAddresses: string[], + ): Promise { + const events: SigningKeyEvent[] = []; + + await Promise.all( + stakingModulesAddresses.map(async (address) => { + const rawEvents = + await this.stakingRouterService.getSigningKeyAddedEvents( + startBlock, + endBlock, + address, + ); + + const moduleEvents: SigningKeyEvent[] = rawEvents.map((rawEvent) => { + return { + operatorIndex: rawEvent.args[0].toNumber(), + key: rawEvent.args[1], + moduleAddress: address, + blockNumber: rawEvent.blockNumber, + logIndex: rawEvent.logIndex, + blockHash: rawEvent.blockHash, + }; + }); + + events.push(...moduleEvents); + + this.logger.log('Fetched signing keys add events for staking module', { + count: moduleEvents.length, + address, + }); + }), + ); + + return { events, startBlock, endBlock }; + } +} diff --git a/src/contracts/signing-keys-registry/fetcher/index.ts b/src/contracts/signing-keys-registry/fetcher/index.ts new file mode 100644 index 00000000..128136bc --- /dev/null +++ b/src/contracts/signing-keys-registry/fetcher/index.ts @@ -0,0 +1,2 @@ +export * from './fetcher.module'; +export * from './fetcher.service'; diff --git a/src/contracts/signing-keys-registry/index.ts b/src/contracts/signing-keys-registry/index.ts new file mode 100644 index 00000000..a5530e09 --- /dev/null +++ b/src/contracts/signing-keys-registry/index.ts @@ -0,0 +1,2 @@ +export * from './signing-keys-registry.module'; +export * from './signing-keys-registry.service'; diff --git a/src/contracts/signing-keys-registry/interfaces/cache.interface.ts b/src/contracts/signing-keys-registry/interfaces/cache.interface.ts new file mode 100644 index 00000000..eac22b32 --- /dev/null +++ b/src/contracts/signing-keys-registry/interfaces/cache.interface.ts @@ -0,0 +1,12 @@ +import { SigningKeyEvent } from './event.interface'; + +export interface SigningKeyEventsCacheHeaders { + stakingModulesAddresses: string[]; + startBlock: number; + endBlock: number; +} + +export interface SigningKeyEventsCache { + headers: SigningKeyEventsCacheHeaders; + data: SigningKeyEvent[]; +} diff --git a/src/contracts/signing-keys-registry/interfaces/event.interface.ts b/src/contracts/signing-keys-registry/interfaces/event.interface.ts new file mode 100644 index 00000000..e84c01a0 --- /dev/null +++ b/src/contracts/signing-keys-registry/interfaces/event.interface.ts @@ -0,0 +1,18 @@ +export interface SigningKeyEvent { + operatorIndex: number; + key: string; + moduleAddress: string; + logIndex: number; + blockNumber: number; + blockHash: string; +} + +export interface SigningKeyEventsGroup { + events: SigningKeyEvent[]; + startBlock: number; + endBlock: number; +} +export interface SigningKeyEventsGroupWithStakingModules + extends SigningKeyEventsGroup { + stakingModulesAddresses: string[]; +} diff --git a/src/contracts/signing-keys-registry/sanity-checker/index.ts b/src/contracts/signing-keys-registry/sanity-checker/index.ts new file mode 100644 index 00000000..04e24741 --- /dev/null +++ b/src/contracts/signing-keys-registry/sanity-checker/index.ts @@ -0,0 +1,2 @@ +export * from './sanity-checker.module'; +export * from './sanity-checker.service'; diff --git a/src/contracts/signing-keys-registry/sanity-checker/sanity-checker.module.ts b/src/contracts/signing-keys-registry/sanity-checker/sanity-checker.module.ts new file mode 100644 index 00000000..8cbedd71 --- /dev/null +++ b/src/contracts/signing-keys-registry/sanity-checker/sanity-checker.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { SigningKeysRegistrySanityCheckerService } from './sanity-checker.service'; + +@Module({ + providers: [SigningKeysRegistrySanityCheckerService], + exports: [SigningKeysRegistrySanityCheckerService], +}) +export class SigningKeysRegistrySanityCheckerModule {} diff --git a/src/contracts/signing-keys-registry/sanity-checker/sanity-checker.service.ts b/src/contracts/signing-keys-registry/sanity-checker/sanity-checker.service.ts new file mode 100644 index 00000000..05f96ea0 --- /dev/null +++ b/src/contracts/signing-keys-registry/sanity-checker/sanity-checker.service.ts @@ -0,0 +1,93 @@ +import { Inject, Injectable, LoggerService } from '@nestjs/common'; + +import { WINSTON_MODULE_NEST_PROVIDER } from 'nest-winston'; + +import { SigningKeyEventsCache } from '../interfaces/cache.interface'; +import { SigningKeyEvent } from '../interfaces/event.interface'; + +@Injectable() +export class SigningKeysRegistrySanityCheckerService { + constructor( + @Inject(WINSTON_MODULE_NEST_PROVIDER) private logger: LoggerService, + ) {} + + /** + * Validates the block number in the cached events against the current block number. + * + * This method checks if the cached events are up to date by comparing the current block number + * with the end block number in the cache. It logs a message if the cache is valid and a warning if it is not. + * + * @param {SigningKeyEventsCache} cachedEvents - The cached events containing block headers to validate. + * @param {number} currentBlock - The current block number to compare against the cached block. + * @returns {boolean} `true` if the cache is valid (i.e., the current block number is greater than or equal to the cached end block), `false` otherwise. + */ + public verifyCacheBlock( + cachedEvents: SigningKeyEventsCache, + currentBlock: number, + ): boolean { + const isCacheValid = currentBlock >= cachedEvents.headers.endBlock; + + const blocks = { + cachedStartBlock: cachedEvents.headers.startBlock, + cachedEndBlock: cachedEvents.headers.endBlock, + currentBlock, + }; + + if (isCacheValid) { + this.logger.log('Signing keys events cache has valid age', blocks); + } + + if (!isCacheValid) { + this.logger.warn( + 'Signing key events cache is newer than the current block', + blocks, + ); + } + + return isCacheValid; + } + + /** + * Validates the block hash of signing key events. + * + * This method checks each event's block hash against the provided block hash, but only if the event's block number + * matches the given `blockNumber`. This ensures that the events are not from an alternate chain (e.g., due to a chain reorganization). + * If a block number match is found but the block hashes do not match, an error is thrown. + * + * @param {SigningKeyEvent[]} events - The list of signing key events to be checked. + * @param {number} blockNumber - The block number to match against the events' block numbers. + * @param {string} blockHash - The block hash to match against the events' block hashes. + */ + public checkEventsBlockHash( + events: SigningKeyEvent[], + blockNumber: number, + blockHash: string, + ): boolean { + const event = this.findReorganizedEvent(events, blockNumber, blockHash); + if (event) { + this.logger.error('Reorganization found in signing key event', { + blockHash: event.blockHash, + blockNumber: event.blockNumber, + }); + return false; + } + return true; + } + + /** + * Checks events block hash + * An additional check to avoid events processing in an alternate chain + */ + private findReorganizedEvent( + events: SigningKeyEvent[], + blockNumber: number, + blockHash: string, + ): SigningKeyEvent | null { + return ( + events.find( + (event) => + event.blockNumber === blockNumber && event.blockHash !== blockHash, + ) || null + ); + } +} diff --git a/src/contracts/signing-keys-registry/signing-keys-registry.constants.ts b/src/contracts/signing-keys-registry/signing-keys-registry.constants.ts new file mode 100644 index 00000000..dd0829c6 --- /dev/null +++ b/src/contracts/signing-keys-registry/signing-keys-registry.constants.ts @@ -0,0 +1,22 @@ +import { CHAINS } from '@lido-sdk/constants'; + +export const SIGNING_KEYS_CACHE_DEFAULT = Object.freeze({ + headers: { + stakingModulesAddresses: [], + startBlock: 0, + endBlock: 0, + }, + data: [], +}); + +export const EARLIEST_MODULE_DEPLOYMENT_BLOCK_NETWORK: { + [key in CHAINS]?: number; +} = { + [CHAINS.Mainnet]: 11473216, + [CHAINS.Holesky]: 0, +}; + +// will make a gap in case of reorganization +export const SIGNING_KEYS_EVENTS_CACHE_LAG_BLOCKS = 100; +export const FETCHING_EVENTS_STEP = 10_000; +export const SIGNING_KEYS_REGISTRY_FINALIZED_TAG = 'finalized'; diff --git a/src/contracts/signing-keys-registry/signing-keys-registry.module.ts b/src/contracts/signing-keys-registry/signing-keys-registry.module.ts new file mode 100644 index 00000000..1bbbdb93 --- /dev/null +++ b/src/contracts/signing-keys-registry/signing-keys-registry.module.ts @@ -0,0 +1,41 @@ +import { DynamicModule, Module } from '@nestjs/common'; +import { SigningKeysStoreModule } from './store'; +import { SigningKeysRegistryService } from './signing-keys-registry.service'; +import { + SIGNING_KEYS_CACHE_DEFAULT, + SIGNING_KEYS_REGISTRY_FINALIZED_TAG, +} from './signing-keys-registry.constants'; +import { SigningKeysRegistryFetcherModule } from './fetcher'; +import { SigningKeysRegistrySanityCheckerModule } from './sanity-checker'; + +@Module({}) +export class SigningKeysRegistryModule { + /** + * Registers the signing keys module with a specific tag to handle block finality. + * The `finalizedTag` is primarily used to address issues with the Ganache handling of the 'finalized' tag, + * where it needs to be substituted with 'latest' for end-to-end tests. This tag is necessary only on a Ethereum node + * to avoid issues with blockchain reorganizations. + * In a production environment, this argument should either be empty or set to 'finalized'. + * + * @param {string} [finalizedTag='finalized'] - The tag to be used for identifying the status of blocks concerning finality. + * @returns {DynamicModule} - The dynamic module configuration for the Deposits Registry. + */ + static register(finalizedTag = 'finalized'): DynamicModule { + return { + module: SigningKeysRegistryModule, + imports: [ + SigningKeysRegistryFetcherModule, + SigningKeysRegistrySanityCheckerModule, + SigningKeysStoreModule.register(SIGNING_KEYS_CACHE_DEFAULT), + ], + providers: [ + SigningKeysRegistryService, + { + provide: SIGNING_KEYS_REGISTRY_FINALIZED_TAG, + useValue: finalizedTag, + }, + ], + exports: [SigningKeysRegistryService], + }; + } +} diff --git a/src/contracts/signing-keys-registry/signing-keys-registry.service.ts b/src/contracts/signing-keys-registry/signing-keys-registry.service.ts new file mode 100644 index 00000000..67d4f697 --- /dev/null +++ b/src/contracts/signing-keys-registry/signing-keys-registry.service.ts @@ -0,0 +1,356 @@ +import { Inject, Injectable, LoggerService } from '@nestjs/common'; +import { WINSTON_MODULE_NEST_PROVIDER } from 'nest-winston'; +import { ProviderService } from 'provider'; +import { SigningKeyEventsGroupWithStakingModules } from './interfaces/event.interface'; +import { SigningKeysStoreService } from './store'; +import { SigningKeyEventsCache } from './interfaces/cache.interface'; +import { + EARLIEST_MODULE_DEPLOYMENT_BLOCK_NETWORK, + FETCHING_EVENTS_STEP, + SIGNING_KEYS_REGISTRY_FINALIZED_TAG, +} from './signing-keys-registry.constants'; +import { performance } from 'perf_hooks'; +import { SigningKeysRegistryFetcherService } from './fetcher'; +import { SigningKeysRegistrySanityCheckerService } from './sanity-checker/sanity-checker.service'; + +@Injectable() +export class SigningKeysRegistryService { + constructor( + @Inject(WINSTON_MODULE_NEST_PROVIDER) private logger: LoggerService, + private providerService: ProviderService, + private store: SigningKeysStoreService, + private fetcher: SigningKeysRegistryFetcherService, + private sanityChecker: SigningKeysRegistrySanityCheckerService, + @Inject(SIGNING_KEYS_REGISTRY_FINALIZED_TAG) private finalizedTag: string, + ) {} + + /** + * Handles the logic for processing a new block. + * + * This method checks if the staking module list has been updated and, if so, deletes the cache and updates the events cache. + * If the staking module list has not been updated, it checks whether the block number is divisible by the + * `SIGNING_KEY_EVENTS_CACHE_UPDATE_BLOCK_RATE` and, if true, updates the events cache. + * + * @param {number} blockNumber - The block number of the newly processed block. + * @returns {Promise} + */ + public async handleNewBlock( + currentStakingModulesAddresses: string[], + ): Promise { + await this.updateEventsCache(currentStakingModulesAddresses); + } + + /** + * Initialize or update cache + * @param {number} blockNumber - The block number to validate the cache against. + * @returns {Promise} + */ + public async initialize(currentStakingModulesAddresses: string[]) { + await this.store.initialize(); + await this.updateEventsCache(currentStakingModulesAddresses); + } + + /** + * Updates the cache signing keys events + * The last N blocks are not stored, in order to avoid storing reorganized blocks + * + * @returns {Promise} The block number up to which the cache has been updated. + */ + public async updateEventsCache( + currentStakingModulesAddresses: string[], + ): Promise { + const fetchTimeStart = performance.now(); + + const wasUpdated = await this.stakingModuleListWasUpdated( + currentStakingModulesAddresses, + ); + + if (wasUpdated) { + this.logger.log('Staking module list was updated. Deleting cache'); + await this.store.deleteCache(); + } + + const [finalizedBlock, initialCache] = await Promise.all([ + this.providerService.getBlock(this.finalizedTag), + this.getCachedEvents(), + ]); + + const { number: finalizedBlockNumber } = finalizedBlock; + const firstNotCachedBlock = initialCache.headers.endBlock + 1; + + const totalEventsCount = initialCache.data.length; + let newEventsCount = 0; + + // check that the cache is written to a block less than or equal to the current block + // otherwise we consider that the Ethereum node has started sending incorrect data + const isCacheValid = this.sanityChecker.verifyCacheBlock( + initialCache, + finalizedBlockNumber, + ); + + if (!isCacheValid) return; + + for ( + let block = firstNotCachedBlock; + block <= finalizedBlockNumber; + block += FETCHING_EVENTS_STEP + ) { + const chunkStartBlock = block; + const chunkToBlock = Math.min( + finalizedBlockNumber, + block + FETCHING_EVENTS_STEP - 1, + ); + + const chunkEventGroup = await this.fetcher.fetchEventsFallOver( + chunkStartBlock, + chunkToBlock, + currentStakingModulesAddresses, + ); + + await this.store.insertEventsCacheBatch({ + headers: { + ...initialCache.headers, + // as we update staking modules addresses always before run of this method, we can update value on every iteration + stakingModulesAddresses: currentStakingModulesAddresses, + endBlock: chunkEventGroup.endBlock, + }, + data: chunkEventGroup.events, + }); + + newEventsCount += chunkEventGroup.events.length; + + this.logger.log('Historical signing key add events are fetched', { + finalizedBlockNumber, + startBlock: chunkStartBlock, + endBlock: chunkToBlock, + }); + } + + const fetchTimeEnd = performance.now(); + const fetchTime = Math.ceil(fetchTimeEnd - fetchTimeStart) / 1000; + // TODO: replace timer with metric + + this.logger.log('Signing key events cache is updated', { + newEventsCount, + totalEventsCount: totalEventsCount + newEventsCount, + fetchTime, + }); + } + + /** + * Checks if the list of staking modules has been updated. + * + * This method compares the current list of staking modules with the previously cached list. + * If the list has changed, it logs a warning and indicates that the cache needs to be cleared and updated. + * + * @returns {Promise} Return `true` if the staking modules list was updated, `false` otherwise. + */ + public async stakingModuleListWasUpdated( + currentModules: string[], + ): Promise { + const { + headers: { stakingModulesAddresses: previousModules }, + } = await this.store.getHeader(); + + const wasUpdated = this.wasStakingModulesListUpdated( + previousModules, + currentModules, + ); + + if (wasUpdated) { + this.logger.warn( + 'Staking module list was changed. Need to clear and update cache', + { + previousModules, + currentModules, + }, + ); + } + + return wasUpdated; + } + + /** + * Compares the previous and current lists of staking modules to determine if any changes have occurred. + * + * This method checks if any staking modules were added or deleted by comparing the previous + * and current lists of staking modules. + * + * @param {string[]} previousModules - The list of staking modules from the previous cache. + * @param {string[]} currentModules - The current list of staking modules. + * @returns {boolean} `true` if the staking modules list was updated (modules were added or deleted), `false` otherwise. + */ + public wasStakingModulesListUpdated( + previousModules: string[], + currentModules: string[], + ) { + const modulesWereDeleted = previousModules.some( + (sm) => !currentModules.includes(sm), + ); + const modulesWereAdded = currentModules.some( + (module) => !previousModules.includes(module), + ); + + return modulesWereDeleted || modulesWereAdded; + } + + /** + * Retrieves signing key events data from the cache. + * + * This method fetches cached signing key events along with their associated headers. + * If the headers have default values (like 0 for the start and end block numbers), + * these values are updated to reflect the actual deployment block of the network. + * + * @returns {Promise} A promise that resolves to a `SigningKeyEventsCache` object, + * containing the cached signing key events and their metadata. + */ + public async getCachedEvents(): Promise { + const { headers, data } = await this.store.getEventsCache(); + + // default values is startBlock: 0, endBlock: 0 + const deploymentBlock = await this.getDeploymentBlockByNetwork(); + + return { + headers: { + ...headers, + startBlock: Math.max(headers.startBlock, deploymentBlock), + endBlock: Math.max(headers.endBlock, deploymentBlock), + }, + data, + }; + } + + /** + * Retrieves signing key events from the cache for the specified operators' keys. + * + * This method takes a list of operators' keys, ensures the list contains unique keys, + * and then fetches the corresponding events from the cache. + * + * @param {string[]} keys - An array of operators' keys for which to retrieve events. + * @returns {Promise} Events associated with the specified keys. + */ + public async getEventsForOperatorsKeys( + keys: string[], + ): Promise { + const uniqueKeys = Array.from(new Set(keys)); + return await this.store.getCachedEvents(uniqueKeys); + } + + /** + * Retrieves and returns all signing key events based on cached data and fresh data for a given key. + * + * This method combines cached signing key events with newly fetched events for a specific key, + * ensuring the cache is valid and updating the cache if necessary. + * + * @param {string} key - The specific signing key to retrieve events for. + * @param {number} blockNumber - The block number up to which the events should be retrieved. + * @param {string} blockHash - The block hash used to verify the integrity of the retrieved events. + * @returns {Promise} merged signing key events and associated staking module addresses. + */ + public async getUpdatedSigningKeyEvents( + key: string, + blockNumber: number, + blockHash: string, + ): Promise { + const endBlock = blockNumber; + const cachedEvents = await this.getEventsForOperatorsKeys([key]); + + const isCacheValid = this.sanityChecker.verifyCacheBlock( + cachedEvents, + blockNumber, + ); + + if (!isCacheValid) { + throw new Error( + `Signing key events cache is newer than the current block: ${blockNumber}`, + ); + } + + const firstNotCachedBlock = cachedEvents.headers.endBlock + 1; + + const freshEventGroup = await this.fetcher.fetchEventsFallOver( + firstNotCachedBlock, + endBlock, + cachedEvents.headers.stakingModulesAddresses, + ); + const freshEvents = freshEventGroup.events; + const lastEvent = freshEvents[freshEvents.length - 1]; + const lastEventBlockHash = lastEvent?.blockHash; + + const isValid = this.sanityChecker.checkEventsBlockHash( + freshEvents, + blockNumber, + blockHash, + ); + + if (!isValid) { + throw new Error(`Reorganization found on block ${blockNumber}`); + } + + this.logger.debug?.('Fresh signing key add events are fetched', { + events: freshEvents.length, + startBlock: firstNotCachedBlock, + endBlock, + blockHash, + lastEventBlockHash, + }); + + const keyFreshEvents = freshEventGroup.events.filter( + (event) => event.key == key, + ); + + const mergedEvents = cachedEvents.data.concat(keyFreshEvents); + + this.logger.debug?.('Merged signing key add events', { + events: mergedEvents.length, + startBlock: firstNotCachedBlock, + endBlock, + blockHash, + lastEventBlockHash, + }); + + return { + events: mergedEvents, + stakingModulesAddresses: cachedEvents.headers.stakingModulesAddresses, + startBlock: cachedEvents.headers.startBlock, + endBlock, + }; + } + + /** + * Saves signing key events to the cache. + * + * This method first deletes the existing cache and then saves the provided signing key events + * and their associated headers to the cache. + * + * @param {SigningKeyEventsCache} cachedEvents - An object containing the signing key events and headers to be saved to the cache. + * @returns {Promise} + */ + public async setCachedEvents( + cachedEvents: SigningKeyEventsCache, + ): Promise { + await this.store.deleteCache(); + await this.store.insertEventsCacheBatch({ + data: cachedEvents.data, + headers: cachedEvents.headers, + }); + } + + /** + * Retrieves the block number when the curated module contract was deployed for the current network. + * + * This method determines the deployment block number based on the current network's chain ID. + * If the chain ID is not supported, an error is thrown. + * + * @returns {Promise} Block number where the curated module contract was deployed. + * @throws {Error} If the chain ID is not supported. + */ + public async getDeploymentBlockByNetwork(): Promise { + const chainId = await this.providerService.getChainId(); + + const block = EARLIEST_MODULE_DEPLOYMENT_BLOCK_NETWORK[chainId]; + if (block == null) throw new Error(`Chain ${chainId} is not supported`); + + return block; + } +} diff --git a/src/contracts/signing-keys-registry/signing-keys-registry.spec.ts b/src/contracts/signing-keys-registry/signing-keys-registry.spec.ts new file mode 100644 index 00000000..9352293b --- /dev/null +++ b/src/contracts/signing-keys-registry/signing-keys-registry.spec.ts @@ -0,0 +1,176 @@ +import { Test } from '@nestjs/testing'; +import { Block } from '@ethersproject/abstract-provider'; +import { MockProviderModule, ProviderService } from 'provider'; +import { ConfigModule } from 'common/config'; +import { LoggerModule } from 'common/logger'; +import { RepositoryModule, RepositoryService } from 'contracts/repository'; +import { SigningKeysStoreService, SigningKeysStoreModule } from './store'; +import { mockRepository } from 'contracts/repository/repository.mock'; +import { LocatorService } from 'contracts/repository/locator/locator.service'; +import { mockLocator } from 'contracts/repository/locator/locator.mock'; +import { cacheMock, newEvent } from './store/store.fixtures'; +import { SigningKeysRegistryModule } from './signing-keys-registry.module'; +import { SigningKeysRegistryService } from './signing-keys-registry.service'; +import { WINSTON_MODULE_NEST_PROVIDER } from 'nest-winston'; +import { SigningKeysRegistryFetcherService } from './fetcher'; + +describe('SigningKeysRegistryService', () => { + const defaultCacheValue = { + headers: {}, + data: [] as any[], + }; + + let dbService: SigningKeysStoreService; + let repositoryService: RepositoryService; + let locatorService: LocatorService; + let signingKeysRegistryService: SigningKeysRegistryService; + let signingKeysFetch: SigningKeysRegistryFetcherService; + let providerService: ProviderService; + + beforeEach(async () => { + const moduleRef = await Test.createTestingModule({ + imports: [ + ConfigModule.forRoot(), + MockProviderModule.forRoot(), + RepositoryModule, + SigningKeysStoreModule.register( + defaultCacheValue, + 'leveldb-spec', + 'signing-keys-spec', + ), + LoggerModule, + SigningKeysRegistryModule.register('latest'), + ], + }).compile(); + + dbService = moduleRef.get(SigningKeysStoreService); + repositoryService = moduleRef.get(RepositoryService); + locatorService = moduleRef.get(LocatorService); + signingKeysRegistryService = moduleRef.get(SigningKeysRegistryService); + signingKeysFetch = moduleRef.get(SigningKeysRegistryFetcherService); + providerService = moduleRef.get(ProviderService); + + const loggerService = moduleRef.get(WINSTON_MODULE_NEST_PROVIDER); + jest.spyOn(loggerService, 'warn').mockImplementation(() => undefined); + jest.spyOn(loggerService, 'log').mockImplementation(() => undefined); + + mockLocator(locatorService); + await mockRepository(repositoryService); + await dbService.initialize(); + }); + + afterEach(async () => { + try { + await dbService.deleteCache(); + await dbService.close(); + } catch (error) {} + }); + + it('should clear cache and update if new module was added', async () => { + await dbService.insertEventsCacheBatch(cacheMock); + const result = await dbService.getEventsCache(); + const expected = cacheMock; + + expect(result.headers).toEqual(cacheMock.headers); + expect(result.data.length).toEqual(expected.data.length); + expect(result.data).toEqual(expect.arrayContaining(expected.data)); + + const endBlock = newEvent.blockNumber + 2000; // (10 - (newEvent.blockNumber % 10)); + + jest + .spyOn(signingKeysFetch, 'fetchEventsFallOver') + .mockImplementation(async () => { + return { + events: [...cacheMock.data, newEvent], + stakingModulesAddresses: [ + ...cacheMock.headers.stakingModulesAddresses, + newEvent.moduleAddress, + ], + startBlock: expected.headers.startBlock, + endBlock, + }; + }); + + jest.spyOn(providerService, 'getBlock').mockImplementation(async () => { + return { number: endBlock } as Block; + }); + + jest + .spyOn(signingKeysRegistryService, 'getDeploymentBlockByNetwork') + .mockImplementation(async () => { + return expected.headers.startBlock; + }); + + const deleteCache = jest.spyOn(dbService, 'deleteCache'); + + await signingKeysRegistryService.handleNewBlock([ + ...cacheMock.headers.stakingModulesAddresses, + newEvent.moduleAddress, + ]); + + expect(deleteCache).toBeCalledTimes(1); + + const newResult = await dbService.getEventsCache(); + + expect(newResult.headers.stakingModulesAddresses).toEqual([ + ...cacheMock.headers.stakingModulesAddresses, + newEvent.moduleAddress, + ]); + expect(newResult.headers.startBlock).toEqual(result.headers.startBlock); + expect(newResult.headers.endBlock).toEqual(endBlock); + expect(newResult.data.length).toEqual([...cacheMock.data, newEvent].length); + expect(newResult.data).toEqual( + expect.arrayContaining([...cacheMock.data, newEvent]), + ); + }); + + describe('wasStakingModulesListUpdated', () => { + const testCases = [ + { previousModules: [], currentModules: [], expected: false }, + { previousModules: [], currentModules: ['1'], expected: true }, + { previousModules: ['1'], currentModules: [], expected: true }, + { previousModules: ['1'], currentModules: ['1'], expected: false }, + { previousModules: ['1'], currentModules: ['2'], expected: true }, + { + previousModules: ['1', '2', '3'], + currentModules: ['1', '2'], + expected: true, + }, + { + previousModules: ['1', '2'], + currentModules: ['1', '2', '3'], + expected: true, + }, + { + previousModules: ['1', '2', '3'], + currentModules: ['2', '3', '4'], + expected: true, + }, + { + previousModules: ['1', '2'], + currentModules: ['2', '3'], + expected: true, + }, + { + previousModules: ['1', '2', '3'], + currentModules: ['4', '5', '6'], + expected: true, + }, + ]; + + testCases.forEach((testCase, index) => { + it(`Test case ${index + 1}: previousModules = ${JSON.stringify( + testCase.previousModules, + )}, currentModules = ${JSON.stringify( + testCase.currentModules, + )}, expected = ${testCase.expected}`, () => { + const result = signingKeysRegistryService.wasStakingModulesListUpdated( + testCase.previousModules, + testCase.currentModules, + ); + + expect(result).toEqual(testCase.expected); + }); + }); + }); +}); diff --git a/src/contracts/signing-keys-registry/store/index.ts b/src/contracts/signing-keys-registry/store/index.ts new file mode 100644 index 00000000..99322182 --- /dev/null +++ b/src/contracts/signing-keys-registry/store/index.ts @@ -0,0 +1,3 @@ +export * from './store.constants'; +export * from './store.module'; +export * from './store.service'; diff --git a/src/contracts/signing-keys-registry/store/store.constants.ts b/src/contracts/signing-keys-registry/store/store.constants.ts new file mode 100644 index 00000000..7ff41070 --- /dev/null +++ b/src/contracts/signing-keys-registry/store/store.constants.ts @@ -0,0 +1,3 @@ +export const DB_DIR = 'cache'; +export const DB_DEFAULT_VALUE = 'cacheDefaultValue'; +export const DB_LAYER_DIR = 'cache:layer'; diff --git a/src/contracts/signing-keys-registry/store/store.fixtures.ts b/src/contracts/signing-keys-registry/store/store.fixtures.ts new file mode 100644 index 00000000..cb744530 --- /dev/null +++ b/src/contracts/signing-keys-registry/store/store.fixtures.ts @@ -0,0 +1,71 @@ +import { SigningKeyEventsCacheHeaders } from '../interfaces/cache.interface'; +import { SigningKeyEvent } from '../interfaces/event.interface'; + +export const keyMock1 = + '0x80d12670ec69b62abd4d24c828136cbb1666a63374a66269031d6101973419b66711ed712d17da05d7ca6c0b28ecd21f'; +export const keys = [ + keyMock1, + '0x81011ad6ebe5c7844e59b1799e12de769f785f66df3f63debb06149c1782d574c8c2cd9c923fa881e9dcf6d413159863', +]; + +export const eventsMock1 = [ + { + key: '0x80d12670ec69b62abd4d24c828136cbb1666a63374a66269031d6101973419b66711ed712d17da05d7ca6c0b28ecd21f', + operatorIndex: 1, + moduleAddress: '0x11a93807078f8BB880c1BD0ee4C387537de4b4b6', + logIndex: 1, + blockNumber: 1591260, + blockHash: '0x1', + }, + { + key: '0x80d12670ec69b62abd4d24c828136cbb1666a63374a66269031d6101973419b66711ed712d17da05d7ca6c0b28ecd21f', + operatorIndex: 2, + moduleAddress: '0x11a93807078f8BB880c1BD0ee4C387537de4b4b6', + logIndex: 2, + blockNumber: 1591260, + blockHash: '0x1', + }, + { + key: '0x80d12670ec69b62abd4d24c828136cbb1666a63374a66269031d6101973419b66711ed712d17da05d7ca6c0b28ecd21f', + operatorIndex: 1, + moduleAddress: '0x11a93807078f8BB880c1BD0ee4C387537de4b4b6', + logIndex: 2, + blockNumber: 1591261, + blockHash: '0x2', + }, +]; + +export const eventsMock = [ + ...eventsMock1, + { + key: '0x81011ad6ebe5c7844e59b1799e12de769f785f66df3f63debb06149c1782d574c8c2cd9c923fa881e9dcf6d413159863', + operatorIndex: 1, + moduleAddress: '0x11a93807078f8BB880c1BD0ee4C387537de4b4b6', + logIndex: 1, + blockNumber: 1591261, + blockHash: '0x2', + }, +]; + +export const headersMock: SigningKeyEventsCacheHeaders = { + stakingModulesAddresses: ['0x11a93807078f8BB880c1BD0ee4C387537de4b4b6'], + startBlock: 1591259, + endBlock: 1593259, +}; + +export const cacheMock: { + data: SigningKeyEvent[]; + headers: SigningKeyEventsCacheHeaders; +} = { + data: eventsMock, + headers: headersMock, +}; + +export const newEvent = { + key: '0x81011ad6ebe5c7844e59b1799e12de769f785f66df3f63debb06149c1782d574c8c2cd9c923fa881e9dcf6d413159863', + operatorIndex: 1, + moduleAddress: '0x77b13807078f8BB880c1BD0ee4C387537de4b4b6', + logIndex: 1, + blockNumber: 1593261, + blockHash: '0x3', +}; diff --git a/src/contracts/signing-keys-registry/store/store.module.ts b/src/contracts/signing-keys-registry/store/store.module.ts new file mode 100644 index 00000000..8aef1fdc --- /dev/null +++ b/src/contracts/signing-keys-registry/store/store.module.ts @@ -0,0 +1,34 @@ +import { DynamicModule, Module } from '@nestjs/common'; +import { ProviderModule } from 'provider'; +import { DB_DIR, DB_DEFAULT_VALUE, DB_LAYER_DIR } from './store.constants'; +import { SigningKeysStoreService } from './store.service'; + +@Module({}) +export class SigningKeysStoreModule { + static register( + defaultValue: unknown, + cacheDir = 'cache', + cacheLayerDir = 'add-sign-keys-cache', + ): DynamicModule { + return { + module: SigningKeysStoreModule, + imports: [ProviderModule], + providers: [ + SigningKeysStoreService, + { + provide: DB_DIR, + useValue: cacheDir, + }, + { + provide: DB_DEFAULT_VALUE, + useValue: defaultValue, + }, + { + provide: DB_LAYER_DIR, + useValue: cacheLayerDir, + }, + ], + exports: [SigningKeysStoreService], + }; + } +} diff --git a/src/contracts/signing-keys-registry/store/store.service.spec.ts b/src/contracts/signing-keys-registry/store/store.service.spec.ts new file mode 100644 index 00000000..08cad728 --- /dev/null +++ b/src/contracts/signing-keys-registry/store/store.service.spec.ts @@ -0,0 +1,67 @@ +import { Test } from '@nestjs/testing'; +import { MockProviderModule } from 'provider'; +import { ConfigModule } from 'common/config'; +import { LoggerModule } from 'common/logger'; +import { SigningKeysStoreModule } from './store.module'; +import { SigningKeysStoreService } from './store.service'; +import { cacheMock, eventsMock1, keyMock1 } from './store.fixtures'; + +describe('dbService', () => { + const defaultCacheValue = { + headers: {}, + data: [] as any[], + }; + + let dbService: SigningKeysStoreService; + + beforeEach(async () => { + const moduleRef = await Test.createTestingModule({ + imports: [ + ConfigModule.forRoot(), + MockProviderModule.forRoot(), + SigningKeysStoreModule.register( + defaultCacheValue, + 'leveldb-spec', + 'signing-keys-spec', + ), + LoggerModule, + ], + }).compile(); + + dbService = moduleRef.get(SigningKeysStoreService); + await dbService.initialize(); + }); + + afterEach(async () => { + try { + await dbService.deleteCache(); + await dbService.close(); + } catch (error) {} + }); + + it('should return default cache', async () => { + const result = await dbService.getEventsCache(); + expect(result).toEqual(defaultCacheValue); + }); + + it('should return saved cache', async () => { + const expected = cacheMock; + + await dbService.insertEventsCacheBatch(expected); + const result = await dbService.getEventsCache(); + + expect(result.headers).toEqual(expected.headers); + expect(result.data.length).toEqual(expected.data.length); + expect(result.data).toEqual(expect.arrayContaining(expected.data)); + }); + + it('should return all values with the same key, node operator and module address', async () => { + await dbService.insertEventsCacheBatch(cacheMock); + const result = await dbService.getCachedEvents([keyMock1]); + const expected = eventsMock1; + + expect(result.headers).toEqual(cacheMock.headers); + expect(result.data.length).toEqual(expected.length); + expect(result.data).toEqual(expect.arrayContaining(expected)); + }); +}); diff --git a/src/contracts/signing-keys-registry/store/store.service.ts b/src/contracts/signing-keys-registry/store/store.service.ts new file mode 100644 index 00000000..4a2755dd --- /dev/null +++ b/src/contracts/signing-keys-registry/store/store.service.ts @@ -0,0 +1,249 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { Level } from 'level'; +import { join } from 'path'; +import { DB_DIR, DB_DEFAULT_VALUE, DB_LAYER_DIR } from './store.constants'; +import { ProviderService } from 'provider'; +import { SigningKeyEvent } from '../interfaces/event.interface'; +import { SigningKeyEventsCacheHeaders } from '../interfaces/cache.interface'; + +@Injectable() +export class SigningKeysStoreService { + private db!: Level; + constructor( + private providerService: ProviderService, + @Inject(DB_DIR) private cacheDir: string, + @Inject(DB_LAYER_DIR) private cacheLayerDir: string, + @Inject(DB_DEFAULT_VALUE) + private cacheDefaultValue: { + data: SigningKeyEvent[]; + headers: SigningKeyEventsCacheHeaders; + }, + ) {} + + public async initialize() { + await this.setupLevel(); + } + + /** + * Initializes LevelDB with JSON encoding at the cache directory path. + * + * @returns {Promise} A promise that resolves when the database is successfully initialized. + * @private + */ + private async setupLevel() { + this.db = new Level(await this.getDBDirPath(), { + valueEncoding: 'json', + }); + await this.db.open(); + } + + /** + * Fetches and constructs the cache directory path for the current blockchain network. + * + * @returns {Promise} A promise that resolves to the full path of the network-specific cache directory. + * @private + */ + private async getDBDirPath(): Promise { + const chainId = await this.providerService.getChainId(); + const networkDir = `chain-${chainId}`; + + return join(this.cacheDir, this.cacheLayerDir, networkDir); + } + + /** + * Asynchronously retrieves signing key events and headers from the database. + * Iterates through entries starting with 'signingKey:' to collect data and fetches headers stored under 'header'. + * Handles errors by logging and returning default cache values. + * + * @returns {Promise<{data: SigningKeyEvent[], headers: SigningKeyEventsCacheHeaders}>} Cache data and headers. + * @public + */ + public async getEventsCache(): Promise<{ + data: SigningKeyEvent[]; + headers: SigningKeyEventsCacheHeaders; + }> { + try { + const stream = this.db.iterator({ + gte: 'signingKey:', + lte: 'signingKey:\xFF', + }); + + const data: SigningKeyEvent[] = []; + + for await (const [, value] of stream) { + data.push(this.parseSigningKeyEvent(value)); + } + const headers: SigningKeyEventsCacheHeaders = JSON.parse( + await this.db.get('headers'), + ); + + return { data, headers }; + } catch (error: any) { + if (error.code === 'LEVEL_NOT_FOUND') return this.cacheDefaultValue; + throw error; + } + } + + /** + * @param {string[]} keys - public keys list + * @returns {Promise<{data: SigningKeyEvent[], headers: SigningKeyEventsCacheHeaders}>} Cache data and headers. + * @public + */ + public async getCachedEvents(keys: string[]): Promise<{ + data: SigningKeyEvent[]; + headers: SigningKeyEventsCacheHeaders; + }> { + try { + const data: SigningKeyEvent[] = []; + for (const key of keys) { + const stream = this.db.iterator({ + gte: `signingKey:${key}`, + lte: `signingKey:${key}\xFF`, + }); + + for await (const [, value] of stream) { + data.push(this.parseSigningKeyEvent(value)); + } + } + + const headers: SigningKeyEventsCacheHeaders = JSON.parse( + await this.db.get('headers'), + ); + + return { + data, + headers, + }; + } catch (error: any) { + if (error.code === 'LEVEL_NOT_FOUND') return this.cacheDefaultValue; + throw error; + } + } + + /** Get header + * @returns {Promise<{ headers: SigningKeyEventsCacheHeaders}>} Cache headers. + * @public + */ + public async getHeader(): Promise<{ + headers: SigningKeyEventsCacheHeaders; + }> { + try { + const headers: SigningKeyEventsCacheHeaders = JSON.parse( + await this.db.get('headers'), + ); + + return { + headers, + }; + } catch (error: any) { + if (error.code === 'LEVEL_NOT_FOUND') return this.cacheDefaultValue; + throw error; + } + } + + /** + * Generates a signing key event key for storage. + */ + private generateSigningKeyEventStorageKey({ + key, + blockNumber, + logIndex, + }: SigningKeyEvent): string { + return `signingKey:${key}:${blockNumber}:${logIndex}`; + } + + /** + * Parses a JSON string to a SigningKeyEvent. + * + * @param {string} dataString - The JSON string representing a signing key event. + * @returns {SigningKeyEvent} The parsed signing key event. + * @private + */ + private parseSigningKeyEvent(dataString: string): SigningKeyEvent { + return JSON.parse(dataString); + } + + /** + * Serializes a SigningKeyEvent into a JSON string. + * + * @param {SigningKeyEvent} signingKeyEvent - The signing key event to serialize. + * @returns {string} The serialized JSON string of the signing key event. + * @public + */ + public serializeEventData(signingKeyEvent: SigningKeyEvent) { + return JSON.stringify(signingKeyEvent); + } + + /** + * Inserts a batch of signing key events and a header into the database. + * + * @param {SigningKeyEvent[]} events - An array of signing key events to be inserted into the database. + * @param {SigningKeyEventsCacheHeaders} header - The header information to be stored along with the events. + * @returns {Promise} A promise that resolves when all operations have been successfully committed to the database. + * @public + */ + public async insertEventsCacheBatch(records: { + data: SigningKeyEvent[]; + headers: SigningKeyEventsCacheHeaders; + }) { + if (!this.validateHeader(records.headers)) { + throw new Error( + 'Invalid headers: Headers must contain exactly all SigningKeyEventsCacheHeaders keys.', + ); + } + + const ops = records.data.map((event) => ({ + type: 'put' as const, + key: this.generateSigningKeyEventStorageKey(event), + value: this.serializeEventData(event), + })); + ops.push({ + type: 'put', + key: 'headers', + value: JSON.stringify(records.headers), + }); + await this.db.batch(ops); + } + + private validateHeader( + headers: any, + ): headers is SigningKeyEventsCacheHeaders { + const requiredHeaders: (keyof SigningKeyEventsCacheHeaders)[] = [ + 'stakingModulesAddresses', + 'startBlock', + 'endBlock', + ]; + + const headersKeys = Object.keys( + headers, + ) as (keyof SigningKeyEventsCacheHeaders)[]; + const hasNoExtraKey = headersKeys.every((key) => + requiredHeaders.includes(key), + ); + const hasAllRequiredKeys = requiredHeaders.every((key) => + headersKeys.includes(key), + ); + + return hasNoExtraKey && hasAllRequiredKeys; + } + + /** + * Clears all entries from the database. + * + * @returns {Promise} + * @public + */ + public async deleteCache(): Promise { + await this.db.clear(); + } + + /** + * Close the database connection. + * + * @returns {Promise} + * @public + */ + public async close(): Promise { + await this.db.close(); + } +} diff --git a/src/staking-router/index.ts b/src/contracts/staking-router/index.ts similarity index 100% rename from src/staking-router/index.ts rename to src/contracts/staking-router/index.ts diff --git a/src/staking-router/staking-router.module.ts b/src/contracts/staking-router/staking-router.module.ts similarity index 60% rename from src/staking-router/staking-router.module.ts rename to src/contracts/staking-router/staking-router.module.ts index b3ac87c0..cf80647e 100644 --- a/src/staking-router/staking-router.module.ts +++ b/src/contracts/staking-router/staking-router.module.ts @@ -1,10 +1,7 @@ import { Module } from '@nestjs/common'; -import { ConfigModule } from 'common/config'; -import { KeysApiModule } from 'keys-api/keys-api.module'; import { StakingRouterService } from './staking-router.service'; @Module({ - imports: [ConfigModule, KeysApiModule], providers: [StakingRouterService], exports: [StakingRouterService], }) diff --git a/src/contracts/lido/lido.service.spec.ts b/src/contracts/staking-router/staking-router.service.spec.ts similarity index 63% rename from src/contracts/lido/lido.service.spec.ts rename to src/contracts/staking-router/staking-router.service.spec.ts index cf3f8df8..6271374d 100644 --- a/src/contracts/lido/lido.service.spec.ts +++ b/src/contracts/staking-router/staking-router.service.spec.ts @@ -2,22 +2,22 @@ import { Test } from '@nestjs/testing'; import { ConfigModule } from 'common/config'; import { LoggerModule } from 'common/logger'; import { MockProviderModule, ProviderService } from 'provider'; -import { LidoAbi__factory } from 'generated'; import { RepositoryModule, RepositoryService } from 'contracts/repository'; import { Interface } from '@ethersproject/abi'; -import { LidoService } from './lido.service'; -import { LidoModule } from './lido.module'; import { LocatorService } from 'contracts/repository/locator/locator.service'; import { WINSTON_MODULE_NEST_PROVIDER } from 'nest-winston'; import { mockLocator } from 'contracts/repository/locator/locator.mock'; import { mockRepository } from 'contracts/repository/repository.mock'; +import { StakingRouterAbi__factory } from 'generated'; +import { StakingRouterModule, StakingRouterService } from '.'; + +const TEST_MODULE_ID = 1; describe('SecurityService', () => { - let lidoService: LidoService; let providerService: ProviderService; - let repositoryService: RepositoryService; let locatorService: LocatorService; + let stakingRouterService: StakingRouterService; beforeEach(async () => { const moduleRef = await Test.createTestingModule({ @@ -25,16 +25,16 @@ describe('SecurityService', () => { ConfigModule.forRoot(), MockProviderModule.forRoot(), LoggerModule, - LidoModule, RepositoryModule, + StakingRouterModule, ], }).compile(); - lidoService = moduleRef.get(LidoService); providerService = moduleRef.get(ProviderService); - repositoryService = moduleRef.get(RepositoryService); locatorService = moduleRef.get(LocatorService); + stakingRouterService = moduleRef.get(StakingRouterService); + jest .spyOn(moduleRef.get(WINSTON_MODULE_NEST_PROVIDER), 'log') .mockImplementation(() => undefined); @@ -43,6 +43,27 @@ describe('SecurityService', () => { await mockRepository(repositoryService); }); + describe('isDepositsPaused', () => { + it('should call contract method', async () => { + const expected = true; + + const mockProviderCalla = jest + .spyOn(providerService.provider, 'call') + .mockImplementation(async () => { + const iface = new Interface(StakingRouterAbi__factory.abi); + return iface.encodeFunctionResult('getStakingModuleIsActive', [ + expected, + ]); + }); + + const isPaused = await stakingRouterService.isModuleDepositsPaused( + TEST_MODULE_ID, + ); + expect(isPaused).toBe(!expected); + expect(mockProviderCalla).toBeCalledTimes(1); + }); + }); + describe('getWithdrawalCredentials', () => { it('should return withdrawal credentials', async () => { const expected = '0x' + '1'.repeat(64); @@ -50,12 +71,12 @@ describe('SecurityService', () => { const mockProviderCall = jest .spyOn(providerService.provider, 'call') .mockImplementation(async () => { - const iface = new Interface(LidoAbi__factory.abi); + const iface = new Interface(StakingRouterAbi__factory.abi); const result = [expected]; return iface.encodeFunctionResult('getWithdrawalCredentials', result); }); - const wc = await lidoService.getWithdrawalCredentials(); + const wc = await await stakingRouterService.getWithdrawalCredentials(); expect(wc).toBe(expected); expect(mockProviderCall).toBeCalledTimes(1); }); diff --git a/src/contracts/staking-router/staking-router.service.ts b/src/contracts/staking-router/staking-router.service.ts new file mode 100644 index 00000000..94ac725f --- /dev/null +++ b/src/contracts/staking-router/staking-router.service.ts @@ -0,0 +1,103 @@ +import { Inject, Injectable, LoggerService } from '@nestjs/common'; +import { RepositoryService } from 'contracts/repository'; +import { IStakingModuleAbi__factory } from 'generated'; +import { WINSTON_MODULE_NEST_PROVIDER } from 'nest-winston'; +import { BlockTag, ProviderService } from 'provider'; + +@Injectable() +export class StakingRouterService { + constructor( + @Inject(WINSTON_MODULE_NEST_PROVIDER) private logger: LoggerService, + private providerService: ProviderService, + private repositoryService: RepositoryService, + ) {} + + /** + * @param blockTag + * @returns List of staking modules fetched from the SR contract + */ + public async getStakingModules(blockTag: BlockTag) { + const stakingRouter = + await this.repositoryService.getCachedStakingRouterContract(); + const stakingModules = await stakingRouter.getStakingModules({ + blockTag: blockTag as any, + }); + + return stakingModules; + } + + /** + * Retrieves the list of staking module addresses. + * This method fetches the cached staking modules contracts and returns the list of staking module addresses. + * @param blockHash - Block hash + * @returns Array of staking module addresses. + */ + public async getStakingModulesAddresses( + blockHash: string, + ): Promise { + const stakingModules = await this.getStakingModules({ blockHash }); + + return stakingModules.map( + (stakingModule) => stakingModule.stakingModuleAddress, + ); + } + + /** + * Retrieves contract factory + * @param stakingModuleAddress Staking module address + * @returns Contract factory + */ + public async getStakingModule(stakingModuleAddress: string) { + return IStakingModuleAbi__factory.connect( + stakingModuleAddress, + this.providerService.provider, + ); + } + + /** + * Retrieves SigningKeyAdded events list + * @param startBlock - Start block for fetching events + * @param endBlock - End block for fetching events + * @param stakingModuleAddress - Staking module address + * @returns List of SigningKeyAdded events + */ + public async getSigningKeyAddedEvents( + startBlock: number, + endBlock: number, + stakingModuleAddress: string, + ) { + const contract = await this.getStakingModule(stakingModuleAddress); + const filter = contract.filters['SigningKeyAdded(uint256,bytes)'](); + + return await contract.queryFilter(filter, startBlock, endBlock); + } + + /** + * Returns the current state of deposits for module + */ + public async isModuleDepositsPaused( + stakingModuleId: number, + blockTag?: BlockTag, + ): Promise { + const stakingRouterContract = + await this.repositoryService.getCachedStakingRouterContract(); + + const isActive = await stakingRouterContract.getStakingModuleIsActive( + stakingModuleId, + { + blockTag: blockTag as any, + }, + ); + + return !isActive; + } + + public async getWithdrawalCredentials(blockTag?: BlockTag): Promise { + const stakingRouterContract = + await this.repositoryService.getCachedStakingRouterContract(); + + return await stakingRouterContract.getWithdrawalCredentials({ + blockTag: blockTag as any, + }); + } +} diff --git a/src/guardian/block-data-collector/block-data-collector.module.ts b/src/guardian/block-data-collector/block-data-collector.module.ts new file mode 100644 index 00000000..0e6291af --- /dev/null +++ b/src/guardian/block-data-collector/block-data-collector.module.ts @@ -0,0 +1,20 @@ +import { Module } from '@nestjs/common'; +import { DepositsRegistryModule } from 'contracts/deposits-registry'; +import { SecurityModule } from 'contracts/security'; +import { StakingModuleGuardModule } from 'guardian/staking-module-guard'; +import { WalletModule } from 'wallet'; +import { StakingRouterModule } from 'contracts/staking-router'; +import { BlockDataCollectorService } from './block-data-collector.service'; + +@Module({ + imports: [ + DepositsRegistryModule.register(), + SecurityModule, + StakingModuleGuardModule, + WalletModule, + StakingRouterModule, + ], + providers: [BlockDataCollectorService], + exports: [BlockDataCollectorService], +}) +export class BlockDataCollectorModule {} diff --git a/src/guardian/block-data-collector/block-data-collector.service.ts b/src/guardian/block-data-collector/block-data-collector.service.ts new file mode 100644 index 00000000..07aa0afd --- /dev/null +++ b/src/guardian/block-data-collector/block-data-collector.service.ts @@ -0,0 +1,129 @@ +import { Inject, Injectable, LoggerService } from '@nestjs/common'; +import { WINSTON_MODULE_NEST_PROVIDER } from 'nest-winston'; + +import { DepositRegistryService } from 'contracts/deposits-registry'; +import { SecurityService } from 'contracts/security'; + +import { BlockData } from '../interfaces'; + +import { InjectMetric } from '@willsoto/nestjs-prometheus'; +import { + METRIC_BLOCK_DATA_REQUEST_DURATION, + METRIC_BLOCK_DATA_REQUEST_ERRORS, +} from 'common/prometheus'; +import { Counter, Histogram } from 'prom-client'; +import { StakingModuleGuardService } from 'guardian/staking-module-guard'; +import { WalletService } from 'wallet'; +import { StakingRouterService } from 'contracts/staking-router'; + +@Injectable() +export class BlockDataCollectorService { + constructor( + @Inject(WINSTON_MODULE_NEST_PROVIDER) + private logger: LoggerService, + + @InjectMetric(METRIC_BLOCK_DATA_REQUEST_DURATION) + private blockRequestsHistogram: Histogram, + + @InjectMetric(METRIC_BLOCK_DATA_REQUEST_ERRORS) + private blockErrorsCounter: Counter, + + private walletService: WalletService, + + private depositService: DepositRegistryService, + private securityService: SecurityService, + private stakingRouterService: StakingRouterService, + + private stakingModuleGuardService: StakingModuleGuardService, + ) {} + + /** + * Collects data from contracts in one place and by block hash, + * to reduce the probability of getting data from different blocks + * @returns collected data from the current block + */ + public async getCurrentBlockData({ + blockNumber, + blockHash, + }: { + blockNumber: number; + blockHash: string; + }): Promise { + const endTimer = this.blockRequestsHistogram.startTimer(); + try { + const guardianAddress = this.securityService.getGuardianAddress(); + const [ + depositRoot, + depositedEvents, + guardianIndex, + lidoWC, + securityVersion, + walletBalanceCritical, + ] = await Promise.all([ + this.depositService.getDepositRoot({ blockHash }), + this.depositService.getAllDepositedEvents(blockNumber, blockHash), + this.securityService.getGuardianIndex({ blockHash }), + this.stakingRouterService.getWithdrawalCredentials({ blockHash }), + this.securityService.version({ + blockHash, + }), + this.walletService.isBalanceCritical(), + ]); + + const theftHappened = + await this.stakingModuleGuardService.getHistoricalFrontRun( + depositedEvents, + lidoWC, + ); + + const alreadyPausedDeposits = await this.alreadyPausedDeposits( + blockHash, + securityVersion, + ); + + if (alreadyPausedDeposits) { + this.logger.warn('Deposits are already paused', { + blockNumber, + blockHash, + }); + } + + return { + blockNumber, + blockHash, + depositRoot, + depositedEvents, + guardianAddress, + guardianIndex, + lidoWC, + securityVersion, + alreadyPausedDeposits, + theftHappened, + walletBalanceCritical, + }; + } catch (error) { + this.blockErrorsCounter.inc(); + this.logger.error(error); + throw error; + } finally { + endTimer(); + } + } + + private async alreadyPausedDeposits( + blockHash: string, + securityVersion: number, + ) { + if (securityVersion === 3) { + const alreadyPaused = await this.securityService.isDepositsPaused({ + blockHash, + }); + + return alreadyPaused; + } + + // for earlier versions DSM contact didn't have this method + // we check pause for every method via staking router contract + return false; + } +} diff --git a/src/guardian/block-data-collector/index.ts b/src/guardian/block-data-collector/index.ts new file mode 100644 index 00000000..4013ae8e --- /dev/null +++ b/src/guardian/block-data-collector/index.ts @@ -0,0 +1,2 @@ +export * from './block-data-collector.module'; +export * from './block-data-collector.service'; diff --git a/src/guardian/block-guard/block-guard.module.ts b/src/guardian/block-guard/block-guard.module.ts deleted file mode 100644 index 6290761f..00000000 --- a/src/guardian/block-guard/block-guard.module.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Module } from '@nestjs/common'; -import { DepositModule } from 'contracts/deposit'; -import { SecurityModule } from 'contracts/security'; -import { BlockGuardService } from './block-guard.service'; -import { LidoModule } from 'contracts/lido'; - -@Module({ - imports: [LidoModule, DepositModule, SecurityModule], - providers: [BlockGuardService], - exports: [BlockGuardService], -}) -export class BlockGuardModule {} diff --git a/src/guardian/block-guard/block-guard.service.ts b/src/guardian/block-guard/block-guard.service.ts deleted file mode 100644 index 0e697de8..00000000 --- a/src/guardian/block-guard/block-guard.service.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { Inject, Injectable, LoggerService } from '@nestjs/common'; -import { WINSTON_MODULE_NEST_PROVIDER } from 'nest-winston'; - -import { DepositService } from 'contracts/deposit'; -import { SecurityService } from 'contracts/security'; - -import { BlockData } from '../interfaces'; - -import { InjectMetric } from '@willsoto/nestjs-prometheus'; -import { - METRIC_BLOCK_DATA_REQUEST_DURATION, - METRIC_BLOCK_DATA_REQUEST_ERRORS, -} from 'common/prometheus'; -import { Counter, Histogram } from 'prom-client'; -import { LidoService } from 'contracts/lido'; - -@Injectable() -export class BlockGuardService { - protected lastProcessedStateMeta?: { blockHash: string; blockNumber: number }; - - constructor( - @Inject(WINSTON_MODULE_NEST_PROVIDER) - private logger: LoggerService, - - @InjectMetric(METRIC_BLOCK_DATA_REQUEST_DURATION) - private blockRequestsHistogram: Histogram, - - @InjectMetric(METRIC_BLOCK_DATA_REQUEST_ERRORS) - private blockErrorsCounter: Counter, - - private depositService: DepositService, - private securityService: SecurityService, - private lidoService: LidoService, - ) {} - - public isNeedToProcessNewState(newMeta: { - blockHash: string; - blockNumber: number; - }) { - const lastMeta = this.lastProcessedStateMeta; - if (!lastMeta) return true; - if (lastMeta.blockNumber > newMeta.blockNumber) { - this.logger.error('Keys-api returns old state', newMeta); - return false; - } - return lastMeta.blockHash !== newMeta.blockHash; - } - - public setLastProcessedStateMeta(newMeta: { - blockHash: string; - blockNumber: number; - }) { - this.lastProcessedStateMeta = newMeta; - } - - /** - * Collects data from contracts in one place and by block hash, - * to reduce the probability of getting data from different blocks - * @returns collected data from the current block - */ - public async getCurrentBlockData({ - blockNumber, - blockHash, - }: { - blockNumber: number; - blockHash: string; - }): Promise { - const endTimer = this.blockRequestsHistogram.startTimer(); - try { - const guardianAddress = this.securityService.getGuardianAddress(); - - const [depositRoot, depositedEvents, guardianIndex, lidoWC] = - await Promise.all([ - this.depositService.getDepositRoot({ blockHash }), - this.depositService.getAllDepositedEvents(blockNumber, blockHash), - this.securityService.getGuardianIndex({ blockHash }), - this.lidoService.getWithdrawalCredentials({ blockHash }), - ]); - - return { - blockNumber, - blockHash, - depositRoot, - depositedEvents, - guardianAddress, - guardianIndex, - lidoWC, - }; - } catch (error) { - this.blockErrorsCounter.inc(); - this.logger.error(error); - throw error; - } finally { - endTimer(); - } - } -} diff --git a/src/guardian/block-guard/index.ts b/src/guardian/block-guard/index.ts deleted file mode 100644 index 78e3eaf6..00000000 --- a/src/guardian/block-guard/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './block-guard.module'; -export * from './block-guard.service'; diff --git a/src/guardian/duplicates/index.ts b/src/guardian/duplicates/index.ts new file mode 100644 index 00000000..5cd524ae --- /dev/null +++ b/src/guardian/duplicates/index.ts @@ -0,0 +1,2 @@ +export * from './keys-duplication-checker.module'; +export * from './keys-duplication-checker.service'; diff --git a/src/guardian/duplicates/keys-duplication-checker.module.ts b/src/guardian/duplicates/keys-duplication-checker.module.ts new file mode 100644 index 00000000..a5ba4528 --- /dev/null +++ b/src/guardian/duplicates/keys-duplication-checker.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { SigningKeysRegistryModule } from 'contracts/signing-keys-registry'; +import { KeysDuplicationCheckerService } from './keys-duplication-checker.service'; + +@Module({ + imports: [SigningKeysRegistryModule.register()], + providers: [KeysDuplicationCheckerService], + exports: [KeysDuplicationCheckerService], +}) +export class KeysDuplicationCheckerModule {} diff --git a/src/guardian/duplicates/keys-duplication-checker.service.spec.ts b/src/guardian/duplicates/keys-duplication-checker.service.spec.ts new file mode 100644 index 00000000..ccd25324 --- /dev/null +++ b/src/guardian/duplicates/keys-duplication-checker.service.spec.ts @@ -0,0 +1,432 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { LoggerModule } from 'common/logger'; +import { + SigningKeysRegistryModule, + SigningKeysRegistryService, +} from 'contracts/signing-keys-registry'; +import { KeysDuplicationCheckerModule } from './keys-duplication-checker.module'; +import { KeysDuplicationCheckerService } from './keys-duplication-checker.service'; +import { eventMock1, keyMock1, keyMock2 } from './keys.fixtures'; +import { ConfigModule } from 'common/config'; +import { MockProviderModule } from 'provider'; +import { BlockData } from 'guardian/interfaces'; +import { StakingRouterModule } from 'contracts/staking-router'; +import { RepositoryModule } from 'contracts/repository'; +import { WINSTON_MODULE_NEST_PROVIDER } from 'nest-winston'; +describe('KeysDuplicationCheckerService', () => { + let service: KeysDuplicationCheckerService; + const mockSigningKeysRegistryService = { + getUpdatedSigningKeyEvents: jest.fn(), + }; + + const emptyBlockData = {} as BlockData; + + beforeEach(async () => { + const moduleRef: TestingModule = await Test.createTestingModule({ + imports: [ + StakingRouterModule, + RepositoryModule, + ConfigModule.forRoot(), + MockProviderModule.forRoot(), + LoggerModule, + KeysDuplicationCheckerModule, + SigningKeysRegistryModule.register('latest'), + ], + }) + .overrideProvider(SigningKeysRegistryService) + .useValue(mockSigningKeysRegistryService) + .compile(); + + service = moduleRef.get( + KeysDuplicationCheckerService, + ); + + const loggerService = moduleRef.get(WINSTON_MODULE_NEST_PROVIDER); + jest.spyOn(loggerService, 'warn').mockImplementation(() => undefined); + jest.spyOn(loggerService, 'log').mockImplementation(() => undefined); + }); + + describe('getDuplicateKeyGroups', () => { + it('should identify and return tuples of duplicated keys along with their occurrences', () => { + const result = service.getDuplicateKeyGroups([ + { ...keyMock1, index: 1 }, + { ...keyMock1, index: 2 }, + { ...keyMock2, index: 3 }, + { ...keyMock2, index: 4 }, + ]); + + // Check the number of groups of duplicated keys identified + expect(result.length).toEqual(2); + + expect(result[0][0]).toEqual(keyMock1.key); + expect(result[0][1].length).toEqual(2); + expect(result[0][1]).toEqual([ + { ...keyMock1, index: 1 }, + { ...keyMock1, index: 2 }, + ]); + + expect(result[1][0]).toEqual(keyMock2.key); + expect(result[1][1].length).toEqual(2); + expect(result[1][1]).toEqual([ + { ...keyMock2, index: 3 }, + { ...keyMock2, index: 4 }, + ]); + }); + }); + + describe('getDuplicatedKeys', () => { + describe('Detect duplicates within a single operator', () => { + it('Returns unused keys as duplicates if the list contains a deposited key', async () => { + // will be return key with smallest index + // deposited keys has a smallest index + const unusedKey = { ...keyMock1, index: 2, used: false }; + const usedKey = { ...keyMock1, index: 1, used: true }; + const duplicatedKeysAmongSingleOperator = [unusedKey, usedKey]; + + const result = await service.getDuplicatedKeys( + duplicatedKeysAmongSingleOperator, + emptyBlockData, + ); + + expect(result.duplicates).toEqual([unusedKey]); + expect(result.unresolved).toEqual([]); + }); + + it('Identifies the key with the smallest index as the earliest and returns the others as duplicates', async () => { + const unusedKey1 = { ...keyMock1, index: 1, used: false }; + const unusedKey2 = { ...keyMock1, index: 2, used: false }; + const duplicatedKeysAmongSingleOperator = [unusedKey1, unusedKey2]; + + const result = await service.getDuplicatedKeys( + duplicatedKeysAmongSingleOperator, + emptyBlockData, + ); + + expect(result.duplicates).toEqual([unusedKey2]); + expect(result.unresolved).toEqual([]); + }); + }); + + describe('Detect duplicates across multiple operators within the same module', () => { + it('Returns unused keys as duplicates if the list contains a deposited key', async () => { + const unusedKey = { ...keyMock1, used: false, operatorIndex: 1 }; + const usedKey = { ...keyMock1, used: true, operatorIndex: 2 }; + const duplicatedKeysAmongMultipleOperator = [unusedKey, usedKey]; + + const result = await service.getDuplicatedKeys( + duplicatedKeysAmongMultipleOperator, + emptyBlockData, + ); + + expect(result.duplicates).toEqual([unusedKey]); + expect(result.unresolved).toEqual([]); + }); + + describe('Detect duplicates based on SigningKeyAdded events', () => { + it('Returns all keys as unresolved if there is no event for operator', async () => { + const unusedKey1 = { ...keyMock1, used: false, operatorIndex: 1 }; + const unusedKey2 = { ...keyMock1, used: false, operatorIndex: 2 }; + + // unresolved will not influence detection of other keys duplicates + const unusedKey3 = { ...keyMock2, used: false, operatorIndex: 1 }; + const usedKeys = { ...keyMock2, used: true, operatorIndex: 2 }; + + const keyMock1Event = { + ...eventMock1, + operatorIndex: 1, + logIndex: 1, + blockNumber: 1, + }; + + mockSigningKeysRegistryService.getUpdatedSigningKeyEvents.mockImplementationOnce( + async () => { + return { + events: [keyMock1Event], + isValid: true, + }; + }, + ); + + const duplicatedKeysAmongMultipleOperators = [ + unusedKey1, + unusedKey2, + unusedKey3, + usedKeys, + ]; + + const result = await service.getDuplicatedKeys( + duplicatedKeysAmongMultipleOperators, + emptyBlockData, + ); + + expect(result.duplicates).toEqual([unusedKey3]); + expect(result.unresolved).toEqual([unusedKey1, unusedKey2]); + }); + + it('Returns all keys as duplicates if multiple events occur in the smallest block', async () => { + const unusedKey1 = { ...keyMock1, used: false, operatorIndex: 1 }; + const unusedKey2 = { ...keyMock1, used: false, operatorIndex: 2 }; + + const keyMock1Event = { + ...eventMock1, + operatorIndex: 1, + logIndex: 1, + blockNumber: 1, + }; + + const keyMock2Event = { + ...eventMock1, + operatorIndex: 2, + logIndex: 2, + blockNumber: 1, + }; + + mockSigningKeysRegistryService.getUpdatedSigningKeyEvents.mockImplementationOnce( + async () => { + return { + events: [keyMock1Event, keyMock2Event], + isValid: true, + }; + }, + ); + + const duplicatedKeysAmongMultipleOperators = [unusedKey1, unusedKey2]; + + const result = await service.getDuplicatedKeys( + duplicatedKeysAmongMultipleOperators, + emptyBlockData, + ); + + expect(result.duplicates).toEqual([unusedKey1, unusedKey2]); + expect(result.unresolved).toEqual([]); + }); + + it('Returns all keys as duplicates except the one with the smallest block number and key index', async () => { + const unusedKey1 = { + ...keyMock1, + index: 1, + used: false, + operatorIndex: 1, + }; + const unusedKey2 = { + ...keyMock1, + index: 2, + used: false, + operatorIndex: 1, + }; + const unusedKey3 = { ...keyMock1, used: false, operatorIndex: 2 }; + + const keyMock1Event = { + ...eventMock1, + operatorIndex: 1, + logIndex: 1, + blockNumber: 1, + }; + + const keyMock2Event = { + ...eventMock1, + operatorIndex: 2, + logIndex: 1, + blockNumber: 2, + }; + + mockSigningKeysRegistryService.getUpdatedSigningKeyEvents.mockImplementationOnce( + async () => { + return { + events: [keyMock1Event, keyMock2Event], + isValid: true, + }; + }, + ); + + const duplicatedKeysAmongMultipleOperators = [ + unusedKey1, + unusedKey2, + unusedKey3, + ]; + + const result = await service.getDuplicatedKeys( + duplicatedKeysAmongMultipleOperators, + emptyBlockData, + ); + + expect(result.duplicates).toEqual([unusedKey2, unusedKey3]); + expect(result.unresolved).toEqual([]); + }); + }); + }); + + describe('Detect duplicates across multiple operators in different modules', () => { + it('Returns unused keys as duplicates if the list contains a deposited key', async () => { + const unusedKey = { + ...keyMock1, + used: false, + moduleAddress: 'address1', + }; + const usedKey = { ...keyMock1, used: true, moduleAddress: 'address2' }; + const duplicatedKeysAmongMultipleModules = [unusedKey, usedKey]; + + const result = await service.getDuplicatedKeys( + duplicatedKeysAmongMultipleModules, + emptyBlockData, + ); + + expect(result.duplicates).toEqual([unusedKey]); + expect(result.unresolved).toEqual([]); + }); + + describe('Detect duplicates based on SigningKeyAdded events', () => { + it('Return all keys as unresolved if there are no event for operator', async () => { + const unusedKey1 = { + ...keyMock1, + used: false, + moduleAddress: 'address1', + }; + const unusedKey2 = { + ...keyMock1, + used: false, + moduleAddress: 'address2', + }; + + // unresolved will not influence detection of other keys duplicates + const unusedKey3 = { ...keyMock2, used: false, operatorIndex: 1 }; + const usedKeys = { ...keyMock2, used: true, operatorIndex: 2 }; + + const keyMock1Event = { + ...eventMock1, + moduleAddress: 'address1', + logIndex: 1, + blockNumber: 1, + }; + + mockSigningKeysRegistryService.getUpdatedSigningKeyEvents.mockImplementationOnce( + async () => { + return { + events: [keyMock1Event], + isValid: true, + }; + }, + ); + + const duplicatedKeysAmongMultipleModules = [ + unusedKey1, + unusedKey2, + unusedKey3, + usedKeys, + ]; + + const result = await service.getDuplicatedKeys( + duplicatedKeysAmongMultipleModules, + emptyBlockData, + ); + + expect(result.duplicates).toEqual([unusedKey3]); + expect(result.unresolved).toEqual([unusedKey1, unusedKey2]); + }); + + it('Returns all keys as duplicates if multiple events occur in the smallest block', async () => { + const unusedKey1 = { + ...keyMock1, + used: false, + moduleAddress: 'address1', + }; + const unusedKey2 = { + ...keyMock1, + used: false, + moduleAddress: 'address2', + }; + + const keyMock1Event = { + ...eventMock1, + moduleAddress: 'address1', + logIndex: 1, + blockNumber: 1, + }; + + const keyMock2Event = { + ...eventMock1, + moduleAddress: 'address2', + logIndex: 2, + blockNumber: 1, + }; + + mockSigningKeysRegistryService.getUpdatedSigningKeyEvents.mockImplementationOnce( + async () => { + return { + events: [keyMock1Event, keyMock2Event], + isValid: true, + }; + }, + ); + + const duplicatedKeysAmongMultipleModules = [unusedKey1, unusedKey2]; + + const result = await service.getDuplicatedKeys( + duplicatedKeysAmongMultipleModules, + emptyBlockData, + ); + + expect(result.duplicates).toEqual([unusedKey1, unusedKey2]); + expect(result.unresolved).toEqual([]); + }); + + it('Returns all keys as duplicates except the one with the smallest block number and key index', async () => { + const unusedKey1 = { + ...keyMock1, + index: 1, + used: false, + moduleAddress: 'address1', + }; + const unusedKey2 = { + ...keyMock1, + index: 2, + used: false, + moduleAddress: 'address1', + }; + const unusedKey3 = { + ...keyMock1, + used: false, + moduleAddress: 'address2', + }; + + const keyMock1Event = { + ...eventMock1, + moduleAddress: 'address1', + logIndex: 1, + blockNumber: 1, + }; + + const keyMock2Event = { + ...eventMock1, + moduleAddress: 'address2', + logIndex: 1, + blockNumber: 2, + }; + + mockSigningKeysRegistryService.getUpdatedSigningKeyEvents.mockImplementationOnce( + async () => { + return { + events: [keyMock1Event, keyMock2Event], + isValid: true, + }; + }, + ); + + const duplicatedKeysAmongMultipleModules = [ + unusedKey1, + unusedKey2, + unusedKey3, + ]; + + const result = await service.getDuplicatedKeys( + duplicatedKeysAmongMultipleModules, + emptyBlockData, + ); + + expect(result.duplicates).toEqual([unusedKey2, unusedKey3]); + expect(result.unresolved).toEqual([]); + }); + }); + }); + }); +}); diff --git a/src/guardian/duplicates/keys-duplication-checker.service.ts b/src/guardian/duplicates/keys-duplication-checker.service.ts new file mode 100644 index 00000000..a909b850 --- /dev/null +++ b/src/guardian/duplicates/keys-duplication-checker.service.ts @@ -0,0 +1,345 @@ +import { Inject, Injectable, LoggerService } from '@nestjs/common'; +import { + SigningKeyEvent, + SigningKeyEventsGroupWithStakingModules, +} from 'contracts/signing-keys-registry/interfaces/event.interface'; +import { SigningKeysRegistryService } from 'contracts/signing-keys-registry/signing-keys-registry.service'; + +import { BlockData } from 'guardian/interfaces'; +import { RegistryKey } from 'keys-api/interfaces/RegistryKey'; +import { WINSTON_MODULE_NEST_PROVIDER } from 'nest-winston'; +import { rangePromise } from 'utils'; + +const BATCH_SIZE = 10; + +@Injectable() +export class KeysDuplicationCheckerService { + constructor( + @Inject(WINSTON_MODULE_NEST_PROVIDER) private logger: LoggerService, + private signingKeysRegistryService: SigningKeysRegistryService, + ) {} + + /** + * Identifies and returns duplicated keys. + * + * Duplicates Search Algorithm: + * 1. If there are duplicates within one operator, the key with the lowest index is considered the original, and the others are considered duplicates. + * 2. If there are duplicates between different operators, check if a deposited key exists in the duplicates list; all others are considered duplicates. + * 3. If there is no deposited key, check the SigningKeyAdded events for operators. + * 4. Sort events by block number. The earliest event is considered the original, and the others are marked as duplicates. + * + * If there is no event for the key it will return list of unresolved keys. + * + * @param key public key + * @param blockData - collected data from the current block + * @returns An object containing two properties: + * - `duplicates`: An array of `RegistryKey` objects that are identified as duplicates. + * - `unresolved`: An array of `RegistryKey` objects for which no corresponding events were found. + */ + public async getDuplicatedKeys( + keys: RegistryKey[], + blockData: BlockData, + ): Promise<{ duplicates: RegistryKey[]; unresolved: RegistryKey[] }> { + if (keys.length === 0) { + return { duplicates: [], unresolved: [] }; + } + // First element of sub-arrays is a key, second - all it's occurrences + const suspectedDuplicateKeyGroups = this.getDuplicateKeyGroups(keys); + + const processDuplicateGroup = async (index) => { + const [key, suspectedDuplicateKeys] = suspectedDuplicateKeyGroups[index]; + return await this.processDuplicateKeyGroup( + key, + suspectedDuplicateKeys, + blockData, + ); + }; + + const result = await rangePromise( + processDuplicateGroup, + 0, + suspectedDuplicateKeyGroups.length, + BATCH_SIZE, + ); + + const duplicates = result.flatMap(({ duplicates }) => duplicates); + const unresolved = result.flatMap(({ unresolved }) => unresolved); + + return { duplicates, unresolved }; + } + + /** + * Groups keys by their pubkey and returns a list of those with duplicates. + * + * This method iterates over the provided keys and groups them by their unique pubkey. + * It then filters out any groups that do not have duplicates, returning only the groups + * that contain more than one instance of the pubkey. + * + * @param keys - An array of `RegistryKey` objects to be checked for duplicates. + * @returns An array of tuples where each tuple contains a pubkey string and an array of + * `RegistryKey` objects that share that pubkey. Only keys with duplicates are included. + */ + public getDuplicateKeyGroups(keys: RegistryKey[]): [string, RegistryKey[]][] { + const keyMap = keys.reduce((acc, key) => { + const duplicateKeys = acc.get(key.key) || []; + duplicateKeys.push(key); + acc.set(key.key, duplicateKeys); + + return acc; + }, new Map()); + + return Array.from(keyMap.entries()).filter( + ([, duplicateKeys]) => duplicateKeys.length > 1, + ); + } + + private async processDuplicateKeyGroup( + key: string, + suspectedDuplicateKeys: RegistryKey[], + blockData: BlockData, + ): Promise<{ duplicates: RegistryKey[]; unresolved: RegistryKey[] }> { + const uniqueOperatorIdentifiers = this.getUniqueIdentifiersForOperators( + suspectedDuplicateKeys, + ); + + if (uniqueOperatorIdentifiers.length === 1) { + return this.handleSingleOperatorDuplicates(suspectedDuplicateKeys); + } + + if (this.hasDepositedKey(suspectedDuplicateKeys)) { + return this.handleDepositedKeyDuplicates(suspectedDuplicateKeys); + } + + return await this.handleMultiOperatorDuplicates( + key, + suspectedDuplicateKeys, + uniqueOperatorIdentifiers, + blockData, + ); + } + + private getUniqueIdentifiersForOperators(keys: RegistryKey[]): string[] { + return [...new Set(keys.map((key) => this.getKeyOperatorIdentifier(key)))]; + } + + private getKeyOperatorIdentifier(key: RegistryKey): string { + return `${key.moduleAddress}-${key.operatorIndex}`; + } + + private handleSingleOperatorDuplicates( + suspectedDuplicateKeys: RegistryKey[], + ): { + duplicates: RegistryKey[]; + unresolved: RegistryKey[]; + } { + const duplicates = this.findDuplicatesWithinOperator( + suspectedDuplicateKeys, + ); + return { duplicates, unresolved: [] }; + } + + private handleDepositedKeyDuplicates(suspectedDuplicateKeys: RegistryKey[]): { + duplicates: RegistryKey[]; + unresolved: RegistryKey[]; + } { + const duplicates = suspectedDuplicateKeys.filter((key) => !key.used); + return { duplicates, unresolved: [] }; + } + + private async handleMultiOperatorDuplicates( + key: string, + suspectedDuplicateKeys: RegistryKey[], + uniqueOperatorIdentifiers: string[], + blockData: BlockData, + ) { + const { duplicateKeys, unresolvedKeys } = + await this.getDuplicatesAcrossOperators( + key, + suspectedDuplicateKeys, + uniqueOperatorIdentifiers, + blockData, + ); + return { duplicates: duplicateKeys, unresolved: unresolvedKeys }; + } + + private findDuplicatesWithinOperator( + operatorKeys: RegistryKey[], + ): RegistryKey[] { + // Assuming keys belong to a single operator + const earliestKey = this.findEarliestKeyWithinOperator(operatorKeys); + return operatorKeys.filter((key) => key.index !== earliestKey.index); + } + + private findEarliestKeyWithinOperator( + operatorKeys: RegistryKey[], + ): RegistryKey { + return operatorKeys.reduce( + (prev, curr) => (prev.index < curr.index ? prev : curr), + operatorKeys[0], + ); + } + + private hasDepositedKey(keys: RegistryKey[]): boolean { + return keys.some((key) => key.used); + } + + private async getDuplicatesAcrossOperators( + key: string, + suspectedDuplicateKeys: RegistryKey[], + uniqueOperatorIdentifiers: string[], + blockData: BlockData, + ) { + const { events } = await this.fetchSigningKeyEvents(key, blockData); + + const operatorsWithoutEvents = this.getOperatorsWithoutEvents( + uniqueOperatorIdentifiers, + events, + ); + + if (operatorsWithoutEvents.length) { + this.logger.error('Missing events for operators', { + operatorsWithoutEvents, + currentBlockNumber: blockData.blockNumber, + currentBlockHash: blockData.blockHash, + }); + // Return the entire list of duplicates as unresolved + return { duplicateKeys: [], unresolvedKeys: suspectedDuplicateKeys }; + } + + return this.handleEventsForDuplicates( + events, + suspectedDuplicateKeys, + blockData, + ); + } + + private handleEventsForDuplicates( + events: SigningKeyEvent[], + suspectedDuplicateKeys: RegistryKey[], + blockData: BlockData, + ) { + const earliestEvents = this.findEarliestEvents(events); + + // have only one event + if (earliestEvents.length === 1) { + const earliestEvent = earliestEvents[0]; + + const duplicateKeys = this.filterNonEarliestKeys( + earliestEvent, + suspectedDuplicateKeys, + blockData, + ); + + return { duplicateKeys, unresolvedKeys: [] }; + } + + // If there are few events at the same block + // There can be an attempt to front-run the key submission transaction, + // in this case, it's difficult to determine who was first, + // therefore it is proposed to unvet the entire set of duplicates. + // If trying to look at the log index, then a malicious actor can make a back-run + return { duplicateKeys: suspectedDuplicateKeys, unresolvedKeys: [] }; + } + + private async fetchSigningKeyEvents( + key: string, + blockData: BlockData, + ): Promise { + const eventsGroup = + await this.signingKeysRegistryService.getUpdatedSigningKeyEvents( + key, + blockData.blockNumber, + blockData.blockHash, + ); + + return eventsGroup; + } + + private getOperatorsWithoutEvents( + uniqueOperatorIdentifiers: string[], + events: SigningKeyEvent[], + ): string[] { + const eventOperators = new Set( + events.map((event) => `${event.moduleAddress}-${event.operatorIndex}`), + ); + return uniqueOperatorIdentifiers.filter( + (operatorIdentifier) => !eventOperators.has(operatorIdentifier), + ); + } + + private filterNonEarliestKeys( + earliestEvent: SigningKeyEvent, + suspectedDuplicateKeys: RegistryKey[], + blockData: BlockData, + ) { + const operatorKeys = this.findOperatorKeys( + suspectedDuplicateKeys, + earliestEvent.moduleAddress, + earliestEvent.operatorIndex, + ); + + const earliestKey = this.findEarliestKeyWithinOperator(operatorKeys); + + this.logger.log('Earliest key is', { + earliestKey, + createBlockNumber: earliestEvent.blockNumber, + createBlockHash: earliestEvent.blockHash, + currentBlockNumber: blockData.blockNumber, + currentBlockHash: blockData.blockHash, + }); + return suspectedDuplicateKeys.filter( + (key) => !this.isSameKey(key, earliestKey), + ); + } + + private findEarliestEvents(events: SigningKeyEvent[]): SigningKeyEvent[] { + if (events.length <= 1) return events; + + const { blockEvents } = events.reduce( + ({ earliestBlockNumber, blockEvents }, currEvent) => { + if (earliestBlockNumber === currEvent.blockNumber) { + blockEvents.push(currEvent); + return { + earliestBlockNumber, + blockEvents, + }; + } + + if (earliestBlockNumber > currEvent.blockNumber) { + return { + earliestBlockNumber: currEvent.blockNumber, + blockEvents: [currEvent], + }; + } + + return { earliestBlockNumber, blockEvents }; + }, + { + earliestBlockNumber: events[0].blockNumber, + blockEvents: [], + } as { earliestBlockNumber: number; blockEvents: SigningKeyEvent[] }, + ); + + return blockEvents; + } + + private findOperatorKeys( + keys: RegistryKey[], + moduleAddress: string, + operatorIndex: number, + ): RegistryKey[] { + return keys.filter( + (key) => + key.moduleAddress === moduleAddress && + key.operatorIndex === operatorIndex, + ); + } + + private isSameKey(key1: RegistryKey, key2: RegistryKey): boolean { + return ( + key1.moduleAddress === key2.moduleAddress && + key1.operatorIndex === key2.operatorIndex && + key1.index === key2.index + ); + } +} diff --git a/src/guardian/duplicates/keys.fixtures.ts b/src/guardian/duplicates/keys.fixtures.ts new file mode 100644 index 00000000..af6683a7 --- /dev/null +++ b/src/guardian/duplicates/keys.fixtures.ts @@ -0,0 +1,42 @@ +import { RegistryKey } from 'keys-api/interfaces/RegistryKey'; +import { SigningKeyEvent } from 'contracts/signing-keys-registry/interfaces/event.interface'; + +export const keyMock1: RegistryKey = { + key: '0xb3c90525010a5710d43acbea46047fc37ed55306d032527fa15dd7e8cd8a9a5fa490347cc5fce59936fb8300683cd9f3', + depositSignature: + '0x8a77d9411781360cc107344a99f6660b206d2c708ae7fa35565b76ec661a0b86b6c78f5b5691d2cf469c27d0655dfc6311451a9e0501f3c19c6f7e35a770d1a908bfec7cba2e07339dc633b8b6626216ce76ec0fa48ee56aaaf2f9dc7ccb2fe2', + operatorIndex: 1, + used: false, + moduleAddress: '0x9D4AF1Ee19Dad8857db3a45B0374c81c8A1C6320', + index: 52, + vetted: true, +}; + +export const keyMock2: RegistryKey = { + key: '0xa9bfaa8207ee6c78644c079ffc91b6e5abcc5eede1b7a06abb8fb40e490a75ea269c178dd524b65185299d2bbd2eb7b2', + depositSignature: + '0xaa5f2a1053ba7d197495df44d4a32b7ae10265cf9e38560a16b782978c0a24271a113c9538453b7e45f35cb64c7adb460d7a9fe8c8ce6b8c80ca42fd5c48e180c73fc08f7d35ba32e39f32c902fd333faf47611827f0b7813f11c4c518dd2e59', + operatorIndex: 1, + used: false, + moduleAddress: '0x9D4AF1Ee19Dad8857db3a45B0374c81c8A1C6320', + index: 51, + vetted: true, +}; + +export const eventMock1: SigningKeyEvent = { + operatorIndex: keyMock1.operatorIndex, + key: keyMock1.key, + moduleAddress: keyMock1.moduleAddress, + logIndex: 1, + blockNumber: 1, + blockHash: '0x', +}; + +export const eventMock2: SigningKeyEvent = { + operatorIndex: keyMock2.operatorIndex, + key: keyMock2.key, + moduleAddress: keyMock2.moduleAddress, + logIndex: 1, + blockNumber: 1, + blockHash: '0x', +}; diff --git a/src/guardian/guardian-message/guardian-message.service.ts b/src/guardian/guardian-message/guardian-message.service.ts index a76c7ee8..c12ee496 100644 --- a/src/guardian/guardian-message/guardian-message.service.ts +++ b/src/guardian/guardian-message/guardian-message.service.ts @@ -3,10 +3,12 @@ import { WINSTON_MODULE_NEST_PROVIDER } from 'nest-winston'; import { MessageDeposit, MessageMeta, - MessagePause, + MessagePauseV2, + MessagePauseV3, MessageRequiredFields, MessagesService, MessageType, + MessageUnvet, } from 'messages'; import { BlockData } from '../interfaces'; import { APP_NAME, APP_VERSION } from 'app.constants'; @@ -51,16 +53,34 @@ export class GuardianMessageService { } /** - * Sends a pause message to the message broker + * Sends a pause message of version 2 to the message broker * @param message - MessagePause object */ - public sendPauseMessage(message: Omit) { + public sendPauseMessageV2(message: Omit) { return this.sendMessageFromGuardian({ ...message, type: MessageType.PAUSE, }); } + /** + * Sends a pause message of version 3 to the message broker + * @param message - MessagePause object + */ + public sendPauseMessageV3(message: Omit) { + return this.sendMessageFromGuardian({ + ...message, + type: MessageType.PAUSE, + }); + } + + public sendUnvetMessage(message: Omit) { + return this.sendMessageFromGuardian({ + ...message, + type: MessageType.UNVET, + }); + } + /** * Adds information about the app to the message * @param message - message object @@ -83,6 +103,7 @@ export class GuardianMessageService { if (messageData.guardianIndex == -1) { this.logger.warn( 'Your address is not in the Guardian List. The message will not be sent', + { type: messageData.type }, ); return; } diff --git a/src/guardian/guardian-metrics/guardian-metrics.service.ts b/src/guardian/guardian-metrics/guardian-metrics.service.ts index a04c2ba6..1d0506ec 100644 --- a/src/guardian/guardian-metrics/guardian-metrics.service.ts +++ b/src/guardian/guardian-metrics/guardian-metrics.service.ts @@ -1,5 +1,5 @@ import { Injectable } from '@nestjs/common'; -import { VerifiedDepositEvent } from 'contracts/deposit'; +import { VerifiedDepositEvent } from 'contracts/deposits-registry'; import { BlockData, StakingModuleData } from '../interfaces'; import { InjectMetric } from '@willsoto/nestjs-prometheus'; import { @@ -8,8 +8,7 @@ import { METRIC_OPERATORS_KEYS_TOTAL, METRIC_INTERSECTIONS_TOTAL, METRIC_INVALID_KEYS_TOTAL, - METRIC_DUPLICATED_VETTED_UNUSED_KEYS_TOTAL, - METRIC_DUPLICATED_USED_KEYS_TOTAL, + METRIC_DUPLICATED_KEYS_TOTAL, } from 'common/prometheus'; import { Gauge } from 'prom-client'; @@ -28,11 +27,8 @@ export class GuardianMetricsService { @InjectMetric(METRIC_INTERSECTIONS_TOTAL) private intersectionsCounter: Gauge, - @InjectMetric(METRIC_DUPLICATED_USED_KEYS_TOTAL) - private duplicatedUsedKeysCounter: Gauge, - - @InjectMetric(METRIC_DUPLICATED_VETTED_UNUSED_KEYS_TOTAL) - private duplicatedVettedUnusedKeysCounter: Gauge, + @InjectMetric(METRIC_DUPLICATED_KEYS_TOTAL) + private duplicatedKeysCounter: Gauge, @InjectMetric(METRIC_INVALID_KEYS_TOTAL) private invalidKeysCounter: Gauge, @@ -104,11 +100,11 @@ export class GuardianMetricsService { * @param blockData - collected data from the current block */ public collectOperatorMetrics(stakingModuleData: StakingModuleData): void { - const { unusedKeys, stakingModuleId } = stakingModuleData; + const { vettedUnusedKeys, stakingModuleId } = stakingModuleData; - const operatorsKeysTotal = unusedKeys.length; + const operatorsKeysTotal = vettedUnusedKeys.length; this.operatorsKeysCounter.set( - { type: 'unused', stakingModuleId }, + { type: 'vetted_unused', stakingModuleId }, operatorsKeysTotal, ); } @@ -133,26 +129,29 @@ export class GuardianMetricsService { /** * increment duplicated vetted unused keys event counter */ - public collectDuplicatedVettedUnusedKeysMetrics( + public collectDuplicatedKeysMetrics( stakingModuleId: number, - duplicatedVettedUnusedKeysCount: number, + allUnresolved: number, + unresolved: number, + allVettedUnused: number, + vettedUnused: number, ) { - this.duplicatedVettedUnusedKeysCounter.set( - { stakingModuleId }, - duplicatedVettedUnusedKeysCount, + this.duplicatedKeysCounter.set( + { stakingModuleId, type: 'all_unresolved' }, + allUnresolved, ); - } - - /** - * increment duplicated used keys event counter - */ - public collectDuplicatedUsedKeysMetrics( - stakingModuleId: number, - duplicatedUsedKeysCount: number, - ) { - this.duplicatedUsedKeysCounter.set( - { stakingModuleId }, - duplicatedUsedKeysCount, + this.duplicatedKeysCounter.set( + { stakingModuleId, type: 'vetted_unused_unresolved' }, + unresolved, + ); + // resolved - SigningKeyAdded event exists + this.duplicatedKeysCounter.set( + { stakingModuleId, type: 'all_vetted_unused' }, + allVettedUnused, + ); + this.duplicatedKeysCounter.set( + { stakingModuleId, type: 'vetted_unused' }, + vettedUnused, ); } diff --git a/src/guardian/guardian.constants.ts b/src/guardian/guardian.constants.ts index 9fcaaa75..459da204 100644 --- a/src/guardian/guardian.constants.ts +++ b/src/guardian/guardian.constants.ts @@ -3,4 +3,4 @@ import { CronExpression } from '@nestjs/schedule'; export const GUARDIAN_DEPOSIT_RESIGNING_BLOCKS = 10; export const GUARDIAN_DEPOSIT_JOB_NAME = 'guardian-deposit-job'; export const GUARDIAN_DEPOSIT_JOB_DURATION = CronExpression.EVERY_5_SECONDS; -export const MIN_KAPI_VERSION = '1.0.1'; +export const MIN_KAPI_VERSION = '2.1.0'; diff --git a/src/guardian/guardian.module.ts b/src/guardian/guardian.module.ts index dc129cc1..509b4b66 100644 --- a/src/guardian/guardian.module.ts +++ b/src/guardian/guardian.module.ts @@ -1,30 +1,34 @@ import { Module } from '@nestjs/common'; -import { DepositModule } from 'contracts/deposit'; +import { DepositsRegistryModule } from 'contracts/deposits-registry'; import { SecurityModule } from 'contracts/security'; -import { LidoModule } from 'contracts/lido'; import { MessagesModule } from 'messages'; import { GuardianService } from './guardian.service'; -import { StakingRouterModule } from 'staking-router'; import { ScheduleModule } from 'common/schedule'; -import { BlockGuardModule } from './block-guard/block-guard.module'; +import { BlockDataCollectorModule } from './block-data-collector/block-data-collector.module'; import { StakingModuleGuardModule } from './staking-module-guard'; import { GuardianMessageModule } from './guardian-message'; import { GuardianMetricsModule } from './guardian-metrics'; import { KeysApiModule } from 'keys-api/keys-api.module'; +import { SigningKeysRegistryModule } from 'contracts/signing-keys-registry'; +import { UnvettingModule } from './unvetting/unvetting.module'; +import { StakingModuleDataCollectorModule } from 'staking-module-data-collector'; +import { StakingRouterModule } from 'contracts/staking-router'; @Module({ imports: [ - DepositModule, + DepositsRegistryModule.register(), SecurityModule, - LidoModule, MessagesModule, - StakingRouterModule, + StakingModuleDataCollectorModule, ScheduleModule, - BlockGuardModule, + BlockDataCollectorModule, StakingModuleGuardModule, + UnvettingModule, GuardianMessageModule, GuardianMetricsModule, KeysApiModule, + SigningKeysRegistryModule.register(), + StakingRouterModule, ], providers: [GuardianService], exports: [GuardianService], diff --git a/src/guardian/guardian.service.spec.ts b/src/guardian/guardian.service.spec.ts index c5d0db9b..46d52c82 100644 --- a/src/guardian/guardian.service.spec.ts +++ b/src/guardian/guardian.service.spec.ts @@ -7,27 +7,26 @@ import { LoggerService } from '@nestjs/common'; import { ConfigModule } from 'common/config'; import { PrometheusModule } from 'common/prometheus'; import { GuardianModule } from 'guardian'; -import { DepositModule } from 'contracts/deposit'; +import { DepositsRegistryModule } from 'contracts/deposits-registry'; import { SecurityModule } from 'contracts/security'; import { RepositoryModule, RepositoryService } from 'contracts/repository'; -import { LidoModule } from 'contracts/lido'; import { MessagesModule } from 'messages'; -import { StakingRouterModule, StakingRouterService } from 'staking-router'; +import { StakingModuleDataCollectorModule } from 'staking-module-data-collector'; import { GuardianMetricsModule } from './guardian-metrics'; import { GuardianMessageModule } from './guardian-message'; import { StakingModuleGuardModule } from './staking-module-guard'; -import { BlockGuardModule, BlockGuardService } from './block-guard'; +import { BlockDataCollectorModule } from './block-data-collector'; import { ScheduleModule } from 'common/schedule'; import { LocatorService } from 'contracts/repository/locator/locator.service'; import { mockLocator } from 'contracts/repository/locator/locator.mock'; import { mockRepository } from 'contracts/repository/repository.mock'; +import { KeysApiService } from 'keys-api/keys-api.service'; +import { UnvettingModule } from './unvetting/unvetting.module'; jest.mock('../transport/stomp/stomp.client'); describe('GuardianService', () => { - let stakingRouterService: StakingRouterService; - let blockGuardService: BlockGuardService; - + let keysApiService: KeysApiService; let guardianService: GuardianService; let loggerService: LoggerService; @@ -43,21 +42,20 @@ describe('GuardianService', () => { PrometheusModule, GuardianModule, RepositoryModule, - DepositModule, + DepositsRegistryModule.register('latest'), SecurityModule, - LidoModule, MessagesModule, - StakingRouterModule, + StakingModuleDataCollectorModule, ScheduleModule, - BlockGuardModule, + BlockDataCollectorModule, StakingModuleGuardModule, GuardianMessageModule, GuardianMetricsModule, + UnvettingModule, ], }).compile(); - stakingRouterService = moduleRef.get(StakingRouterService); - blockGuardService = moduleRef.get(BlockGuardService); + keysApiService = moduleRef.get(KeysApiService); repositoryService = moduleRef.get(RepositoryService); locatorService = moduleRef.get(LocatorService); @@ -77,21 +75,31 @@ describe('GuardianService', () => { it('should exit if the previous call is not completed', async () => { // OneAtTime test const getOperatorsAndModulesMock = jest - .spyOn(stakingRouterService, 'getOperatorsAndModules') + .spyOn(keysApiService, 'getModules') .mockImplementation(async () => ({ data: [], - meta: { - elBlockSnapshot: { - blockNumber: 0, - blockHash: 'string', - timestamp: 0, - lastChangedBlockHash: '', - }, + elBlockSnapshot: { + blockNumber: 0, + blockHash: 'string', + timestamp: 0, + lastChangedBlockHash: '', }, })); - const getBlockGuardServiceMock = jest - .spyOn(blockGuardService, 'isNeedToProcessNewState') + jest.spyOn(keysApiService, 'getKeys').mockImplementation(async () => ({ + data: [], + meta: { + elBlockSnapshot: { + blockNumber: 0, + blockHash: 'string', + timestamp: 0, + lastChangedBlockHash: '', + }, + }, + })); + + const isNeedToProcessNewStatMock = jest + .spyOn(guardianService, 'isNeedToProcessNewState') .mockImplementation(() => false); // run concurrently and check that second attempt @@ -100,7 +108,7 @@ describe('GuardianService', () => { guardianService.handleNewBlock(), ]); - expect(getBlockGuardServiceMock).toBeCalledTimes(1); + expect(isNeedToProcessNewStatMock).toBeCalledTimes(1); expect(getOperatorsAndModulesMock).toBeCalledTimes(1); }); }); diff --git a/src/guardian/guardian.service.ts b/src/guardian/guardian.service.ts index 064d0652..987a8b91 100644 --- a/src/guardian/guardian.service.ts +++ b/src/guardian/guardian.service.ts @@ -8,7 +8,7 @@ import { compare } from 'compare-versions'; import { WINSTON_MODULE_NEST_PROVIDER } from 'nest-winston'; import { SchedulerRegistry } from '@nestjs/schedule'; import { CronJob } from 'cron'; -import { DepositService } from 'contracts/deposit'; +import { DepositRegistryService } from 'contracts/deposits-registry'; import { SecurityService } from 'contracts/security'; import { RepositoryService } from 'contracts/repository'; import { @@ -16,16 +16,23 @@ import { GUARDIAN_DEPOSIT_JOB_NAME, } from './guardian.constants'; import { OneAtTime } from 'common/decorators'; -import { StakingRouterService } from 'staking-router'; +import { StakingModuleDataCollectorService } from 'staking-module-data-collector'; + +import { BlockDataCollectorService } from './block-data-collector'; -import { BlockGuardService } from './block-guard'; import { StakingModuleGuardService } from './staking-module-guard'; import { GuardianMessageService } from './guardian-message'; import { GuardianMetricsService } from './guardian-metrics'; -import { StakingModuleData } from './interfaces'; +import { BlockData, StakingModuleData } from './interfaces'; import { ProviderService } from 'provider'; import { KeysApiService } from 'keys-api/keys-api.service'; import { MIN_KAPI_VERSION } from './guardian.constants'; +import { UnvettingService } from './unvetting/unvetting.service'; +import { RegistryKey } from 'keys-api/interfaces/RegistryKey'; +import { StakingRouterService } from 'contracts/staking-router'; +import { ELBlockSnapshot } from 'keys-api/interfaces/ELBlockSnapshot'; +import { SRModule } from 'keys-api/interfaces'; +import { SigningKeysRegistryService } from 'contracts/signing-keys-registry'; @Injectable() export class GuardianService implements OnModuleInit { @@ -38,17 +45,22 @@ export class GuardianService implements OnModuleInit { private schedulerRegistry: SchedulerRegistry, - private depositService: DepositService, + private depositService: DepositRegistryService, private securityService: SecurityService, - private stakingRouterService: StakingRouterService, + private stakingModuleDataCollectorService: StakingModuleDataCollectorService, - private blockGuardService: BlockGuardService, + private blockDataCollectorService: BlockDataCollectorService, private stakingModuleGuardService: StakingModuleGuardService, private guardianMessageService: GuardianMessageService, private guardianMetricsService: GuardianMetricsService, private providerService: ProviderService, private keysApiService: KeysApiService, + private signingKeysRegistryService: SigningKeysRegistryService, + + private unvettingService: UnvettingService, + + private stakingRouterService: StakingRouterService, ) {} public async onModuleInit(): Promise { @@ -59,9 +71,15 @@ export class GuardianService implements OnModuleInit { const block = await this.repositoryService.initOrWaitCachedContracts(); const blockHash = block.hash; + const stakingRouterModuleAddresses = + await this.stakingRouterService.getStakingModulesAddresses(blockHash); + await Promise.all([ - this.depositService.initialize(block.number), + this.depositService.initialize(), this.securityService.initialize({ blockHash }), + this.signingKeysRegistryService.initialize( + stakingRouterModuleAddresses, + ), ]); const chainId = await this.providerService.getChainId(); @@ -87,10 +105,6 @@ export class GuardianService implements OnModuleInit { ); } - // The event cache is stored with an N block lag to avoid caching data from uncle blocks - // so we don't worry about blockHash here - await this.depositService.updateEventsCache(); - this.subscribeToModulesUpdates(); } catch (error) { this.logger.error(error); @@ -124,110 +138,284 @@ export class GuardianService implements OnModuleInit { this.logger.log('New staking router state cycle start'); try { - const { data: operatorsByModules, meta } = - await this.stakingRouterService.getOperatorsAndModules(); + // Fetch the minimum required data fro Keys Api to make an early exit + const { data: stakingModules, elBlockSnapshot: firstRequestMeta } = + await this.keysApiService.getModules(); + + const { blockHash, blockNumber } = firstRequestMeta; + + // Compare the block stored in memory from the previous iteration with the current block from the Keys API. + const isNewBlock = this.isNeedToProcessNewState({ + blockHash, + blockNumber, + }); - const { - elBlockSnapshot: { blockHash, blockNumber }, - } = meta; + if (!isNewBlock) return; + const stakingModulesCount = stakingModules.length; + + this.logger.log('Staking modules loaded', { + modulesCount: stakingModulesCount, + }); + + // fetch all lido keys + const { data: lidoKeys, meta: secondRequestMeta } = + await this.keysApiService.getKeys(); + + // check that there were no updates in Keys Api between two requests + this.keysApiService.verifyMetaDataConsistency( + firstRequestMeta.lastChangedBlockHash, + secondRequestMeta.elBlockSnapshot.lastChangedBlockHash, + ); + + // contracts initialization await this.repositoryService.initCachedContracts({ blockHash }); + await this.depositService.handleNewBlock(); + + const { stakingModulesData, blockData } = await this.collectData( + stakingModules, + firstRequestMeta, + lidoKeys, + ); + if ( - !this.blockGuardService.isNeedToProcessNewState({ - blockHash, - blockNumber, - }) + blockData.securityVersion === 3 && + !blockData.alreadyPausedDeposits && + blockData.theftHappened ) { - this.logger.debug?.( - `The block has not changed since the last cycle. Exit`, - { - blockHash, - blockNumber, - }, + await this.stakingModuleGuardService.handlePauseV3(blockData); + return; + } + + if (blockData.securityVersion !== 3 && blockData.theftHappened) { + await this.stakingModuleGuardService.handlePauseV2( + stakingModulesData, + blockData, ); return; } - const stakingModulesCount = operatorsByModules.length; + // To avoid blocking the pause, run the following tasks asynchronously: + // updating the SigningKeyAdded events cache, checking keys, handling the unvetting of keys, + // and sending deposit messages to the queue. + this.handleKeys(stakingModulesData, blockData, lidoKeys).catch( + this.logger.error, + ); - this.logger.log('Staking modules loaded', { - modulesCount: stakingModulesCount, - }); + await this.guardianMessageService.pingMessageBroker( + stakingModulesData.map(({ stakingModuleId }) => stakingModuleId), + blockData, + ); + } catch (error) { + this.logger.error('Staking router state update error'); + this.logger.error(error); + } + } - await this.depositService.handleNewBlock(blockNumber); + private async collectData( + stakingModules: SRModule[], + meta: ELBlockSnapshot, + lidoKeys: RegistryKey[], + ) { + const { blockHash, blockNumber } = meta; - // TODO: e2e test 'node operator deposit frontrun' shows that it is possible to find event and not save in cache - const blockData = await this.blockGuardService.getCurrentBlockData({ + const [blockData, stakingModulesData] = await Promise.all([ + this.blockDataCollectorService.getCurrentBlockData({ blockHash, blockNumber, - }); + }), + // Construct the Staking Module data array using information fetched from the Keys API, + // identifying vetted unused keys and checking the module pause status + this.stakingModuleDataCollectorService.collectStakingModuleData({ + stakingModules, + meta, + lidoKeys, + }), + ]); + + this.logger.debug?.('Current block data loaded', { + guardianIndex: blockData.guardianIndex, + blockNumber: blockNumber, + blockHash: blockHash, + securityVersion: blockData.securityVersion, + }); - this.logger.debug?.('Current block data loaded', { - guardianIndex: blockData.guardianIndex, - blockNumber: blockData.blockNumber, - blockHash: blockData.blockHash, - }); + return { blockData, stakingModulesData }; + } + + /** + * This method check keys and if they are correct send deposit message in queue, another way send unvet transaction + */ + @OneAtTime() + private async handleKeys( + stakingModulesData: StakingModuleData[], + blockData: BlockData, + lidoKeys: RegistryKey[], + ) { + // check lido keys + await this.checkKeys(stakingModulesData, blockData, lidoKeys); + // unvet keys if need + await this.handleUnvetting(stakingModulesData, blockData); + await this.handleDeposit(stakingModulesData, blockData); + + const { blockHash, blockNumber } = blockData; + this.setLastProcessedStateMeta({ + blockHash, + blockNumber, + }); - const stakingModulesData = - await this.stakingRouterService.getStakingModulesData({ - data: operatorsByModules, - meta, - }); + this.logger.log('New staking router state cycle end'); + } - const modulesIdWithDuplicateKeys: number[] = - this.stakingModuleGuardService.getModulesIdsWithDuplicatedVettedUnusedKeys( - stakingModulesData, - blockData, - ); + private async checkKeys( + stakingModulesData: StakingModuleData[], + blockData: BlockData, + lidoKeys: RegistryKey[], + ) { + const stakingRouterModuleAddresses = stakingModulesData.map( + (stakingModule) => stakingModule.stakingModuleAddress, + ); + // update cache if needs + await this.signingKeysRegistryService.handleNewBlock( + stakingRouterModuleAddresses, + ); + + // check keys on duplicates, attempts of front-run and check signatures + await this.stakingModuleDataCollectorService.checkKeys( + stakingModulesData, + lidoKeys, + blockData, + ); + } - const stakingModulesWithoutDuplicates: StakingModuleData[] = - this.stakingModuleGuardService.excludeModulesWithDuplicatedKeys( - stakingModulesData, - modulesIdWithDuplicateKeys, - ); + private async handleUnvetting( + stakingModulesData: StakingModuleData[], + blockData: BlockData, + ) { + if (blockData.securityVersion !== 3) { + return; + } - this.logger.log('Staking modules without duplicates', { - modulesCount: stakingModulesWithoutDuplicates.length, - }); + const firstInvalidModule = this.findFirstInvalidModule(stakingModulesData); + + if (!firstInvalidModule) { + this.logger.log( + 'Keys of all modules are correct. No need in unvetting.', + { + blockHash: blockData.blockHash, + }, + ); + return; + } - await Promise.all( - stakingModulesData.map(async (stakingModuleData) => { - // stakingModulesWithoutDuplicates - modules without duplicates - // if found in this module it means it doesnt have duplicates + await this.unvettingService.handleUnvetting(firstInvalidModule, blockData); + } - const noDuplicates = !!stakingModulesWithoutDuplicates.find( - (srmd) => - srmd.stakingModuleId === stakingModuleData.stakingModuleId, - ); + private findFirstInvalidModule( + stakingModulesData: StakingModuleData[], + ): StakingModuleData | undefined { + return stakingModulesData.find((moduleData) => + this.hasInvalidKeys(moduleData), + ); + } - await this.stakingModuleGuardService.checkKeysIntersections( - stakingModuleData, - blockData, - noDuplicates, - ); + private hasInvalidKeys(moduleData: StakingModuleData): boolean { + const keys = moduleData.invalidKeys.concat( + moduleData.duplicatedKeys, + moduleData.frontRunKeys, + ); + return keys.length > 0; + } + + private async handleDeposit( + stakingModulesData: StakingModuleData[], + blockData: BlockData, + ) { + await Promise.all( + stakingModulesData.map(async (stakingModuleData) => { + this.guardianMetricsService.collectMetrics( + stakingModuleData, + blockData, + ); - this.guardianMetricsService.collectMetrics( + if ( + this.ignoreDeposits( stakingModuleData, - blockData, - ); - }), - ); + blockData.theftHappened, + blockData.alreadyPausedDeposits, + stakingModuleData.stakingModuleId, + ) + ) { + return; + } - await this.guardianMessageService.pingMessageBroker( - stakingModulesData.map(({ stakingModuleId }) => stakingModuleId), - blockData, - ); + await this.stakingModuleGuardService.handleCorrectKeys( + stakingModuleData, + blockData, + ); + }), + ); + } - this.blockGuardService.setLastProcessedStateMeta({ - blockHash, - blockNumber, + private ignoreDeposits( + stakingModuleData: StakingModuleData, + theftHappened: boolean, + alreadyPausedDeposits: boolean, + stakingModuleId: number, + ): boolean { + const keysForUnvetting = stakingModuleData.invalidKeys.concat( + stakingModuleData.frontRunKeys, + stakingModuleData.duplicatedKeys, + ); + + // if neither of this conditions is true, deposits are allowed for module + const ignoreDeposits = + keysForUnvetting.length > 0 || + stakingModuleData.unresolvedDuplicatedKeys.length > 0 || + alreadyPausedDeposits || + theftHappened || + stakingModuleData.isModuleDepositsPaused; + + if (ignoreDeposits) { + this.logger.warn('Deposits are not available', { + keysForUnvetting: keysForUnvetting.length, + duplicates: stakingModuleData.unresolvedDuplicatedKeys.length, + alreadyPausedDeposits, + theftHappened, + isModuleDepositsPaused: stakingModuleData.isModuleDepositsPaused, + stakingModuleId, }); - } catch (error) { - this.logger.error('Staking router state update error'); - this.logger.error(error); - } finally { - this.logger.log('New staking router state cycle end'); } + + return ignoreDeposits; + } + + public isNeedToProcessNewState(newMeta: { + blockHash: string; + blockNumber: number; + }) { + const lastMeta = this.lastProcessedStateMeta; + if (!lastMeta) return true; + if (lastMeta.blockNumber > newMeta.blockNumber) { + this.logger.error('Keys API returns old state', { newMeta, lastMeta }); + return false; + } + const isSameBlock = lastMeta.blockHash === newMeta.blockHash; + + if (isSameBlock) { + this.logger.log(`The block has not changed since the last cycle. Exit`, { + newMeta, + }); + } + + return !isSameBlock; + } + + private setLastProcessedStateMeta(newMeta: { + blockHash: string; + blockNumber: number; + }) { + this.lastProcessedStateMeta = newMeta; } } diff --git a/src/guardian/interfaces/block.interface.ts b/src/guardian/interfaces/block.interface.ts index 7e5d1d97..9580e095 100644 --- a/src/guardian/interfaces/block.interface.ts +++ b/src/guardian/interfaces/block.interface.ts @@ -1,11 +1,15 @@ -import { VerifiedDepositEventGroup } from 'contracts/deposit'; +import { VerifiedDepositedEventGroup } from 'contracts/deposits-registry'; export interface BlockData { blockNumber: number; blockHash: string; depositRoot: string; - depositedEvents: VerifiedDepositEventGroup; + depositedEvents: VerifiedDepositedEventGroup; guardianAddress: string; guardianIndex: number; lidoWC: string; + securityVersion: number; + alreadyPausedDeposits: boolean; + theftHappened: boolean; + walletBalanceCritical: boolean; } diff --git a/src/guardian/interfaces/staking-module.interface.ts b/src/guardian/interfaces/staking-module.interface.ts index a600a67a..37596dd5 100644 --- a/src/guardian/interfaces/staking-module.interface.ts +++ b/src/guardian/interfaces/staking-module.interface.ts @@ -2,9 +2,14 @@ import { RegistryKey } from 'keys-api/interfaces/RegistryKey'; export interface StakingModuleData { blockHash: string; - unusedKeys: string[]; vettedUnusedKeys: RegistryKey[]; nonce: number; stakingModuleId: number; + stakingModuleAddress: string; lastChangedBlockHash: string; + duplicatedKeys: RegistryKey[]; + invalidKeys: RegistryKey[]; + frontRunKeys: RegistryKey[]; + unresolvedDuplicatedKeys: RegistryKey[]; + isModuleDepositsPaused: boolean; } diff --git a/src/guardian/interfaces/state.interface.ts b/src/guardian/interfaces/state.interface.ts index b9e0a858..b04f98e1 100644 --- a/src/guardian/interfaces/state.interface.ts +++ b/src/guardian/interfaces/state.interface.ts @@ -3,5 +3,4 @@ export interface ContractsState { nonce: number; depositRoot: string; lastChangedBlockHash: string; - invalidKeysFound: boolean; } diff --git a/src/guardian/keys-validation/constants.ts b/src/guardian/keys-validation/constants.ts index 1f71acf3..69c257c5 100644 --- a/src/guardian/keys-validation/constants.ts +++ b/src/guardian/keys-validation/constants.ts @@ -1,2 +1 @@ -// TODO: put in config -export const KEYS_LRU_CACHE_SIZE = 32000; +export const DEPOSIT_DATA_LRU_CACHE_SIZE = 80000; diff --git a/src/guardian/keys-validation/keys-validation.service.ts b/src/guardian/keys-validation/keys-validation.service.ts index 00ba0309..e8861f6c 100644 --- a/src/guardian/keys-validation/keys-validation.service.ts +++ b/src/guardian/keys-validation/keys-validation.service.ts @@ -1,120 +1,135 @@ import { Inject, Injectable, LoggerService } from '@nestjs/common'; -import { WINSTON_MODULE_NEST_PROVIDER } from 'nest-winston'; import { KeyValidatorInterface, bufferFromHexString, - Pubkey, WithdrawalCredentialsBuffer, Key, } from '@lido-nestjs/key-validation'; import { RegistryKey } from 'keys-api/interfaces/RegistryKey'; import { GENESIS_FORK_VERSION_BY_CHAIN_ID } from 'bls/bls.constants'; import { LRUCache } from 'lru-cache'; -import { KEYS_LRU_CACHE_SIZE } from './constants'; +import { DEPOSIT_DATA_LRU_CACHE_SIZE } from './constants'; import { ProviderService } from 'provider'; +import { WINSTON_MODULE_NEST_PROVIDER } from 'nest-winston'; -type DepositData = { - key: Pubkey; - depositSignature: string; +type DepositKey = RegistryKey & { withdrawalCredentials: WithdrawalCredentialsBuffer; genesisForkVersion: Buffer; }; @Injectable() export class KeysValidationService { - private keysCache: LRUCache; + private depositDataCache: LRUCache; constructor( - @Inject(WINSTON_MODULE_NEST_PROVIDER) - protected readonly logger: LoggerService, private readonly keyValidator: KeyValidatorInterface, private readonly provider: ProviderService, + @Inject(WINSTON_MODULE_NEST_PROVIDER) private logger: LoggerService, ) { - this.keysCache = new LRUCache({ max: KEYS_LRU_CACHE_SIZE }); + this.depositDataCache = new LRUCache({ max: DEPOSIT_DATA_LRU_CACHE_SIZE }); } /** - * - * Return list of invalid keys + * return list of invalid keys + * @param keys + * @param withdrawalCredentials */ - public async findInvalidKeys( - vettedKeys: RegistryKey[], + public async getInvalidKeys( + keys: RegistryKey[], withdrawalCredentials: string, - ): Promise<{ key: string; depositSignature: string }[]> { - const forkVersion: Uint8Array = await this.forkVersion(); - - const { keysNeedingValidation, unchangedAndInvalidKeys } = - this.divideKeys(vettedKeys); - - const keysForValidation = keysNeedingValidation.map((key) => - this.toDepositData(key, withdrawalCredentials, forkVersion), + ): Promise { + const withdrawalCredentialsBuffer = bufferFromHexString( + withdrawalCredentials, ); + const genesisForkVersion: Uint8Array = await this.forkVersion(); + const genesisForkVersionBuffer = Buffer.from(genesisForkVersion.buffer); + + const { cachedInvalidKeyList, uncachedDepositKeyList } = + this.partitionCachedData( + keys, + withdrawalCredentialsBuffer, + genesisForkVersionBuffer, + ); + + this.logger.log('Validation status of deposit keys:', { + cachedInvalidKeyCount: cachedInvalidKeyList.length, + keysNeedingValidationCount: uncachedDepositKeyList.length, + totalKeysCount: keys.length, + }); - const validatedKeys: [Key & DepositData, boolean][] = - await this.keyValidator.validateKeys(keysForValidation); + const validatedDepositKeyList: [DepositKey & Key, boolean][] = + await this.keyValidator.validateKeys(uncachedDepositKeyList); - this.updateCache(validatedKeys); + this.updateCache(validatedDepositKeyList); - // this list will not include invalid keys from cache - const invalidKeysFromCurrentValidation = validatedKeys - .filter(([, isValid]) => !isValid) - .map(([key]) => ({ - key: key.key, - depositSignature: key.depositSignature, - })); + const invalidKeys = this.filterInvalidKeys(validatedDepositKeyList); - this.logger.log('Validation keys information', { - vettedKeysCount: vettedKeys.length, - currentCacheSize: this.keysCache.size, - cacheInvalidKeysCount: unchangedAndInvalidKeys.length, - newInvalidKeys: invalidKeysFromCurrentValidation.length, - }); + return cachedInvalidKeyList.concat(invalidKeys); + } - const unchangedAndInvalidKeysValues = unchangedAndInvalidKeys.map( - (key) => ({ - key: key.key, - depositSignature: key.depositSignature, - }), + private filterInvalidKeys( + validatedKeys: [DepositKey & Key, boolean][], + ): RegistryKey[] { + return validatedKeys.reduce( + (invalidKeys, [data, isValid]) => { + if (!isValid) { + invalidKeys.push({ + key: data.key, + depositSignature: data.depositSignature, + operatorIndex: data.operatorIndex, + used: data.used, + index: data.index, + moduleAddress: data.moduleAddress, + vetted: data.vetted, + }); + } + return invalidKeys; + }, + [], ); - - // merge just checked invalid keys and invalid keys from cache but only from vettedKeys - return [ - ...invalidKeysFromCurrentValidation, - ...unchangedAndInvalidKeysValues, - ]; } - private divideKeys(vettedKeys: RegistryKey[]): { - keysNeedingValidation: RegistryKey[]; - unchangedAndInvalidKeys: RegistryKey[]; + /** + * Partition the deposit data into cached invalid data and uncached data. + * @param depositDataList List of deposit data to check against the cache + * @returns An object containing cached invalid data and uncached data + */ + private partitionCachedData( + keys: RegistryKey[], + withdrawalCredentialsBuffer: WithdrawalCredentialsBuffer, + genesisForkVersionBuffer: Buffer, + ): { + cachedInvalidKeyList: RegistryKey[]; + uncachedDepositKeyList: DepositKey[]; } { - const keysNeedingValidation: RegistryKey[] = []; - const unchangedAndInvalidKeys: RegistryKey[] = []; - - vettedKeys.forEach((key) => { - const cachedEntry = this.keysCache.get(key.key); - - if (!cachedEntry || cachedEntry.signature !== key.depositSignature) { - keysNeedingValidation.push(key); - } else if (!cachedEntry.isValid) { - unchangedAndInvalidKeys.push(key); - } - }); - - return { keysNeedingValidation, unchangedAndInvalidKeys }; + return keys.reduce<{ + cachedInvalidKeyList: RegistryKey[]; + uncachedDepositKeyList: DepositKey[]; + }>( + (acc, key) => { + const depositKey = { + ...key, + withdrawalCredentials: withdrawalCredentialsBuffer, + genesisForkVersion: genesisForkVersionBuffer, + }; + const cacheResult = this.getCachedDepositData(depositKey); + + if (cacheResult === false) { + acc.cachedInvalidKeyList.push(key); + } + + if (cacheResult === undefined) { + acc.uncachedDepositKeyList.push(depositKey); + } + + return acc; + }, + { cachedInvalidKeyList: [], uncachedDepositKeyList: [] }, + ); } - private toDepositData( - key: RegistryKey, - withdrawalCredentials: string, - forkVersion: Uint8Array, - ): DepositData { - return { - key: key.key, - depositSignature: key.depositSignature, - withdrawalCredentials: bufferFromHexString(withdrawalCredentials), - genesisForkVersion: Buffer.from(forkVersion.buffer), - }; + private getCachedDepositData(depositKey: DepositKey): boolean | undefined { + return this.depositDataCache.get(this.serializeDepositData(depositKey)); } private async forkVersion(): Promise { @@ -128,9 +143,21 @@ export class KeysValidationService { return forkVersion; } - private async updateCache(validatedKeys: [Key & DepositData, boolean][]) { - validatedKeys.forEach(([key, isValid]) => - this.keysCache.set(key.key, { signature: key.depositSignature, isValid }), + private updateCache(validatedKeys: [Key & DepositKey, boolean][]) { + validatedKeys.forEach(([depositData, isValid]) => + this.depositDataCache.set( + this.serializeDepositData(depositData), + isValid, + ), ); } + + private serializeDepositData(depositKey: DepositKey): string { + return JSON.stringify({ + key: depositKey.key, + depositSignature: depositKey.depositSignature, + withdrawalCredentials: depositKey.withdrawalCredentials.toString('hex'), + genesisForkVersion: depositKey.genesisForkVersion.toString('hex'), + }); + } } diff --git a/src/guardian/keys-validation/keys-validation.spec.ts b/src/guardian/keys-validation/keys-validation.spec.ts index 1d089698..eebc91ad 100644 --- a/src/guardian/keys-validation/keys-validation.spec.ts +++ b/src/guardian/keys-validation/keys-validation.spec.ts @@ -10,23 +10,25 @@ import { LoggerModule } from 'common/logger'; import { ConfigModule } from 'common/config'; import { MockProviderModule } from 'provider'; import { - invalidKey, + invalidKey1, invalidKey2, invalidKey2GoodSign, validKeys, } from './keys.fixtures'; import { GENESIS_FORK_VERSION_BY_CHAIN_ID } from 'bls/bls.constants'; +import { WINSTON_MODULE_NEST_PROVIDER } from 'nest-winston'; describe('KeysValidationService', () => { let keysValidationService: KeysValidationService; let keysValidator: KeyValidatorInterface; - let validateKeysFun: jest.SpyInstance; const wc = '0x010000000000000000000000dc62f9e8c34be08501cdef4ebde0a280f576d762'; - beforeEach(async () => { + const fork = GENESIS_FORK_VERSION_BY_CHAIN_ID[5]; + + beforeAll(async () => { const moduleRef = await Test.createTestingModule({ imports: [ ConfigModule.forRoot(), @@ -41,113 +43,88 @@ describe('KeysValidationService', () => { keysValidator = moduleRef.get(KeyValidatorInterface); validateKeysFun = jest.spyOn(keysValidator, 'validateKeys'); + + const loggerService = moduleRef.get(WINSTON_MODULE_NEST_PROVIDER); + jest.spyOn(loggerService, 'warn').mockImplementation(() => undefined); + jest.spyOn(loggerService, 'log').mockImplementation(() => undefined); }); - it('should find and return invalid keys from the provided list', async () => { - // Test scenario where new invalid keys are added to the list - const result = await keysValidationService.findInvalidKeys( - [...validKeys, invalidKey, invalidKey2], - wc, - ); - - const expected = [invalidKey, invalidKey2].map((key) => ({ - key: key.key, - depositSignature: key.depositSignature, - })); - - const fork = GENESIS_FORK_VERSION_BY_CHAIN_ID[5]; - - const depositData = [...validKeys, invalidKey, invalidKey2].map((key) => ({ - key: key.key, - depositSignature: key.depositSignature, - withdrawalCredentials: bufferFromHexString(wc), - genesisForkVersion: Buffer.from(fork.buffer), - })); - - expect(validateKeysFun).toBeCalledTimes(1); - expect(validateKeysFun).toBeCalledWith(depositData); - expect(result).toEqual(expect.arrayContaining(expected)); - expect(result.length).toEqual(expected.length); - - validateKeysFun.mockClear(); - // Test scenario where one invalid key was removed from request's list - const newResult = await keysValidationService.findInvalidKeys( - [...validKeys, invalidKey], - wc, - ); - - const newExpected = [invalidKey].map((key) => ({ - key: key.key, - depositSignature: key.depositSignature, - })); - - expect(keysValidationService['keysCache'].get(invalidKey2.key)).toEqual({ - isValid: false, - signature: invalidKey2.depositSignature, + describe('Validate again if signature was changed', () => { + beforeEach(() => { + validateKeysFun.mockClear(); }); - expect(validateKeysFun).toBeCalledTimes(1); - expect(validateKeysFun).toBeCalledWith([]); - expect(newResult).toEqual(expect.arrayContaining(newExpected)); - expect(newResult.length).toEqual(newExpected.length); - }); + it('validate without use of cache', async () => { + const duplicate = { ...invalidKey1, index: 102 }; + const keysForValidation = [ + ...validKeys, + invalidKey1, + // getInvalidKeys should return all invalid duplicates + duplicate, + invalidKey2, + ]; + const result = await keysValidationService.getInvalidKeys( + keysForValidation, + wc, + ); + + // we extended RegistryKey to satisfy DepositData type + const depositKeyList = keysForValidation.map((key) => ({ + ...key, + depositSignature: key.depositSignature, + withdrawalCredentials: bufferFromHexString(wc), + genesisForkVersion: Buffer.from(fork.buffer), + })); + + expect(validateKeysFun).toBeCalledTimes(1); + expect(validateKeysFun).toBeCalledWith(depositKeyList); + expect(result).toEqual([invalidKey1, duplicate, invalidKey2]); + + expect(result[0].index).toEqual(invalidKey1.index); + expect(result[0].operatorIndex).toEqual(invalidKey1.operatorIndex); + expect(result[0].used).toEqual(invalidKey1.used); + expect(result[0].moduleAddress).toEqual(invalidKey1.moduleAddress); + }); + + it('validate with use of cache ', async () => { + const duplicate = { ...invalidKey1, index: 102 }; + // Test scenario where one invalid key was removed from request's list + const newResult = await keysValidationService.getInvalidKeys( + [...validKeys, invalidKey1, duplicate, invalidKey2], + wc, + ); + + expect(validateKeysFun).toBeCalledTimes(1); + expect(validateKeysFun).toBeCalledWith([]); + expect(newResult).toEqual([invalidKey1, duplicate, invalidKey2]); + }); - it('should validate key again if signature was changed', async () => { - // if signature was changed we need to repeat validation - // invalid key could become valid and visa versa - // Test scenario where new invalid keys are added to the list - const result = await keysValidationService.findInvalidKeys( - [...validKeys, invalidKey, invalidKey2], - wc, - ); - - const expected = [invalidKey, invalidKey2].map((key) => ({ - key: key.key, - depositSignature: key.depositSignature, - })); - - const fork = GENESIS_FORK_VERSION_BY_CHAIN_ID[5]; - - const depositData = [...validKeys, invalidKey, invalidKey2].map((key) => ({ - key: key.key, - depositSignature: key.depositSignature, - withdrawalCredentials: bufferFromHexString(wc), - genesisForkVersion: Buffer.from(fork.buffer), - })); - - expect(validateKeysFun).toBeCalledTimes(1); - expect(validateKeysFun).toBeCalledWith(depositData); - expect(result).toEqual(expect.arrayContaining(expected)); - expect(result.length).toEqual(expected.length); - - validateKeysFun.mockClear(); - // Test scenario where one invalid key was changed - const newResult = await keysValidationService.findInvalidKeys( - [ + it('validate without use of cache because of signature change', async () => { + const duplicate = { ...invalidKey1, index: 102 }; + const invalidKey2Fix = { + ...invalidKey2, + depositSignature: invalidKey2GoodSign, + }; + const keyForValidation = [ ...validKeys, - invalidKey, - { ...invalidKey2, depositSignature: invalidKey2GoodSign }, - ], - wc, - ); - - const newDepositData = [ - { ...invalidKey2, depositSignature: invalidKey2GoodSign }, - ].map((key) => ({ - key: key.key, - depositSignature: key.depositSignature, - withdrawalCredentials: bufferFromHexString(wc), - genesisForkVersion: Buffer.from(fork.buffer), - })); - - const newExpected = [invalidKey].map((key) => ({ - key: key.key, - depositSignature: key.depositSignature, - })); - - expect(validateKeysFun).toBeCalledTimes(1); - expect(validateKeysFun).toBeCalledWith(newDepositData); - expect(newResult).toEqual(expect.arrayContaining(newExpected)); - expect(newResult.length).toEqual(newExpected.length); + invalidKey1, + duplicate, + // change signature on valid + invalidKey2Fix, + ]; + const newResult = await keysValidationService.getInvalidKeys( + keyForValidation, + wc, + ); + const depositKeyList = [invalidKey2Fix].map((key) => ({ + ...key, + withdrawalCredentials: bufferFromHexString(wc), + genesisForkVersion: Buffer.from(fork.buffer), + })); + + expect(validateKeysFun).toBeCalledTimes(1); + expect(validateKeysFun).toBeCalledWith(depositKeyList); + expect(newResult).toEqual([invalidKey1, duplicate]); + }); }); }); diff --git a/src/guardian/keys-validation/keys.fixtures.ts b/src/guardian/keys-validation/keys.fixtures.ts index eafda3bf..e45c348a 100644 --- a/src/guardian/keys-validation/keys.fixtures.ts +++ b/src/guardian/keys-validation/keys.fixtures.ts @@ -1,4 +1,7 @@ -export const validKeys = [ +import { RegistryKey } from 'keys-api/interfaces/RegistryKey'; + +// goerli keys +export const validKeys: RegistryKey[] = [ { key: '0xa9bfaa8207ee6c78644c079ffc91b6e5abcc5eede1b7a06abb8fb40e490a75ea269c178dd524b65185299d2bbd2eb7b2', depositSignature: @@ -7,6 +10,7 @@ export const validKeys = [ used: false, moduleAddress: '0x9D4AF1Ee19Dad8857db3a45B0374c81c8A1C6320', index: 51, + vetted: true, }, { key: '0xb3c90525010a5710d43acbea46047fc37ed55306d032527fa15dd7e8cd8a9a5fa490347cc5fce59936fb8300683cd9f3', @@ -16,9 +20,10 @@ export const validKeys = [ used: false, moduleAddress: '0x9D4AF1Ee19Dad8857db3a45B0374c81c8A1C6320', index: 52, + vetted: true, }, ]; -export const invalidKey = { +export const invalidKey1: RegistryKey = { key: '0x84e85db03bee714dbecf01914460d9576b7f7226030bdbeae9ee923bf5f8e01eec4f7dfe54aa7eca6f4bccce59a0bf42', depositSignature: '0xb45b15f6e043d91eabbda838eae32f7dcb998578919bd813d8add67de9b14bc268a4fde41d08058a9dc2c40b881f47970c30fd3beee46517e4e5eebd4aba52060425e021302c987d365347d478681b2cabfd31208d0607f71f3766a53ca1ada0', @@ -26,9 +31,10 @@ export const invalidKey = { used: false, moduleAddress: '0x11a93807078f8BB880c1BD0ee4C387537de4b4b6', index: 5, + vetted: true, }; -export const invalidKey2 = { +export const invalidKey2: RegistryKey = { key: '0x9100e67cfb22cb7f1c3924e91bc8f70111f0634fa87d3361f807585e7ab06f84a0f504b7390683ce01567e5de3ad7445', depositSignature: '0x8d4ed47875fab45e9cfec65bf67c956be0b00d4d4cde2b6b898b09d07eed10457b4e2a8f496077e4a145e523d5b18749035b87c2412360d4fbbc850051b307f704a758f4ef35ca4af6c5f8f4e4a95603dc688bb3773b5a22c6c21b5440c71e13', @@ -36,6 +42,7 @@ export const invalidKey2 = { used: false, moduleAddress: '0x9D4AF1Ee19Dad8857db3a45B0374c81c8A1C6320', index: 54, + vetted: true, }; export const invalidKey2GoodSign = diff --git a/src/guardian/staking-module-guard/keys.fixtures.ts b/src/guardian/staking-module-guard/keys.fixtures.ts index e69cd5bc..0d38d3b2 100644 --- a/src/guardian/staking-module-guard/keys.fixtures.ts +++ b/src/guardian/staking-module-guard/keys.fixtures.ts @@ -1,254 +1,14 @@ -export const vettedKeysDuplicatesAcrossModules: any = [ - { - stakingModuleId: 100, - vettedUnusedKeys: [ - { - key: '0x9948d2becf42e9f76922bc6f664545e6f50401050af95785a984802d32a95c4c61f8e3de312b78167f86e047f83a7796', - depositSignature: - '0x8bf4401a354de243a3716ee2efc0bde1ded56a40e2943ac7c50290bec37e935d6170b21e7c0872f203199386143ef12612a1488a8e9f1cdf1229c382f29c326bcbf6ed6a87d8fbfe0df87dacec6632fc4709d9d338f4cf81e861d942c23bba1e', - operatorIndex: 0, - used: false, - moduleAddress: '0x595F64Ddc3856a3b5Ff4f4CC1d1fb4B46cFd2bAC', - index: 100, - }, - { - key: '0x911dd3091cfb1b42c960e4f343ea98d9ee6a1dc8ef215afa976fb557bd627a901717c0008bc33a0bfea15f0dfe9c5d01', - depositSignature: - '0x898ac7072aa26d983f9ece384c4037966dde614b75dddf982f6a415f3107cb2569b96f6d1c44e608a250ac4bbe908df51473f0de2cf732d283b07d88f3786893124967b8697a8b93d31976e7ac49ab1e568f98db0bbb13384477e8357b6d7e9b', - operatorIndex: 0, - used: false, - moduleAddress: '0x595F64Ddc3856a3b5Ff4f4CC1d1fb4B46cFd2bAC', - index: 101, - }, - ], - }, - { - stakingModuleId: 102, - vettedUnusedKeys: [ - { - key: '0x911dd3091cfb1b42c960e4f343ea98d9ee6a1dc8ef215afa976fb557bd627a901717c0008bc33a0bfea15f0dfe9c5d01', - depositSignature: - '0xa13833d96f4b98291dbf428cb69e7a3bdce61c9d20efcdb276423c7d6199ebd10cf1728dbd418c592701a41983cb02330e736610be254f617140af48a9d20b31cdffdd1d4fc8c0776439fca3330337d33042768acf897000b9e5da386077be44', - operatorIndex: 28, - used: false, - moduleAddress: '0x11a93807078f8BB880c1BD0ee4C387537de4b4b6', - index: 4, - }, - { - key: '0x84e85db03bee714dbecf01914460d9576b7f7226030bdbeae9ee923bf5f8e01eec4f7dfe54aa7eca6f4bccce59a0bf42', - depositSignature: - '0xb024b67a2f6c579213529e143bd4ebb81c5a2dc385cb526de4a816c8fe0317ebfb38369b08622e9f27e62cce2811679a13a459d4e9a8d7bd00080c36b359c1ca03bdcf4a0fcbbc2e18fe9923d8c4edb503ade58bdefe690760611e3738d5e64f', - operatorIndex: 28, - used: false, - moduleAddress: '0x11a93807078f8BB880c1BD0ee4C387537de4b4b6', - index: 5, - }, - ], - }, - { - stakingModuleId: 103, - vettedUnusedKeys: [ - { - key: '0x84ff489c1e07c75ac635914d4fa20bb37b30f7cf37a8fb85298a88e6f45daab122b43a352abce2132bdde96fd4a01599', - depositSignature: - '0xb024b67a2f6c579213529e143bd4ebb81c5a2dc385cb526de4a816c8fe0317ebfb38369b08622e9f27e62cce2811679a13a459d4e9a8d7bd00080c36b359c1ca03bdcf4a0fcbbc2e18fe9923d8c4edb503ade58bdefe690760611e3738d5e64f', - operatorIndex: 28, - used: false, - moduleAddress: 'another_module', - index: 5, - }, - ], - }, -]; - -export const vettedKeysDuplicatesAcrossOneModule: any = [ - { - stakingModuleId: 100, - vettedUnusedKeys: [ - { - key: '0x9948d2becf42e9f76922bc6f664545e6f50401050af95785a984802d32a95c4c61f8e3de312b78167f86e047f83a7796', - depositSignature: - '0x8bf4401a354de243a3716ee2efc0bde1ded56a40e2943ac7c50290bec37e935d6170b21e7c0872f203199386143ef12612a1488a8e9f1cdf1229c382f29c326bcbf6ed6a87d8fbfe0df87dacec6632fc4709d9d338f4cf81e861d942c23bba1e', - operatorIndex: 0, - used: false, - moduleAddress: '0x595F64Ddc3856a3b5Ff4f4CC1d1fb4B46cFd2bAC', - index: 100, - }, - { - key: '0x911dd3091cfb1b42c960e4f343ea98d9ee6a1dc8ef215afa976fb557bd627a901717c0008bc33a0bfea15f0dfe9c5d01', - depositSignature: - '0x898ac7072aa26d983f9ece384c4037966dde614b75dddf982f6a415f3107cb2569b96f6d1c44e608a250ac4bbe908df51473f0de2cf732d283b07d88f3786893124967b8697a8b93d31976e7ac49ab1e568f98db0bbb13384477e8357b6d7e9b', - operatorIndex: 0, - used: false, - moduleAddress: '0x595F64Ddc3856a3b5Ff4f4CC1d1fb4B46cFd2bAC', - index: 101, - }, - { - key: '0x9948d2becf42e9f76922bc6f664545e6f50401050af95785a984802d32a95c4c61f8e3de312b78167f86e047f83a7796', - depositSignature: - '0x8bf4401a354de243a3716ee2efc0bde1ded56a40e2943ac7c50290bec37e935d6170b21e7c0872f203199386143ef12612a1488a8e9f1cdf1229c382f29c326bcbf6ed6a87d8fbfe0df87dacec6632fc4709d9d338f4cf81e861d942c23bba1e', - operatorIndex: 0, - used: false, - moduleAddress: '0x595F64Ddc3856a3b5Ff4f4CC1d1fb4B46cFd2bAC', - index: 102, - }, - ], - }, - { - stakingModuleId: 102, - vettedUnusedKeys: [ - { - key: '0x84e85db03bee714dbecf01914460d9576b7f7226030bdbeae9ee923bf5f8e01eec4f7dfe54aa7eca6f4bccce59a0bf42', - depositSignature: - '0xb024b67a2f6c579213529e143bd4ebb81c5a2dc385cb526de4a816c8fe0317ebfb38369b08622e9f27e62cce2811679a13a459d4e9a8d7bd00080c36b359c1ca03bdcf4a0fcbbc2e18fe9923d8c4edb503ade58bdefe690760611e3738d5e64f', - operatorIndex: 28, - used: false, - moduleAddress: '0x11a93807078f8BB880c1BD0ee4C387537de4b4b6', - index: 5, - }, - ], - }, - - { - stakingModuleId: 103, - vettedUnusedKeys: [ - { - key: '0x84ff489c1e07c75ac635914d4fa20bb37b30f7cf37a8fb85298a88e6f45daab122b43a352abce2132bdde96fd4a01599', - depositSignature: - '0xb024b67a2f6c579213529e143bd4ebb81c5a2dc385cb526de4a816c8fe0317ebfb38369b08622e9f27e62cce2811679a13a459d4e9a8d7bd00080c36b359c1ca03bdcf4a0fcbbc2e18fe9923d8c4edb503ade58bdefe690760611e3738d5e64f', - operatorIndex: 28, - used: false, - moduleAddress: 'another_module', - index: 5, - }, - ], - }, -]; - -export const vettedKeysDuplicatesAcrossOneModuleAndFew: any = [ - { - stakingModuleId: 100, - vettedUnusedKeys: [ - { - key: '0x9948d2becf42e9f76922bc6f664545e6f50401050af95785a984802d32a95c4c61f8e3de312b78167f86e047f83a7796', - depositSignature: - '0x8bf4401a354de243a3716ee2efc0bde1ded56a40e2943ac7c50290bec37e935d6170b21e7c0872f203199386143ef12612a1488a8e9f1cdf1229c382f29c326bcbf6ed6a87d8fbfe0df87dacec6632fc4709d9d338f4cf81e861d942c23bba1e', - operatorIndex: 0, - used: false, - moduleAddress: '0x595F64Ddc3856a3b5Ff4f4CC1d1fb4B46cFd2bAC', - index: 100, - }, - { - key: '0x911dd3091cfb1b42c960e4f343ea98d9ee6a1dc8ef215afa976fb557bd627a901717c0008bc33a0bfea15f0dfe9c5d01', - depositSignature: - '0x898ac7072aa26d983f9ece384c4037966dde614b75dddf982f6a415f3107cb2569b96f6d1c44e608a250ac4bbe908df51473f0de2cf732d283b07d88f3786893124967b8697a8b93d31976e7ac49ab1e568f98db0bbb13384477e8357b6d7e9b', - operatorIndex: 0, - used: false, - moduleAddress: '0x595F64Ddc3856a3b5Ff4f4CC1d1fb4B46cFd2bAC', - index: 101, - }, - ], - }, - { - stakingModuleId: 102, - vettedUnusedKeys: [ - { - key: '0x911dd3091cfb1b42c960e4f343ea98d9ee6a1dc8ef215afa976fb557bd627a901717c0008bc33a0bfea15f0dfe9c5d01', - depositSignature: - '0xa13833d96f4b98291dbf428cb69e7a3bdce61c9d20efcdb276423c7d6199ebd10cf1728dbd418c592701a41983cb02330e736610be254f617140af48a9d20b31cdffdd1d4fc8c0776439fca3330337d33042768acf897000b9e5da386077be44', - operatorIndex: 28, - used: false, - moduleAddress: '0x11a93807078f8BB880c1BD0ee4C387537de4b4b6', - index: 4, - }, - { - key: '0x84e85db03bee714dbecf01914460d9576b7f7226030bdbeae9ee923bf5f8e01eec4f7dfe54aa7eca6f4bccce59a0bf42', - depositSignature: - '0xb024b67a2f6c579213529e143bd4ebb81c5a2dc385cb526de4a816c8fe0317ebfb38369b08622e9f27e62cce2811679a13a459d4e9a8d7bd00080c36b359c1ca03bdcf4a0fcbbc2e18fe9923d8c4edb503ade58bdefe690760611e3738d5e64f', - operatorIndex: 28, - used: false, - moduleAddress: '0x11a93807078f8BB880c1BD0ee4C387537de4b4b6', - index: 5, - }, - { - key: '0x911dd3091cfb1b42c960e4f343ea98d9ee6a1dc8ef215afa976fb557bd627a901717c0008bc33a0bfea15f0dfe9c5d01', - depositSignature: - '0xa13833d96f4b98291dbf428cb69e7a3bdce61c9d20efcdb276423c7d6199ebd10cf1728dbd418c592701a41983cb02330e736610be254f617140af48a9d20b31cdffdd1d4fc8c0776439fca3330337d33042768acf897000b9e5da386077be44', - operatorIndex: 28, - used: false, - moduleAddress: '0x11a93807078f8BB880c1BD0ee4C387537de4b4b6', - index: 6, - }, - ], - }, - { - stakingModuleId: 103, - vettedUnusedKeys: [ - { - key: '0x84ff489c1e07c75ac635914d4fa20bb37b30f7cf37a8fb85298a88e6f45daab122b43a352abce2132bdde96fd4a01599', - depositSignature: - '0xb024b67a2f6c579213529e143bd4ebb81c5a2dc385cb526de4a816c8fe0317ebfb38369b08622e9f27e62cce2811679a13a459d4e9a8d7bd00080c36b359c1ca03bdcf4a0fcbbc2e18fe9923d8c4edb503ade58bdefe690760611e3738d5e64f', - operatorIndex: 28, - used: false, - moduleAddress: 'another_module', - index: 5, - }, - ], - }, -]; - -export const vettedKeysWithoutDuplicates: any = [ - { - stakingModuleId: 100, - vettedUnusedKeys: [ - { - key: '0x9948d2becf42e9f76922bc6f664545e6f50401050af95785a984802d32a95c4c61f8e3de312b78167f86e047f83a7796', - depositSignature: - '0x8bf4401a354de243a3716ee2efc0bde1ded56a40e2943ac7c50290bec37e935d6170b21e7c0872f203199386143ef12612a1488a8e9f1cdf1229c382f29c326bcbf6ed6a87d8fbfe0df87dacec6632fc4709d9d338f4cf81e861d942c23bba1e', - operatorIndex: 0, - used: false, - moduleAddress: '0x595F64Ddc3856a3b5Ff4f4CC1d1fb4B46cFd2bAC', - index: 100, - }, - { - key: '0x911dd3091cfb1b42c960e4f343ea98d9ee6a1dc8ef215afa976fb557bd627a901717c0008bc33a0bfea15f0dfe9c5d01', - depositSignature: - '0x898ac7072aa26d983f9ece384c4037966dde614b75dddf982f6a415f3107cb2569b96f6d1c44e608a250ac4bbe908df51473f0de2cf732d283b07d88f3786893124967b8697a8b93d31976e7ac49ab1e568f98db0bbb13384477e8357b6d7e9b', - operatorIndex: 0, - used: false, - moduleAddress: '0x595F64Ddc3856a3b5Ff4f4CC1d1fb4B46cFd2bAC', - index: 101, - }, - ], - }, - - { - stakingModuleId: 102, - vettedUnusedKeys: [ - { - key: '0x84e85db03bee714dbecf01914460d9576b7f7226030bdbeae9ee923bf5f8e01eec4f7dfe54aa7eca6f4bccce59a0bf42', - depositSignature: - '0xb024b67a2f6c579213529e143bd4ebb81c5a2dc385cb526de4a816c8fe0317ebfb38369b08622e9f27e62cce2811679a13a459d4e9a8d7bd00080c36b359c1ca03bdcf4a0fcbbc2e18fe9923d8c4edb503ade58bdefe690760611e3738d5e64f', - operatorIndex: 28, - used: false, - moduleAddress: '0x11a93807078f8BB880c1BD0ee4C387537de4b4b6', - index: 5, - }, - ], - }, - - { - stakingModuleId: 103, - vettedUnusedKeys: [ - { - key: '0x84ff489c1e07c75ac635914d4fa20bb37b30f7cf37a8fb85298a88e6f45daab122b43a352abce2132bdde96fd4a01599', - depositSignature: - '0xb024b67a2f6c579213529e143bd4ebb81c5a2dc385cb526de4a816c8fe0317ebfb38369b08622e9f27e62cce2811679a13a459d4e9a8d7bd00080c36b359c1ca03bdcf4a0fcbbc2e18fe9923d8c4edb503ade58bdefe690760611e3738d5e64f', - operatorIndex: 28, - used: false, - moduleAddress: 'another_module', - index: 5, - }, - ], +import { RegistryKey } from 'keys-api/interfaces/RegistryKey'; + +export const vettedKeys: RegistryKey[] = [ + { + key: '0x911dd3091cfb1b42c960e4f343ea98d9ee6a1dc8ef215afa976fb557bd627a901717c0008bc33a0bfea15f0dfe9c5d01', + depositSignature: + '0x898ac7072aa26d983f9ece384c4037966dde614b75dddf982f6a415f3107cb2569b96f6d1c44e608a250ac4bbe908df51473f0de2cf732d283b07d88f3786893124967b8697a8b93d31976e7ac49ab1e568f98db0bbb13384477e8357b6d7e9b', + operatorIndex: 0, + used: false, + moduleAddress: '0x595F64Ddc3856a3b5Ff4f4CC1d1fb4B46cFd2bAC', + index: 101, + vetted: true, }, ]; diff --git a/src/guardian/staking-module-guard/staking-module-guard.module.ts b/src/guardian/staking-module-guard/staking-module-guard.module.ts index 8ad5478f..f4bf51b7 100644 --- a/src/guardian/staking-module-guard/staking-module-guard.module.ts +++ b/src/guardian/staking-module-guard/staking-module-guard.module.ts @@ -1,21 +1,23 @@ import { Module } from '@nestjs/common'; import { SecurityModule } from 'contracts/security'; -import { StakingRouterModule } from 'staking-router'; import { GuardianMetricsModule } from '../guardian-metrics'; import { GuardianMessageModule } from '../guardian-message'; import { StakingModuleGuardService } from './staking-module-guard.service'; import { KeysValidationModule } from 'guardian/keys-validation/keys-validation.module'; +import { UnvettingModule } from 'guardian/unvetting/unvetting.module'; +import { KeysApiModule } from 'keys-api/keys-api.module'; @Module({ imports: [ SecurityModule, - StakingRouterModule, GuardianMetricsModule, GuardianMessageModule, KeysValidationModule, + UnvettingModule, + KeysApiModule, ], providers: [StakingModuleGuardService], exports: [StakingModuleGuardService], diff --git a/src/guardian/staking-module-guard/staking-module-guard.service.ts b/src/guardian/staking-module-guard/staking-module-guard.service.ts index d77dcc73..da74f1b5 100644 --- a/src/guardian/staking-module-guard/staking-module-guard.service.ts +++ b/src/guardian/staking-module-guard/staking-module-guard.service.ts @@ -1,7 +1,10 @@ import { Inject, Injectable, LoggerService } from '@nestjs/common'; import { WINSTON_MODULE_NEST_PROVIDER } from 'nest-winston'; -import { VerifiedDepositEvent } from 'contracts/deposit'; +import { + VerifiedDepositEvent, + VerifiedDepositEventGroup, +} from 'contracts/deposits-registry'; import { SecurityService } from 'contracts/security'; import { ContractsState, BlockData, StakingModuleData } from '../interfaces'; @@ -9,9 +12,10 @@ import { GUARDIAN_DEPOSIT_RESIGNING_BLOCKS } from '../guardian.constants'; import { GuardianMetricsService } from '../guardian-metrics'; import { GuardianMessageService } from '../guardian-message'; -import { StakingRouterService } from 'staking-router'; import { KeysValidationService } from 'guardian/keys-validation/keys-validation.service'; import { performance } from 'perf_hooks'; +import { RegistryKey } from 'keys-api/interfaces/RegistryKey'; +import { KeysApiService } from 'keys-api/keys-api.service'; @Injectable() export class StakingModuleGuardService { @@ -20,7 +24,7 @@ export class StakingModuleGuardService { private logger: LoggerService, private securityService: SecurityService, - private stakingRouterService: StakingRouterService, + private keysApiService: KeysApiService, private guardianMetricsService: GuardianMetricsService, private guardianMessageService: GuardianMessageService, private keysValidationService: KeysValidationService, @@ -30,88 +34,14 @@ export class StakingModuleGuardService { {}; /** - * @returns List of staking modules id with duplicates + * Determines if the first event occurred earlier than the second event. + * Compares block numbers first; if they are equal, compares log indexes. + * + * @param firstEvent - The first event to compare. + * @param secondEvent - The second event to compare. + * @returns True if the first event is earlier, false otherwise. */ - public getModulesIdsWithDuplicatedVettedUnusedKeys( - stakingModulesData: StakingModuleData[], - blockData: BlockData, - ): number[] { - // Collects the duplicate count for each unique key across staking modules - // This map collects, for every key, a set of module IDs where the key was found. - const keyModuleOccurrences = new Map>(); - // This map collects, for every module, the number of duplicated keys found in it. - const moduleDuplicatedKeysCount = new Map(); - - stakingModulesData.forEach(({ vettedUnusedKeys, stakingModuleId }) => { - // This set tracks keys that are duplicated within the same module. - const duplicatedKeysInsideOneModule = new Set(); - vettedUnusedKeys.forEach(({ key }) => { - // is a key new for module - const isNewKeyForModule = !duplicatedKeysInsideOneModule.has(key); - // Mark this key as encountered for this module - duplicatedKeysInsideOneModule.add(key); - - // modules where the key was found - const moduleIds = keyModuleOccurrences.get(key); - - if (!moduleIds) { - // add new key - keyModuleOccurrences.set(key, new Set([stakingModuleId])); - } else { - moduleIds.add(stakingModuleId); - - if (moduleIds.size > 1 || !isNewKeyForModule) { - moduleIds.forEach((id) => { - moduleDuplicatedKeysCount.set( - id, - (moduleDuplicatedKeysCount.get(id) || 0) + 1, - ); - }); - } - } - }); - }); - - // set metrics - stakingModulesData.forEach(({ stakingModuleId }) => { - const countOfDuplicatedKeys = - moduleDuplicatedKeysCount.get(stakingModuleId) || 0; - this.guardianMetricsService.collectDuplicatedVettedUnusedKeysMetrics( - stakingModuleId, - countOfDuplicatedKeys, - ); - }); - - if (moduleDuplicatedKeysCount.size) { - const moduleDuplicatedKeysCountList = Array.from( - moduleDuplicatedKeysCount, - ).map(([stakingModuleId, duplicatedKeys]) => ({ - stakingModuleId, - duplicatedKeys, - })); - this.logger.error('Found duplicated vetted keys'); - this.logger.log('Duplicated keys', { - blockHash: blockData.blockHash, - modulesWithDuplicates: moduleDuplicatedKeysCountList, - }); - - return Array.from(moduleDuplicatedKeysCount.keys()); - } - - return []; - } - - public excludeModulesWithDuplicatedKeys( - stakingModulesData: StakingModuleData[], - modulesIdWithDuplicateKeys: number[], - ): StakingModuleData[] { - return stakingModulesData.filter( - ({ stakingModuleId }) => - !modulesIdWithDuplicateKeys.includes(stakingModuleId), - ); - } - - isFirstEventEarlier( + private isFirstEventEarlier( firstEvent: VerifiedDepositEvent, secondEvent: VerifiedDepositEvent, ) { @@ -127,68 +57,136 @@ export class StakingModuleGuardService { return isFirstEventEarlier; } + /** - * Method is not taking into account WC rotation since historical deposits were checked manually - * @param blockData - * @returns + * Filters and retrieves deposit events that have Lido's withdrawal credentials + * and are marked as valid. + * + * @param depositedEvents - A group of deposit events. + * @param lidoWC - The withdrawal credential associated with Lido. + * @returns An array of deposit events that match the Lido withdrawal credential and are valid. */ - async getHistoricalFrontRun(blockData: BlockData) { - const { depositedEvents, lidoWC } = blockData; - const potentialLidoDepositsEvents = depositedEvents.events.filter( + private getDepositsWithLidoWC( + depositedEvents: VerifiedDepositEventGroup, + lidoWC: string, + ): VerifiedDepositEvent[] { + // Filter events for those with Lido withdrawal credentials and valid status + const depositsMatchingLidoWC = depositedEvents.events.filter( ({ wc, valid }) => wc === lidoWC && valid, ); - this.logger.log('potential lido deposits events count', { - count: potentialLidoDepositsEvents.length, + this.logger.log('Deposits matching Lido WC count', { + count: depositsMatchingLidoWC.length, }); - const potentialLidoDepositsKeysMap: Record = + return depositsMatchingLidoWC; + } + + /** + * Creates a map of the earliest deposit events for each public key. + * If multiple deposits are found for the same public key, only the earliest one is stored. + * + * @param depositsMatchingLidoWC - Array of deposit events that match the Lido withdrawal credential + * @returns A record map with public keys as keys and the earliest deposit events as values. + */ + private getEarliestDepositsMap( + depositsMatchingLidoWC: VerifiedDepositEvent[], + ): Record { + const earliestLidoWCDepositsByPubkey: Record = {}; - potentialLidoDepositsEvents.forEach((event) => { - if (potentialLidoDepositsKeysMap[event.pubkey]) { - const existed = potentialLidoDepositsKeysMap[event.pubkey]; - const isExisted = this.isFirstEventEarlier(existed, event); - // this should not happen, since Lido deposits once per key. - // but someone can still make such a deposit. - if (isExisted) return; + depositsMatchingLidoWC.forEach((event) => { + const existingDeposit = earliestLidoWCDepositsByPubkey[event.pubkey]; + + if (existingDeposit) { + const isExistingEarlier = this.isFirstEventEarlier( + existingDeposit, + event, + ); + // This should not happen, since only one deposit per key is expected. + // However, someone could still make such a deposit. + if (isExistingEarlier) return; } - potentialLidoDepositsKeysMap[event.pubkey] = event; + earliestLidoWCDepositsByPubkey[event.pubkey] = event; }); - const duplicatedDepositEvents: VerifiedDepositEvent[] = []; + return earliestLidoWCDepositsByPubkey; + } + + /** + * Identifies duplicated deposit events that have non-Lido withdrawal credentials. + * These are deposits made on the same public key but with different withdrawal credentials. + * + * @param depositEventGroup - A group of deposit events. + * @param lidoWithdrawalCredential - The withdrawal credential associated with Lido. + * @param earliestDepositsByPubkey - A map of the earliest deposit events with Lido wc by public key. + * @returns An array of duplicated deposit events with non-Lido withdrawal credentials. + */ + private getNonLidoDuplicatedDeposits( + depositedEventsGroup: VerifiedDepositEventGroup, + lidoWC: string, + earliestLidoWCDepositsByPubkey: Record, + ): VerifiedDepositEvent[] { + const nonLidoDuplicatedDeposits: VerifiedDepositEvent[] = []; + + const { events: depositedEvents } = depositedEventsGroup; - depositedEvents.events.forEach((event) => { - if (potentialLidoDepositsKeysMap[event.pubkey] && event.wc !== lidoWC) { - duplicatedDepositEvents.push(event); + depositedEvents.forEach((event) => { + if (earliestLidoWCDepositsByPubkey[event.pubkey] && event.wc !== lidoWC) { + nonLidoDuplicatedDeposits.push(event); } }); - this.logger.log('duplicated deposit events', { - count: duplicatedDepositEvents.length, + this.logger.log('Non-Lido duplicated deposit events count', { + count: nonLidoDuplicatedDeposits.length, }); - const validDuplicatedDepositEvents = duplicatedDepositEvents.filter( + return nonLidoDuplicatedDeposits; + } + + /** + * Filters and returns valid duplicated deposit events from a given list. + * @param nonLidoDuplicatedDeposits - An array of duplicated deposit events with non-Lido withdrawal credentials + * @returns An array of valid duplicated deposit events. + */ + private getValidNonLidoDuplicatedDeposits( + nonLidoDuplicatedDeposits: VerifiedDepositEvent[], + ): VerifiedDepositEvent[] { + const validNonLidoDuplicatedDeposits = nonLidoDuplicatedDeposits.filter( (event) => event.valid, ); - this.logger.log('valid duplicated deposit events', { - count: validDuplicatedDepositEvents.length, + this.logger.log('Valid non-lido duplicated deposit events count', { + count: validNonLidoDuplicatedDeposits.length, }); - const frontRunnedDepositEvents = validDuplicatedDepositEvents.filter( + return validNonLidoDuplicatedDeposits; + } + + /** + * Identifies and returns the public keys associated with deposit events that front-ran the deposits with lido withdrawal credentials. + * @param validNonLidoDuplicatedDeposits - An array of duplicated deposit events with non-Lido withdrawal credentials. + * @param earliestLidoWCDepositsByPubkey - A map of the earliest deposit events with Lido withdrawal credentials by public key. + * @returns An array of public keys for events that front-ran deposits with lido withdrawal credentials. + */ + private getFrontRun( + validNonLidoDuplicatedDeposits: VerifiedDepositEvent[], + earliestLidoWCDepositsByPubkey: Record, + ): string[] { + const frontRunnedDepositEvents = validNonLidoDuplicatedDeposits.filter( (suspectedEvent) => { // get event from lido map const sameKeyLidoDeposit = - potentialLidoDepositsKeysMap[suspectedEvent.pubkey]; + earliestLidoWCDepositsByPubkey[suspectedEvent.pubkey]; + // TODO: do we need to leave here this check if (!sameKeyLidoDeposit) throw new Error('expected event not found'); return this.isFirstEventEarlier(suspectedEvent, sameKeyLidoDeposit); }, ); - this.logger.log('front runned deposit events', { + this.logger.log('Front-ran deposit events', { events: frontRunnedDepositEvents, }); @@ -196,118 +194,121 @@ export class StakingModuleGuardService { ({ pubkey }) => pubkey, ); - if (!frontRunnedDepositKeys.length) { + return frontRunnedDepositKeys; + } + + /** + * Retrieves the keys associated with front-runned deposits that were previously deposited by Lido. + * + * @param frontRunnedDepositKeys - An array of public keys for events that front-ran deposits with lido withdrawal credentials. + * @returns An array of registry keys that were previously deposited by Lido. + */ + private async getKeysDepositedByLido( + frontRunnedDepositKeys: string[], + ): Promise { + const { data: lidoDepositedKeys } = + await this.keysApiService.getKeysByPubkeys(frontRunnedDepositKeys); + + return lidoDepositedKeys.filter((key) => key.used); + } + + /** + * Checks if Lido deposits have been front-ran in the past based on historical deposit data. + * This method does not account for WC rotation as historical deposits were manually checked. + * + * @param depositedEvents - A group of historical deposit events. + * @param lidoWC - The withdrawal credential associated with Lido. + * @returns True if front-running was detected at any point in the past; false if no front-running occurred. + */ + public async getHistoricalFrontRun( + depositedEvents: VerifiedDepositEventGroup, + lidoWC: string, + ) { + const lidoWCDeposits = this.getDepositsWithLidoWC(depositedEvents, lidoWC); + + const earliestDepositsMap = this.getEarliestDepositsMap(lidoWCDeposits); + + const nonLidoDuplicatedDeposits = this.getNonLidoDuplicatedDeposits( + depositedEvents, + lidoWC, + earliestDepositsMap, + ); + + const validNonLidoDeposits = this.getValidNonLidoDuplicatedDeposits( + nonLidoDuplicatedDeposits, + ); + + const frontRunnedDepositKeys = this.getFrontRun( + validNonLidoDeposits, + earliestDepositsMap, + ); + + if (frontRunnedDepositKeys.length === 0) { return false; } - const lidoDepositedKeys = await this.stakingRouterService.getKeysByPubkeys( + // front run happened only if these keys exist in lido contracts + const frontRunnedLidoDeposits = await this.getKeysDepositedByLido( frontRunnedDepositKeys, ); - const isLidoDepositedKeys = lidoDepositedKeys.data.length; + const hasFrontRunning = frontRunnedLidoDeposits.length > 0; + + if (hasFrontRunning) { + this.logger.warn('Found historical front-run', { + frontRunnedLidoDeposits, + }); + } + + return hasFrontRunning; + } + + public async alreadyPausedDeposits(blockData: BlockData, version: number) { + if (version === 3) { + const alreadyPaused = await this.securityService.isDepositsPaused({ + blockHash: blockData.blockHash, + }); - if (isLidoDepositedKeys) { - this.logger.warn('historical front-run found'); + return alreadyPaused; } - return isLidoDepositedKeys; + // for earlier versions DSM contact didn't have this method + // we check pause for every method via staking router contract + return false; } + /** - * Checks keys for intersections with previously deposited keys and handles the situation - * @param blockData - collected data from the current block + * Return intersections with previously deposited keys */ - // TODO: rename, because this method more than intersections checks - public async checkKeysIntersections( + public getFrontRunAttempts( stakingModuleData: StakingModuleData, blockData: BlockData, - noDuplicates: boolean, - ): Promise { - const { blockHash } = blockData; - const { stakingModuleId } = stakingModuleData; - + ): RegistryKey[] { const keysIntersections = this.getKeysIntersections( stakingModuleData, blockData, ); - // exclude invalid deposits as they ignored by cl - const validIntersections = this.excludeInvalidDeposits(keysIntersections); - - const filteredIntersections = await this.excludeEligibleIntersections( + // if we have one ineligible and eligible events for the same key we should check which one was first + // or we will report key for unvetting without reason + // at the same time such vetted unused key will be reported as duplicated too + const frontRunAttempts = this.excludeEligibleIntersections( blockData, - validIntersections, + keysIntersections, ); - const isFilteredIntersectionsFound = filteredIntersections.length > 0; - this.guardianMetricsService.collectIntersectionsMetrics( stakingModuleData.stakingModuleId, keysIntersections, - filteredIntersections, + frontRunAttempts, ); - // TODO: add metrics for getHistoricalFrontRun same as for keysIntersections - const historicalFrontRunFound = await this.getHistoricalFrontRun(blockData); - const isDepositsPaused = await this.securityService.isDepositsPaused( - stakingModuleData.stakingModuleId, - { - blockHash: stakingModuleData.blockHash, - }, - ); + const keys = new Set(frontRunAttempts.map((deposit) => deposit.pubkey)); - if (isDepositsPaused) { - this.logger.warn('Deposits are paused', { blockHash, stakingModuleId }); - return; - } - - if (isFilteredIntersectionsFound || historicalFrontRunFound) { - await this.handleKeysIntersections(stakingModuleData, blockData); - } else { - if (!noDuplicates) { - this.logger.warn('Found duplicated keys', { - blockHash, - stakingModuleId, - }); - return; - } - - // it could throw error if kapi returned old data - const usedKeys = await this.findAlreadyDepositedKeys( - stakingModuleData.lastChangedBlockHash, - validIntersections, - ); - - this.guardianMetricsService.collectDuplicatedUsedKeysMetrics( - stakingModuleData.stakingModuleId, - usedKeys.length, - ); - - // if found used keys, Lido already made deposit on this keys - if (usedKeys.length) { - this.logger.log('Found that we already deposited on these keys', { - blockHash, - stakingModuleId, - }); - return; - } - - const isValidKeys = await this.isVettedUnusedKeysValid( - stakingModuleData, - blockData, - ); - - if (!isValidKeys) { - this.logger.error('Staking module contains invalid keys'); - this.logger.log('State', { - blockHash: stakingModuleData.blockHash, - lastChangedBlockHash: stakingModuleData.lastChangedBlockHash, - stakingModuleId: stakingModuleData.stakingModuleId, - }); - return; - } - - await this.handleCorrectKeys(stakingModuleData, blockData); - } + // list can have duplicated keys + return stakingModuleData.vettedUnusedKeys.filter((key) => + keys.has(key.key), + ); } /** @@ -321,11 +322,15 @@ export class StakingModuleGuardService { blockData: BlockData, ): VerifiedDepositEvent[] { const { blockHash, depositRoot, depositedEvents } = blockData; - const { nonce, unusedKeys, stakingModuleId } = stakingModuleData; - - const unusedKeysSet = new Set(unusedKeys); + const { + nonce, + vettedUnusedKeys: keys, + stakingModuleId, + } = stakingModuleData; + const vettedUnusedKeys = keys.map((key) => key.key); + const vettedUnusedKeysSet = new Set(vettedUnusedKeys); const intersections = depositedEvents.events.filter(({ pubkey }) => - unusedKeysSet.has(pubkey), + vettedUnusedKeysSet.has(pubkey), ); if (intersections.length) { @@ -341,68 +346,98 @@ export class StakingModuleGuardService { return intersections; } - public excludeInvalidDeposits(intersections: VerifiedDepositEvent[]) { - // Exclude deposits with invalid signature over the deposit data - return intersections.filter(({ valid }) => valid); - } - /** * Excludes invalid deposits and deposits with Lido WC from intersections - * @param intersections - list of deposits with keys that were deposited earlier * @param blockData - collected data from the current block + * @param intersections - list of deposits with keys that were deposited earlier */ - public async excludeEligibleIntersections( + public excludeEligibleIntersections( blockData: BlockData, - validIntersections: VerifiedDepositEvent[], - ): Promise { - // Exclude deposits with Lido withdrawal credentials - return validIntersections.filter( - (deposit) => deposit.wc !== blockData.lidoWC, + intersections: VerifiedDepositEvent[], + ): VerifiedDepositEvent[] { + return intersections.filter( + ({ wc, valid }) => wc !== blockData.lidoWC && valid, ); } - /** - * If we find an intersection between the unused keys and the deposited keys in the Ethereum deposit contract - * with Lido withdrawal credentials, we need to determine whether this deposit was made by Lido. - * If it was indeed made by Lido, we set a metric and skip sending deposit messages in the queue for this iteration. - */ - public async findAlreadyDepositedKeys( - lastChangedBlockHash: string, - intersectionsWithLidoWC: VerifiedDepositEvent[], - ) { - const depositedPubkeys = intersectionsWithLidoWC.map( - (deposit) => deposit.pubkey, - ); - // if depositedPubkeys == [], /find will return validation error - if (!depositedPubkeys.length) { - return []; - } + public async handlePauseV3(blockData: BlockData): Promise { + const { blockNumber, blockHash, guardianAddress, guardianIndex } = + blockData; - // TODO: add staking module id - this.logger.log( - 'Found intersections with lido credentials, need to check used duplicated keys', + const signature = await this.securityService.signPauseDataV3( + blockNumber, + blockHash, ); - const { data, meta } = await this.stakingRouterService.getKeysByPubkeys( - depositedPubkeys, - ); + const pauseMessage = { + guardianAddress, + guardianIndex, + blockNumber, + signature, + }; - this.stakingRouterService.isEqualLastChangedBlockHash( - lastChangedBlockHash, - meta.elBlockSnapshot.lastChangedBlockHash, - ); + this.logger.warn('Suspicious case detected, initialize the module pause', { + blockNumber, + }); + + // Call pause without waiting for completion + this.securityService + .pauseDepositsV3(blockNumber, signature) + .catch((error) => { + this.logger.error('Pause trx failed', { blockNumber }); + this.logger.error(error); + }); + + await this.guardianMessageService.sendPauseMessageV3(pauseMessage); + } - return data.filter((key) => key.used); + /** + * pause all modules, old version of contract + */ + public async handlePauseV2( + stakingModulesData: StakingModuleData[], + blockData: BlockData, + ) { + for (const stakingModuleData of stakingModulesData) { + if (this.isModuleAlreadyPaused(stakingModuleData, blockData)) { + continue; + } + + await this.pauseModuleDeposits(stakingModuleData, blockData); + return; // Only process one transaction per handleNewBlock + } + return; + } + + private isModuleAlreadyPaused( + stakingModuleData: StakingModuleData, + blockData: BlockData, + ): boolean { + if (stakingModuleData.isModuleDepositsPaused) { + this.logger.log('Deposits are already paused for module', { + blockHash: blockData.blockHash, + stakingModuleId: stakingModuleData.stakingModuleId, + }); + return true; + } + return false; } /** - * Handles the situation when keys have previously deposited copies + * pause module * @param blockData - collected data from the current block */ - public async handleKeysIntersections( + public async pauseModuleDeposits( stakingModuleData: StakingModuleData, blockData: BlockData, ): Promise { + const { nonce, stakingModuleId } = stakingModuleData; + + this.logger.warn('Pause deposits for module', { + blockHash: blockData.blockHash, + stakingModuleId, + }); + const { blockNumber, blockHash, @@ -411,10 +446,9 @@ export class StakingModuleGuardService { depositRoot, } = blockData; - const { nonce, stakingModuleId } = stakingModuleData; - - const signature = await this.securityService.signPauseData( + const signature = await this.securityService.signPauseDataV2( blockNumber, + blockHash, stakingModuleId, ); @@ -429,17 +463,12 @@ export class StakingModuleGuardService { stakingModuleId, }; - this.logger.warn('Suspicious case detected, initialize the module pause', { - blockHash, - stakingModuleId, - }); - // Call pause without waiting for completion this.securityService - .pauseDeposits(blockNumber, stakingModuleId, signature) + .pauseDepositsV2(blockNumber, stakingModuleId, signature) .catch((error) => this.logger.error(error)); - await this.guardianMessageService.sendPauseMessage(pauseMessage); + await this.guardianMessageService.sendPauseMessageV2(pauseMessage); } /** @@ -460,14 +489,11 @@ export class StakingModuleGuardService { const { nonce, stakingModuleId, lastChangedBlockHash } = stakingModuleData; - // if we are here we didn't find invalid keys const currentContractState = { nonce, depositRoot, blockNumber, lastChangedBlockHash, - // if we are here we didn't find invalid keys - invalidKeysFound: false, }; const lastContractsState = @@ -480,7 +506,6 @@ export class StakingModuleGuardService { this.lastContractsStateByModuleId[stakingModuleId] = currentContractState; - // need to check invalidKeysFound if (isSameContractsState) { this.logger.log("Contract states didn't change", { stakingModuleId }); return; @@ -515,89 +540,19 @@ export class StakingModuleGuardService { await this.guardianMessageService.sendDepositMessage(depositMessage); } - public async isVettedUnusedKeysValid( - stakingModuleData: StakingModuleData, - blockData: BlockData, - ): Promise { - // TODO: consider change state on upper level - const { blockNumber, depositRoot } = blockData; - const { nonce, stakingModuleId, lastChangedBlockHash } = stakingModuleData; - const lastContractsState = - this.lastContractsStateByModuleId[stakingModuleId]; - - if ( - lastContractsState && - lastChangedBlockHash === lastContractsState.lastChangedBlockHash && - lastContractsState.invalidKeysFound - ) { - // if found invalid keys on previous iteration and lastChangedBlockHash returned by kapi was not changed - // we dont need to validate again, but we still need to skip deposits until problem will not be solved - this.logger.error( - `LastChangedBlockHash was not changed and on previous iteration we found invalid keys, skip until solving problem, stakingModuleId: ${stakingModuleId}`, - ); - - this.lastContractsStateByModuleId[stakingModuleId] = { - nonce, - depositRoot, - blockNumber, - lastChangedBlockHash, - invalidKeysFound: true, - }; - - return false; - } - - if ( - !lastContractsState || - lastChangedBlockHash !== lastContractsState.lastChangedBlockHash - ) { - // keys was changed or it is a first attempt, need to validate again - const invalidKeys = await this.getInvalidKeys( - stakingModuleData, - blockData, - ); - - this.guardianMetricsService.collectInvalidKeysMetrics( - stakingModuleData.stakingModuleId, - invalidKeys.length, - ); - - // if found invalid keys, update state and exit - if (invalidKeys.length) { - this.logger.error( - `Found invalid keys, will skip deposits until solving problem, stakingModuleId: ${stakingModuleId}`, - ); - - // save info about invalid keys in cache - this.lastContractsStateByModuleId[stakingModuleId] = { - nonce, - depositRoot, - blockNumber, - lastChangedBlockHash, - invalidKeysFound: true, - }; - - return false; - } - - // keys are valid, state will be updated later - return true; - } - - return true; - } - public async getInvalidKeys( stakingModuleData: StakingModuleData, blockData: BlockData, - ): Promise<{ key: string; depositSignature: string }[]> { + ): Promise { this.logger.log('Start keys validation', { keysCount: stakingModuleData.vettedUnusedKeys.length, stakingModuleId: stakingModuleData.stakingModuleId, }); + + // TODO: move to decorator const validationTimeStart = performance.now(); - const invalidKeysList = await this.keysValidationService.findInvalidKeys( + const invalidKeysList = await this.keysValidationService.getInvalidKeys( stakingModuleData.vettedUnusedKeys, blockData.lidoWC, ); @@ -608,7 +563,7 @@ export class StakingModuleGuardService { this.logger.log('Keys validated', { stakingModuleId: stakingModuleData.stakingModuleId, - invalidKeysList, + invalidKeysCount: invalidKeysList.length, validationTime, }); @@ -628,10 +583,12 @@ export class StakingModuleGuardService { if (!firstState || !secondState) return false; if (firstState.depositRoot !== secondState.depositRoot) return false; - // If the nonce is unchanged, the state might still have changed. - // Therefore, we need to compare the 'lastChangedBlockHash' instead - // It's important to note that it's not possible for the nonce to be different - // while having the same 'lastChangedBlockHash'. + // If the nonce is unchanged, the state might still have changed due to a reorganization. + // Therefore, we need to compare the 'lastChangedBlockHash' instead. + // It's important to note that the nonce cannot be different while having the same 'lastChangedBlockHash'. + // Additionally, it's important to note that 'lastChangedBlockHash' will change not only during key update-related events, + // but also when a node operator is added, when node operator data is changed, during a reorganization, and so on. + // TODO: We may need to reconsider this approach for the Data Bus. if (firstState.lastChangedBlockHash !== secondState.lastChangedBlockHash) return false; diff --git a/src/guardian/staking-module-guard/staking-module-guard.spec.ts b/src/guardian/staking-module-guard/staking-module-guard.spec.ts index 45d4227e..479fb90d 100644 --- a/src/guardian/staking-module-guard/staking-module-guard.spec.ts +++ b/src/guardian/staking-module-guard/staking-module-guard.spec.ts @@ -7,26 +7,18 @@ import { ConfigModule } from 'common/config'; import { PrometheusModule } from 'common/prometheus'; import { SecurityModule, SecurityService } from 'contracts/security'; import { RepositoryModule } from 'contracts/repository'; -import { LidoModule } from 'contracts/lido'; -import { MessageType } from 'messages'; import { StakingModuleGuardModule } from './staking-module-guard.module'; -import { StakingRouterModule, StakingRouterService } from 'staking-router'; import { GuardianMetricsModule } from '../guardian-metrics'; import { GuardianMessageModule, GuardianMessageService, } from '../guardian-message'; import { StakingModuleGuardService } from './staking-module-guard.service'; -import { StakingModuleData } from 'guardian/interfaces'; -import { - vettedKeysDuplicatesAcrossModules, - vettedKeysDuplicatesAcrossOneModule, - vettedKeysDuplicatesAcrossOneModuleAndFew, - vettedKeysWithoutDuplicates, -} from './keys.fixtures'; -import { InconsistentLastChangedBlockHash } from 'common/custom-errors'; + import { KeysValidationModule } from 'guardian/keys-validation/keys-validation.module'; -import { KeysValidationService } from 'guardian/keys-validation/keys-validation.service'; +import { vettedKeys } from './keys.fixtures'; +import { KeysApiModule } from 'keys-api/keys-api.module'; +import { KeysApiService } from 'keys-api/keys-api.service'; jest.mock('../../transport/stomp/stomp.client'); @@ -53,9 +45,7 @@ describe('StakingModuleGuardService', () => { let securityService: SecurityService; let stakingModuleGuardService: StakingModuleGuardService; let guardianMessageService: GuardianMessageService; - let stakingRouterService: StakingRouterService; - let keysValidationService: KeysValidationService; - let findInvalidKeys: jest.SpyInstance; + let keysApiService: KeysApiService; beforeEach(async () => { const moduleRef = await Test.createTestingModule({ @@ -65,8 +55,7 @@ describe('StakingModuleGuardService', () => { LoggerModule, StakingModuleGuardModule, SecurityModule, - LidoModule, - StakingRouterModule, + KeysApiModule, GuardianMetricsModule, GuardianMessageModule, RepositoryModule, @@ -79,16 +68,14 @@ describe('StakingModuleGuardService', () => { loggerService = moduleRef.get(WINSTON_MODULE_NEST_PROVIDER); stakingModuleGuardService = moduleRef.get(StakingModuleGuardService); guardianMessageService = moduleRef.get(GuardianMessageService); - stakingRouterService = moduleRef.get(StakingRouterService); - keysValidationService = moduleRef.get(KeysValidationService); - findInvalidKeys = jest.spyOn(keysValidationService, 'findInvalidKeys'); + keysApiService = moduleRef.get(KeysApiService); jest.spyOn(loggerService, 'log').mockImplementation(() => undefined); jest.spyOn(loggerService, 'warn').mockImplementation(() => undefined); jest.spyOn(loggerService, 'debug').mockImplementation(() => undefined); jest - .spyOn(stakingRouterService, 'getKeysByPubkeys') + .spyOn(keysApiService, 'getKeysByPubkeys') .mockImplementation(async () => ({ data: [], meta: { @@ -104,40 +91,48 @@ describe('StakingModuleGuardService', () => { describe('getKeysIntersections', () => { it('should find the keys when they match', () => { - const unusedKeys = ['0x1']; - const depositedKeys = ['0x1']; + const depositedKeys = vettedKeys.map((key) => key.key); const depositedEvents = { events: depositedKeys.map((pubkey) => ({ pubkey } as any)), }; - const blockData = { unusedKeys, depositedEvents } as any; + const blockData = { depositedEvents } as any; const matched = stakingModuleGuardService.getKeysIntersections( { ...stakingModuleData, lastChangedBlockHash: '', - unusedKeys, - vettedUnusedKeys: [], + vettedUnusedKeys: vettedKeys, + isModuleDepositsPaused: false, + invalidKeys: [], + duplicatedKeys: [], + frontRunKeys: [], + unresolvedDuplicatedKeys: [], }, blockData, ); expect(matched).toBeInstanceOf(Array); expect(matched).toHaveLength(1); - expect(matched).toContainEqual({ pubkey: '0x1' }); + expect(matched).toContainEqual({ pubkey: vettedKeys[0].key }); }); it('should not find the keys when they don’t match', () => { - const unusedKeys = ['0x2']; - const depositedKeys = ['0x1']; + const depositedKeys = [ + '0x9948d2becf42e9f76922bc6f664545e6f50401050af95785a984802d32a95c4c61f8e3de312b78167f86e047f83a7796', + ]; const depositedEvents = { events: depositedKeys.map((pubkey) => ({ pubkey } as any)), }; - const blockData = { unusedKeys, depositedEvents } as any; + const blockData = { depositedEvents } as any; const matched = stakingModuleGuardService.getKeysIntersections( { ...stakingModuleData, lastChangedBlockHash: '', - unusedKeys, - vettedUnusedKeys: [], + vettedUnusedKeys: vettedKeys, + isModuleDepositsPaused: false, + invalidKeys: [], + duplicatedKeys: [], + frontRunKeys: [], + unresolvedDuplicatedKeys: [], }, blockData, ); @@ -157,8 +152,12 @@ describe('StakingModuleGuardService', () => { { ...stakingModuleData, lastChangedBlockHash: '', - unusedKeys, vettedUnusedKeys: [], + isModuleDepositsPaused: false, + invalidKeys: [], + duplicatedKeys: [], + frontRunKeys: [], + unresolvedDuplicatedKeys: [], }, blockData, ); @@ -168,354 +167,6 @@ describe('StakingModuleGuardService', () => { }); }); - describe('checkKeysIntersections', () => { - const lidoWC = '0x12'; - const attackerWC = '0x23'; - const depositedPubKeys = ['0x1234', '0x5678']; - const depositedEvents = { - startBlock: 1, - endBlock: 5, - events: depositedPubKeys.map( - (pubkey) => ({ pubkey, valid: true } as any), - ), - }; - const nodeOperatorsCache = { - depositRoot: '0x2345', - nonce: 1, - operators: [], - version: '1', - }; - - const currentBlockData = { - blockNumber: 1, - blockHash: '0x1234', - depositRoot: '0x2345', - nonce: 1, - nextSigningKeys: [] as string[], - nodeOperatorsCache, - depositedEvents, - guardianAddress: '0x3456', - guardianIndex: 1, - isDepositsPaused: false, - srModuleId: 1, - }; - - it('should call handleKeysIntersections if unused keys are found in the deposit contract', async () => { - const depositedKey = depositedPubKeys[0]; - const unusedKeys = [depositedKey]; - const events = currentBlockData.depositedEvents.events.map( - ({ ...data }) => ({ ...data, wc: attackerWC } as any), - ); - - const blockData = { - ...currentBlockData, - depositedEvents: { ...currentBlockData.depositedEvents, events }, - unusedKeys, - lidoWC, - }; - - const mockHandleCorrectKeys = jest - .spyOn(stakingModuleGuardService, 'handleCorrectKeys') - .mockImplementation(async () => undefined); - - const mockHandleKeysIntersections = jest - .spyOn(stakingModuleGuardService, 'handleKeysIntersections') - .mockImplementation(async () => undefined); - - const mockSecurityContractIsDepositsPaused = jest - .spyOn(securityService, 'isDepositsPaused') - .mockImplementation(async () => false); - - await stakingModuleGuardService.checkKeysIntersections( - { - ...stakingModuleData, - lastChangedBlockHash: '', - unusedKeys, - vettedUnusedKeys: [], - }, - blockData, - true, - ); - - expect(mockHandleCorrectKeys).not.toBeCalled(); - expect(mockHandleKeysIntersections).toBeCalledTimes(1); - expect(mockHandleKeysIntersections).toBeCalledWith( - { - ...stakingModuleData, - lastChangedBlockHash: '', - unusedKeys, - vettedUnusedKeys: [], - }, - blockData, - ); - expect(mockSecurityContractIsDepositsPaused).toBeCalledTimes(1); - }); - - it('should call handleCorrectKeys when Lido unused keys are absent in the deposit contract and vetted unused keys are valid', async () => { - const notDepositedKey = '0x2345'; - const unusedKeys = [notDepositedKey]; - const blockData = { ...currentBlockData, unusedKeys, lidoWC }; - - const mockHandleCorrectKeys = jest - .spyOn(stakingModuleGuardService, 'handleCorrectKeys') - .mockImplementation(async () => undefined); - - const mockHandleKeysIntersections = jest - .spyOn(stakingModuleGuardService, 'handleKeysIntersections') - .mockImplementation(async () => undefined); - - const mockSecurityContractIsDepositsPaused = jest - .spyOn(securityService, 'isDepositsPaused') - .mockImplementation(async () => false); - - // not found invalid keys - findInvalidKeys.mockImplementation(async () => []); - - await stakingModuleGuardService.checkKeysIntersections( - { - ...stakingModuleData, - lastChangedBlockHash: '', - unusedKeys, - vettedUnusedKeys: [], - }, - blockData, - true, - ); - - expect(findInvalidKeys).toBeCalledTimes(1); - expect(mockHandleKeysIntersections).not.toBeCalled(); - expect(mockHandleCorrectKeys).toBeCalledTimes(1); - expect(mockHandleCorrectKeys).toBeCalledWith( - { - ...stakingModuleData, - lastChangedBlockHash: '', - unusedKeys, - vettedUnusedKeys: [], - }, - blockData, - ); - expect(mockSecurityContractIsDepositsPaused).toBeCalledTimes(1); - }); - - it('should not call handleCorrectKeys if vetted unused keys are invalid', async () => { - const notDepositedKey = '0x2345'; - const unusedKeys = [notDepositedKey]; - const blockData = { ...currentBlockData, unusedKeys, lidoWC }; - - const mockHandleCorrectKeys = jest - .spyOn(stakingModuleGuardService, 'handleCorrectKeys') - .mockImplementation(async () => undefined); - - const mockHandleKeysIntersections = jest - .spyOn(stakingModuleGuardService, 'handleKeysIntersections') - .mockImplementation(async () => undefined); - - const mockSecurityContractIsDepositsPaused = jest - .spyOn(securityService, 'isDepositsPaused') - .mockImplementation(async () => false); - - // found invalid keys - findInvalidKeys.mockImplementation(async () => ['something']); - - await stakingModuleGuardService.checkKeysIntersections( - { - ...stakingModuleData, - lastChangedBlockHash: '', - unusedKeys, - vettedUnusedKeys: [], - }, - blockData, - true, - ); - - expect(findInvalidKeys).toBeCalledTimes(1); - expect(mockHandleKeysIntersections).not.toBeCalled(); - expect(mockHandleCorrectKeys).not.toBeCalled(); - expect(mockSecurityContractIsDepositsPaused).toBeCalledTimes(1); - - // check that if lastChangedBlockHash the same but keys prev was invalid, handleCorrect will not be called - // but we also will not validate keys again - findInvalidKeys.mockClear(); - - await stakingModuleGuardService.checkKeysIntersections( - { - ...stakingModuleData, - lastChangedBlockHash: '', - unusedKeys, - vettedUnusedKeys: [], - }, - blockData, - true, - ); - - expect(findInvalidKeys).not.toBeCalled(); - expect(mockHandleKeysIntersections).not.toBeCalled(); - expect(mockHandleCorrectKeys).not.toBeCalled(); - // second execution - expect(mockSecurityContractIsDepositsPaused).toBeCalledTimes(2); - - // now we fixed keys (lastChangedBlockHash was changed) and we will run validation again - findInvalidKeys.mockImplementation(async () => []); - - await stakingModuleGuardService.checkKeysIntersections( - { - ...stakingModuleData, - lastChangedBlockHash: '0x1', - unusedKeys, - vettedUnusedKeys: [], - }, - blockData, - true, - ); - - expect(findInvalidKeys).toBeCalledTimes(1); - expect(mockHandleKeysIntersections).not.toBeCalled(); - expect(mockHandleCorrectKeys).toBeCalledTimes(1); - expect(mockHandleCorrectKeys).toBeCalledWith( - { - ...stakingModuleData, - lastChangedBlockHash: '0x1', - unusedKeys, - vettedUnusedKeys: [], - }, - blockData, - ); - expect(mockSecurityContractIsDepositsPaused).toBeCalledTimes(3); - }); - - it('should not rerun validation when the lastChangedBlockHash is unchanged and no invalid keys were found previously', async () => { - const notDepositedKey = '0x2345'; - const unusedKeys = [notDepositedKey]; - const blockData = { ...currentBlockData, unusedKeys, lidoWC }; - - jest - .spyOn(guardianMessageService, 'sendMessageFromGuardian') - .mockImplementation(async () => undefined); - const sign = {} as any; - - jest - .spyOn(securityService, 'signDepositData') - .mockImplementation(async () => sign); - - const mockHandleKeysIntersections = jest - .spyOn(stakingModuleGuardService, 'handleKeysIntersections') - .mockImplementation(async () => undefined); - - const mockSecurityContractIsDepositsPaused = jest - .spyOn(securityService, 'isDepositsPaused') - .mockImplementation(async () => false); - - const mockHandleCorrectKeys = jest.spyOn( - stakingModuleGuardService, - 'handleCorrectKeys', - ); - - // found invalid keys - findInvalidKeys.mockImplementation(async () => []); - - await stakingModuleGuardService.checkKeysIntersections( - { - ...stakingModuleData, - lastChangedBlockHash: '', - unusedKeys, - vettedUnusedKeys: [], - }, - blockData, - true, - ); - - expect(findInvalidKeys).toBeCalledTimes(1); - expect(mockHandleKeysIntersections).not.toBeCalled(); - expect(mockHandleCorrectKeys).toBeCalledTimes(1); - expect(mockSecurityContractIsDepositsPaused).toBeCalledTimes(1); - - findInvalidKeys.mockClear(); - - await stakingModuleGuardService.checkKeysIntersections( - { - ...stakingModuleData, - lastChangedBlockHash: '', - unusedKeys, - vettedUnusedKeys: [], - }, - blockData, - true, - ); - - expect(findInvalidKeys).not.toBeCalled(); - expect(mockHandleKeysIntersections).not.toBeCalled(); - expect(mockHandleCorrectKeys).toBeCalledTimes(2); - // second execution - expect(mockSecurityContractIsDepositsPaused).toBeCalledTimes(2); - }); - - it('should run validation when the lastChangedBlockHash was changed and no invalid keys were found previously', async () => { - const notDepositedKey = '0x2345'; - const unusedKeys = [notDepositedKey]; - const blockData = { ...currentBlockData, unusedKeys, lidoWC }; - - jest - .spyOn(guardianMessageService, 'sendMessageFromGuardian') - .mockImplementation(async () => undefined); - const sign = {} as any; - - jest - .spyOn(securityService, 'signDepositData') - .mockImplementation(async () => sign); - - const mockHandleKeysIntersections = jest - .spyOn(stakingModuleGuardService, 'handleKeysIntersections') - .mockImplementation(async () => undefined); - - const mockSecurityContractIsDepositsPaused = jest - .spyOn(securityService, 'isDepositsPaused') - .mockImplementation(async () => false); - - const mockHandleCorrectKeys = jest.spyOn( - stakingModuleGuardService, - 'handleCorrectKeys', - ); - - // found invalid keys - findInvalidKeys.mockImplementation(async () => []); - - await stakingModuleGuardService.checkKeysIntersections( - { - ...stakingModuleData, - lastChangedBlockHash: '', - unusedKeys, - vettedUnusedKeys: [], - }, - blockData, - true, - ); - - expect(findInvalidKeys).toBeCalledTimes(1); - expect(mockHandleKeysIntersections).not.toBeCalled(); - expect(mockHandleCorrectKeys).toBeCalledTimes(1); - expect(mockSecurityContractIsDepositsPaused).toBeCalledTimes(1); - - findInvalidKeys.mockClear(); - - await stakingModuleGuardService.checkKeysIntersections( - { - ...stakingModuleData, - lastChangedBlockHash: '0x1', - unusedKeys, - vettedUnusedKeys: [], - }, - blockData, - true, - ); - - expect(findInvalidKeys).toBeCalledTimes(1); - expect(mockHandleKeysIntersections).not.toBeCalled(); - expect(mockHandleCorrectKeys).toBeCalledTimes(2); - // second execution - expect(mockSecurityContractIsDepositsPaused).toBeCalledTimes(2); - }); - }); - describe('handleCorrectKeys', () => { const signature = {} as any; const currentContractState = { @@ -543,8 +194,12 @@ describe('StakingModuleGuardService', () => { { ...stakingModuleData, lastChangedBlockHash: '', - unusedKeys: [], vettedUnusedKeys: [], + isModuleDepositsPaused: false, + invalidKeys: [], + duplicatedKeys: [], + frontRunKeys: [], + unresolvedDuplicatedKeys: [], }, blockData, ); @@ -552,8 +207,12 @@ describe('StakingModuleGuardService', () => { { ...stakingModuleData, lastChangedBlockHash: '', - unusedKeys: [], vettedUnusedKeys: [], + isModuleDepositsPaused: false, + invalidKeys: [], + duplicatedKeys: [], + frontRunKeys: [], + unresolvedDuplicatedKeys: [], }, blockData, ); @@ -580,8 +239,12 @@ describe('StakingModuleGuardService', () => { { ...stakingModuleData, lastChangedBlockHash: '', - unusedKeys: [], vettedUnusedKeys: [], + isModuleDepositsPaused: false, + invalidKeys: [], + duplicatedKeys: [], + frontRunKeys: [], + unresolvedDuplicatedKeys: [], }, blockData, ); @@ -591,140 +254,6 @@ describe('StakingModuleGuardService', () => { }); }); - describe('isVettedUnusedKeysValid', () => { - const blockData = {} as any; - - it('should return false if last state was undefined and found invalid key', async () => { - findInvalidKeys.mockImplementation(() => ['something']); - - const result = await stakingModuleGuardService.isVettedUnusedKeysValid( - { - ...stakingModuleData, - lastChangedBlockHash: '', - unusedKeys: [], - vettedUnusedKeys: [], - }, - blockData, - ); - - expect(findInvalidKeys).toBeCalledTimes(1); - expect(result).toBeFalsy(); - }); - - it('should return true if last state was undefined and keys are valid', async () => { - findInvalidKeys.mockImplementation(() => []); - const result = await stakingModuleGuardService.isVettedUnusedKeysValid( - { - ...stakingModuleData, - lastChangedBlockHash: '', - unusedKeys: [], - vettedUnusedKeys: [], - }, - blockData, - ); - - expect(findInvalidKeys).toBeCalledTimes(1); - expect(result).toBeTruthy(); - }); - - it('should return false if prev found invalid key and lastChangedBlockHash was not changed', async () => { - findInvalidKeys.mockImplementation(() => ['something']); - - const result = await stakingModuleGuardService.isVettedUnusedKeysValid( - { - ...stakingModuleData, - lastChangedBlockHash: '', - unusedKeys: [], - vettedUnusedKeys: [], - }, - blockData, - ); - - expect(findInvalidKeys).toBeCalledTimes(1); - expect(result).toBeFalsy(); - - findInvalidKeys.mockClear(); - - const newResult = await stakingModuleGuardService.isVettedUnusedKeysValid( - { - ...stakingModuleData, - lastChangedBlockHash: '', - unusedKeys: [], - vettedUnusedKeys: [], - }, - blockData, - ); - - expect(findInvalidKeys).toBeCalledTimes(0); - expect(newResult).toBeFalsy(); - }); - - it('should return true if prev found invalid key and problem was solved', async () => { - findInvalidKeys.mockImplementation(() => ['something']); - - const result = await stakingModuleGuardService.isVettedUnusedKeysValid( - { - ...stakingModuleData, - lastChangedBlockHash: '', - unusedKeys: [], - vettedUnusedKeys: [], - }, - blockData, - ); - - expect(findInvalidKeys).toBeCalledTimes(1); - expect(result).toBeFalsy(); - - findInvalidKeys.mockImplementation(() => []); - - const newResult = await stakingModuleGuardService.isVettedUnusedKeysValid( - { - ...stakingModuleData, - lastChangedBlockHash: '0x1', - unusedKeys: [], - vettedUnusedKeys: [], - }, - blockData, - ); - - expect(findInvalidKeys).toBeCalledTimes(2); - expect(newResult).toBeTruthy(); - }); - - it('should run validation if prev didnt find invalid key and lastChangedBlockHash was not changed', async () => { - // TODO: maybe delete this test - // isVettedUnusedKeysValid didn't change state in positive case - // what is why lastState in this case is undefined - findInvalidKeys.mockImplementation(() => []); - - const result = await stakingModuleGuardService.isVettedUnusedKeysValid( - { - ...stakingModuleData, - lastChangedBlockHash: '', - unusedKeys: [], - vettedUnusedKeys: [], - }, - blockData, - ); - - expect(findInvalidKeys).toBeCalledTimes(1); - expect(result).toBeTruthy(); - - const newResult = await stakingModuleGuardService.isVettedUnusedKeysValid( - { - ...stakingModuleData, - lastChangedBlockHash: '', - unusedKeys: [], - vettedUnusedKeys: [], - }, - blockData, - ); - - expect(findInvalidKeys).toBeCalledTimes(2); - expect(newResult).toBeTruthy(); - }); - }); - describe('excludeEligibleIntersections', () => { const pubkey = '0x1234'; const lidoWC = '0x12'; @@ -771,70 +300,6 @@ describe('StakingModuleGuardService', () => { }); }); - describe('handleKeysIntersections', () => { - const signature = {} as any; - const blockData = { blockNumber: 1 } as any; - const type = MessageType.PAUSE; - - beforeEach(async () => { - jest - .spyOn(securityService, 'signPauseData') - .mockImplementation(async () => signature); - }); - - it('should pause deposits', async () => { - jest - .spyOn(guardianMessageService, 'sendMessageFromGuardian') - .mockImplementation(async () => undefined); - - const mockPauseDeposits = jest - .spyOn(securityService, 'pauseDeposits') - .mockImplementation(async () => undefined); - - await stakingModuleGuardService.handleKeysIntersections( - { - ...stakingModuleData, - lastChangedBlockHash: '', - unusedKeys: [], - vettedUnusedKeys: [], - }, - blockData, - ); - - expect(mockPauseDeposits).toBeCalledTimes(1); - expect(mockPauseDeposits).toBeCalledWith( - blockData.blockNumber, - TEST_MODULE_ID, - signature, - ); - }); - - it('should send pause message', async () => { - const mockSendMessageFromGuardian = jest - .spyOn(guardianMessageService, 'sendMessageFromGuardian') - .mockImplementation(async () => undefined); - - jest - .spyOn(securityService, 'pauseDeposits') - .mockImplementation(async () => undefined); - - await stakingModuleGuardService.handleKeysIntersections( - { - ...stakingModuleData, - lastChangedBlockHash: '', - unusedKeys: [], - vettedUnusedKeys: [], - }, - blockData, - ); - - expect(mockSendMessageFromGuardian).toBeCalledTimes(1); - expect(mockSendMessageFromGuardian).toBeCalledWith( - expect.objectContaining({ type, signature, ...blockData }), - ); - }); - }); - describe('isSameContractsStates', () => { it('should return true if states are the same', () => { const state = { @@ -842,7 +307,6 @@ describe('StakingModuleGuardService', () => { nonce: 1, blockNumber: 100, lastChangedBlockHash: 'hash', - invalidKeysFound: false, }; const result = stakingModuleGuardService.isSameContractsStates( { ...state }, @@ -857,7 +321,6 @@ describe('StakingModuleGuardService', () => { nonce: 1, blockNumber: 100, lastChangedBlockHash: 'hash', - invalidKeysFound: false, }; const result = stakingModuleGuardService.isSameContractsStates(state, { ...state, @@ -872,7 +335,6 @@ describe('StakingModuleGuardService', () => { nonce: 1, blockNumber: 100, lastChangedBlockHash: 'hash', - invalidKeysFound: false, }; const result = stakingModuleGuardService.isSameContractsStates(state, { ...state, @@ -887,7 +349,6 @@ describe('StakingModuleGuardService', () => { nonce: 1, blockNumber: 100, lastChangedBlockHash: 'hash', - invalidKeysFound: false, }; const result = stakingModuleGuardService.isSameContractsStates(state, { ...state, @@ -904,7 +365,6 @@ describe('StakingModuleGuardService', () => { nonce: 1, blockNumber: 100, lastChangedBlockHash: 'hash', - invalidKeysFound: false, }; const result = stakingModuleGuardService.isSameContractsStates(state, { ...state, @@ -913,439 +373,4 @@ describe('StakingModuleGuardService', () => { expect(result).toBeFalsy(); }); }); - - describe('excludeModulesWithDuplicatedKeys', () => { - const stakingModules: StakingModuleData[] = [ - { - blockHash: '', - unusedKeys: [], - vettedUnusedKeys: [], - nonce: 0, - stakingModuleId: 1, - lastChangedBlockHash: '', - }, - { - blockHash: '', - unusedKeys: [], - vettedUnusedKeys: [], - nonce: 0, - stakingModuleId: 2, - lastChangedBlockHash: '', - }, - { - blockHash: '', - unusedKeys: [], - vettedUnusedKeys: [], - nonce: 0, - stakingModuleId: 3, - lastChangedBlockHash: '', - }, - ]; - - it('should exclude modules', () => { - const moduleIdsWithDuplicateKeys = [2]; - const expectedStakingModules: StakingModuleData[] = [ - { - blockHash: '', - unusedKeys: [], - vettedUnusedKeys: [], - nonce: 0, - stakingModuleId: 1, - lastChangedBlockHash: '', - }, - { - blockHash: '', - unusedKeys: [], - vettedUnusedKeys: [], - nonce: 0, - stakingModuleId: 3, - lastChangedBlockHash: '', - }, - ]; - - const result = stakingModuleGuardService.excludeModulesWithDuplicatedKeys( - stakingModules, - moduleIdsWithDuplicateKeys, - ); - - expect(result.length).toEqual(2); - expect(result).toEqual(expect.arrayContaining(expectedStakingModules)); - }); - - it('should return list without changes', () => { - const moduleIdsWithDuplicateKeys = [4]; - const expectedStakingModules: StakingModuleData[] = [ - { - blockHash: '', - unusedKeys: [], - vettedUnusedKeys: [], - nonce: 0, - stakingModuleId: 1, - lastChangedBlockHash: '', - }, - { - blockHash: '', - unusedKeys: [], - vettedUnusedKeys: [], - nonce: 0, - stakingModuleId: 2, - lastChangedBlockHash: '', - }, - { - blockHash: '', - unusedKeys: [], - vettedUnusedKeys: [], - nonce: 0, - stakingModuleId: 3, - lastChangedBlockHash: '', - }, - ]; - - const result = stakingModuleGuardService.excludeModulesWithDuplicatedKeys( - stakingModules, - moduleIdsWithDuplicateKeys, - ); - - expect(result.length).toEqual(3); - expect(result).toEqual(expect.arrayContaining(expectedStakingModules)); - }); - - it('should return list without changes if duplicated keys were not found', () => { - const moduleIdsWithDuplicateKeys = []; - const expectedStakingModules: StakingModuleData[] = [ - { - blockHash: '', - unusedKeys: [], - vettedUnusedKeys: [], - nonce: 0, - stakingModuleId: 1, - lastChangedBlockHash: '', - }, - { - blockHash: '', - unusedKeys: [], - vettedUnusedKeys: [], - nonce: 0, - stakingModuleId: 2, - lastChangedBlockHash: '', - }, - { - blockHash: '', - unusedKeys: [], - vettedUnusedKeys: [], - nonce: 0, - stakingModuleId: 3, - lastChangedBlockHash: '', - }, - ]; - - const result = stakingModuleGuardService.excludeModulesWithDuplicatedKeys( - stakingModules, - moduleIdsWithDuplicateKeys, - ); - - expect(result.length).toEqual(3); - expect(result).toEqual(expect.arrayContaining(expectedStakingModules)); - }); - }); - - describe('getModulesIdsWithDuplicatedVettedUnusedKeys', () => { - const blockData = { blockHash: 'some_hash' } as any; - - it('should found duplicated keys across two module', () => { - const result = - stakingModuleGuardService.getModulesIdsWithDuplicatedVettedUnusedKeys( - vettedKeysDuplicatesAcrossModules, - blockData, - ); - - const addressesOfModulesWithDuplicateKeys = [100, 102]; - - // result has all addressesOfModulesWithDuplicateKeys elements - // but it also could contain more elements, that is why we check length too - expect(result).toEqual( - expect.arrayContaining(addressesOfModulesWithDuplicateKeys), - ); - expect(result.length).toEqual(2); - }); - - it('should found duplicated keys across one module', () => { - const result = - stakingModuleGuardService.getModulesIdsWithDuplicatedVettedUnusedKeys( - vettedKeysDuplicatesAcrossOneModule, - blockData, - ); - - const addressesOfModulesWithDuplicateKeys = [100]; - expect(result).toEqual( - expect.arrayContaining(addressesOfModulesWithDuplicateKeys), - ); - expect(result.length).toEqual(1); - }); - - it('should found duplicated keys across one module and few', () => { - const result = - stakingModuleGuardService.getModulesIdsWithDuplicatedVettedUnusedKeys( - vettedKeysDuplicatesAcrossOneModuleAndFew, - blockData, - ); - - const addressesOfModulesWithDuplicateKeys = [100, 102]; - expect(result).toEqual( - expect.arrayContaining(addressesOfModulesWithDuplicateKeys), - ); - expect(result.length).toEqual(2); - }); - - it('should return empty list if duplicated keys were not found', () => { - const result = - stakingModuleGuardService.getModulesIdsWithDuplicatedVettedUnusedKeys( - vettedKeysWithoutDuplicates, - blockData, - ); - - const addressesOfModulesWithDuplicateKeys = []; - - expect(result).toEqual( - expect.arrayContaining(addressesOfModulesWithDuplicateKeys), - ); - expect(result.length).toEqual(0); - }); - }); - - describe('findAlreadyDepositedKeys', () => { - // function that return list from kapi that match keys in parameter - it('intersection is empty', async () => { - const intersectionsWithLidoWC = []; - // function that return list from kapi that match keys in parameter - const mockSendMessageFromGuardian = jest.spyOn( - stakingRouterService, - 'getKeysByPubkeys', - ); - - const result = await stakingModuleGuardService.findAlreadyDepositedKeys( - 'lastHash', - intersectionsWithLidoWC, - ); - - expect(result).toEqual([]); - expect(mockSendMessageFromGuardian).toBeCalledTimes(0); - }); - - it('should return keys list if deposits with lido wc were made by lido', async () => { - const pubkeyWithUsedKey1 = '0x1234'; - const pubkeyWithoutUsedKey = '0x56789'; - const pubkeyWithUsedKey2 = '0x3478'; - const lidoWC = '0x12'; - const intersectionsWithLidoWC = [ - { pubkey: pubkeyWithUsedKey1, wc: lidoWC, valid: true } as any, - { pubkey: pubkeyWithoutUsedKey, wc: lidoWC, valid: true } as any, - { pubkey: pubkeyWithUsedKey2, wc: lidoWC, valid: true } as any, - ]; - // function that return list from kapi that match keys in parameter - const mockSendMessageFromGuardian = jest - .spyOn(stakingRouterService, 'getKeysByPubkeys') - .mockImplementation(async () => ({ - data: [ - { - key: pubkeyWithUsedKey1, - depositSignature: 'signature', - operatorIndex: 0, - used: false, - index: 0, - moduleAddress: '0x0000', - }, - { - key: pubkeyWithUsedKey1, - depositSignature: 'signature', - operatorIndex: 0, - used: true, - index: 0, - moduleAddress: '0x0000', - }, - { - key: pubkeyWithUsedKey2, - depositSignature: 'signature', - operatorIndex: 0, - used: false, - index: 0, - moduleAddress: '0x0000', - }, - { - key: pubkeyWithUsedKey2, - depositSignature: 'signature', - operatorIndex: 0, - used: true, - index: 0, - moduleAddress: '0x0000', - }, - { - key: pubkeyWithoutUsedKey, - depositSignature: 'signature', - operatorIndex: 0, - used: false, - index: 0, - moduleAddress: '0x0000', - }, - ], - meta: { - elBlockSnapshot: { - blockNumber: 0, - blockHash: 'hash', - timestamp: 12345, - lastChangedBlockHash: 'lastHash', - }, - }, - })); - - const result = await stakingModuleGuardService.findAlreadyDepositedKeys( - 'lastHash', - intersectionsWithLidoWC, - ); - - expect(result.length).toEqual(2); - expect(result).toEqual( - expect.arrayContaining([ - { - key: pubkeyWithUsedKey1, - depositSignature: 'signature', - operatorIndex: 0, - used: true, - index: 0, - moduleAddress: '0x0000', - }, - { - key: pubkeyWithUsedKey2, - depositSignature: 'signature', - operatorIndex: 0, - used: true, - index: 0, - moduleAddress: '0x0000', - }, - ]), - ); - expect(mockSendMessageFromGuardian).toBeCalledTimes(1); - }); - - it('should return empty list if deposits with lido wc were made by someone else ', async () => { - const pubkey1 = '0x1234'; - const pubkey2 = '0x56789'; - const pubkey3 = '0x3478'; - const lidoWC = '0x12'; - const intersectionsWithLidoWC = [ - { pubkey: pubkey1, wc: lidoWC, valid: true } as any, - { pubkey: pubkey2, wc: lidoWC, valid: true } as any, - { pubkey: pubkey3, wc: lidoWC, valid: true } as any, - ]; - // function that return list from kapi that match keys in parameter - const mockSendMessageFromGuardian = jest - .spyOn(stakingRouterService, 'getKeysByPubkeys') - .mockImplementation(async () => ({ - data: [ - { - key: pubkey1, - depositSignature: 'signature', - operatorIndex: 0, - used: false, - index: 0, - moduleAddress: '0x0000', - }, - { - key: pubkey2, - depositSignature: 'signature', - operatorIndex: 0, - used: false, - index: 0, - moduleAddress: '0x0000', - }, - { - key: pubkey3, - depositSignature: 'signature', - operatorIndex: 0, - used: false, - index: 0, - moduleAddress: '0x0000', - }, - ], - meta: { - elBlockSnapshot: { - blockNumber: 0, - blockHash: 'hash', - timestamp: 12345, - lastChangedBlockHash: 'lastHash', - }, - }, - })); - - const result = await stakingModuleGuardService.findAlreadyDepositedKeys( - 'lastHash', - intersectionsWithLidoWC, - ); - - expect(result).toEqual([]); - expect(mockSendMessageFromGuardian).toBeCalledTimes(1); - }); - - it('should throw error if lastChangedBlockHash that kapi returned is not equal to prev value', async () => { - const pubkey1 = '0x1234'; - const pubkey2 = '0x56789'; - const pubkey3 = '0x3478'; - const lidoWC = '0x12'; - const intersectionsWithLidoWC = [ - { pubkey: pubkey1, wc: lidoWC, valid: true } as any, - { pubkey: pubkey2, wc: lidoWC, valid: true } as any, - { pubkey: pubkey3, wc: lidoWC, valid: true } as any, - ]; - // function that return list from kapi that match keys in parameter - const mockSendMessageFromGuardian = jest - .spyOn(stakingRouterService, 'getKeysByPubkeys') - .mockImplementation(async () => ({ - data: [ - { - key: pubkey1, - depositSignature: 'signature', - operatorIndex: 0, - used: false, - index: 0, - moduleAddress: '0x0000', - }, - { - key: pubkey2, - depositSignature: 'signature', - operatorIndex: 0, - used: false, - index: 0, - moduleAddress: '0x0000', - }, - { - key: pubkey3, - depositSignature: 'signature', - operatorIndex: 0, - used: false, - index: 0, - moduleAddress: '0x0000', - }, - ], - meta: { - elBlockSnapshot: { - blockNumber: 0, - blockHash: 'hash', - timestamp: 12345, - lastChangedBlockHash: 'lastHash', - }, - }, - })); - - const prevLastChangedBlockHash = 'prevHash'; - - expect( - stakingModuleGuardService.findAlreadyDepositedKeys( - prevLastChangedBlockHash, - intersectionsWithLidoWC, - ), - ).rejects.toThrowError(new InconsistentLastChangedBlockHash()); - - expect(mockSendMessageFromGuardian).toBeCalledTimes(1); - }); - }); }); diff --git a/src/guardian/unvetting/bytes.spec.ts b/src/guardian/unvetting/bytes.spec.ts new file mode 100644 index 00000000..0a36d037 --- /dev/null +++ b/src/guardian/unvetting/bytes.spec.ts @@ -0,0 +1,40 @@ +import { + packNodeOperatorIds, + packVettedSigningKeysCounts, + padAndJoinHex, + padHex, +} from './bytes'; + +describe('padHex', () => { + test('converts number to hex with specified bytes', () => { + expect(padHex(123, 8)).toBe('0x000000000000007b'); + expect(padHex(456, 8)).toBe('0x00000000000001c8'); + expect(padHex(789, 8)).toBe('0x0000000000000315'); + }); +}); + +describe('padAndJoinHex', () => { + test('converts list of numbers to joined hex string', () => { + expect(padAndJoinHex([123, 456, 789], 8)).toBe( + '0x000000000000007b00000000000001c80000000000000315', + ); + }); +}); + +describe('packNodeOperatorIds', () => { + test('packs node operator IDs into hexadecimal string', () => { + const nodeOperatorIds = [123, 456, 789]; + expect(packNodeOperatorIds(nodeOperatorIds)).toBe( + '0x000000000000007b00000000000001c80000000000000315', + ); + }); +}); + +describe('packVettedSigningKeysCounts', () => { + test('packs node operator IDs into hexadecimal string', () => { + const vettedSigningKeysCounts = [123, 456, 789]; + expect(packVettedSigningKeysCounts(vettedSigningKeysCounts)).toBe( + '0x0000000000000000000000000000007b000000000000000000000000000001c800000000000000000000000000000315', + ); + }); +}); diff --git a/src/guardian/unvetting/bytes.ts b/src/guardian/unvetting/bytes.ts new file mode 100644 index 00000000..7747ced0 --- /dev/null +++ b/src/guardian/unvetting/bytes.ts @@ -0,0 +1,18 @@ +import { utils } from 'ethers'; + +export function padAndJoinHex(numbers: number[], bytes: number): string { + const paddedHexArray = numbers.map((num) => padHex(num, bytes).slice(2)); + return '0x' + paddedHexArray.join(''); +} + +export function padHex(decimal: number, bytes: number) { + return utils.hexZeroPad(utils.hexlify(decimal), bytes); +} + +export function packVettedSigningKeysCounts(vettedSigningKeysCounts: number[]) { + return padAndJoinHex(vettedSigningKeysCounts, 16); +} + +export function packNodeOperatorIds(nodeOperatorIds: number[]) { + return padAndJoinHex(nodeOperatorIds, 8); +} diff --git a/src/guardian/unvetting/fixtures.ts b/src/guardian/unvetting/fixtures.ts new file mode 100644 index 00000000..af0bf964 --- /dev/null +++ b/src/guardian/unvetting/fixtures.ts @@ -0,0 +1,69 @@ +import { RegistryKey } from 'keys-api/interfaces/RegistryKey'; + +// holesky +export const mockKeys: RegistryKey[] = [ + { + key: '0x80d12670ec69b62abd4d24c828136cbb1666a63374a66269031d6101973419b66711ed712d17da05d7ca6c0b28ecd21f', + depositSignature: + '0x89496064a1d745ea20a8b1f3accd576539602985184975bc0b5f092b19c0b2c96e13821726d2201333437c17cf482e5b04e3ee2ae171a01e2826270099dc129d6ffbf894421d0f071aeeb8597e6a9bafb10b559c7490dfd690f76f64e41c13b5', + operatorIndex: 1, + used: true, + moduleAddress: '0x11a93807078f8BB880c1BD0ee4C387537de4b4b6', + index: 0, + vetted: true, + }, + { + key: '0x81011ad6ebe5c7844e59b1799e12de769f785f66df3f63debb06149c1782d574c8c2cd9c923fa881e9dcf6d413159863', + depositSignature: + '0xb56e6da7917b081ff3c8c786066124daf17ab87d10775a472cde02436444f843ea5b4f35de21906314967db503e300c510e30e990f48ff3d498e38f5d0ed55faf5398a64bac975ceb133f7fa50016054129881038aafcc792a6af9ee6a588838', + operatorIndex: 1, + used: true, + moduleAddress: '0x11a93807078f8BB880c1BD0ee4C387537de4b4b6', + index: 1, + vetted: true, + }, + { + key: '0x823c9c577aead54ac40c7986ceb8596eaf45df0140fe9b637bb8d465f878884e3f9e39914edf39c3c64f5720ec0be3a4', + depositSignature: + '0x85b3b5576e79df2ab16c2580f424c73cb0498e5e381e41c7f5b19ec9c53a573a4c2d8071ce989f17515af3c6fffc29540c631bf9774574a93ee13eda02853cf3a6d359751da40dd31b45eef67f30caf0ad43c582654ef4568fab20fa579d85dd', + operatorIndex: 2, + used: true, + moduleAddress: '0x11a93807078f8BB880c1BD0ee4C387537de4b4b6', + index: 2, + vetted: true, + }, + { + key: '0x837851278c4ab4a4641128a709c9c985f7e4c7c35082e5e2a75ae4ed712c8161b493b135b35d39ee8a65024122feb7c1', + depositSignature: + '0xb07139a38f9ba00e65add00b5b76d909335b5365cbfd9a2b29bda4d0932e4c3a89255484fb56b1cd0e47d922c5c1e6da009ccea44d57691e5043a1acedf70f748db072b3d5b3bf29327df0ee805da418285f9f542a77ef92044bbfa67822b7f2', + operatorIndex: 2, + used: true, + moduleAddress: '0x11a93807078f8BB880c1BD0ee4C387537de4b4b6', + index: 3, + vetted: true, + }, + { + key: '0x80db3318374e7c1489e1f421a66bf1ef51a48f6ad02a6ad304c67fbbad60a0a5ce51a939aa008930c3b0ed25db63710f', + depositSignature: + '0x95d9cfc2a54f218d23f6068e7c1787f348f76470ad3f70968717005b0a0fd5519562affa4d1f6a94d3feb47f3c0414d302855fe4a1fb39faa822876abbe192f4753eba03ea260f4bd57667c74d0a3c354e2909e7cc53ab23e1a3647c6055b484', + operatorIndex: 3, + used: true, + moduleAddress: '0x11a93807078f8BB880c1BD0ee4C387537de4b4b6', + index: 0, + vetted: true, + }, +]; + +export const mockKeys2: RegistryKey[] = [ + ...mockKeys, + { + key: '0x8101cf19c664f22c5209c4129cf20629d8375a2de6a26f089ea37d142d000abe6b3585ab5bc7818c7449ed5089c86054', + depositSignature: + '0x8c2e8abffe019f652bec5c8a1d4bb91e546f9b45fa1d6753b29388015d59733b25d21056df1271a7e9dff9471a5e0bb300e2f2acd3e392f7a9567b7e53848334deda79a318d1bc8ba1f4780569e40256c7e3d564500ca61fbc8058062313dede', + operatorIndex: 4, + used: true, + moduleAddress: '0x11a93807078f8BB880c1BD0ee4C387537de4b4b6', + index: 1, + vetted: true, + }, +]; diff --git a/src/guardian/unvetting/unvetting.module.ts b/src/guardian/unvetting/unvetting.module.ts new file mode 100644 index 00000000..fbf12e45 --- /dev/null +++ b/src/guardian/unvetting/unvetting.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { SecurityModule } from 'contracts/security'; +import { UnvettingService } from './unvetting.service'; +import { GuardianMessageModule } from 'guardian/guardian-message'; + +@Module({ + imports: [SecurityModule, GuardianMessageModule], + providers: [UnvettingService], + exports: [UnvettingService], +}) +export class UnvettingModule {} diff --git a/src/guardian/unvetting/unvetting.service.spec.ts b/src/guardian/unvetting/unvetting.service.spec.ts new file mode 100644 index 00000000..7240042a --- /dev/null +++ b/src/guardian/unvetting/unvetting.service.spec.ts @@ -0,0 +1,205 @@ +import { ConfigModule } from 'common/config'; +import { Test, TestingModule } from '@nestjs/testing'; +import { UnvettingService } from './unvetting.service'; +import { SecurityModule, SecurityService } from 'contracts/security'; +import { + GuardianMessageModule, + GuardianMessageService, +} from 'guardian/guardian-message'; +import { mockKeys, mockKeys2 } from './fixtures'; +import { LoggerModule } from 'common/logger'; +import { UnvettingModule } from './unvetting.module'; +import { PrometheusModule } from 'common/prometheus'; +import { MockProviderModule } from 'provider'; +import { WINSTON_MODULE_NEST_PROVIDER } from 'nest-winston'; + +jest.mock('../../transport/stomp/stomp.client'); + +describe('UnvettingService', () => { + let service: UnvettingService; + let securityService: SecurityService; + let guardianMessageService: GuardianMessageService; + + const mockSecurityService = { + signUnvetData: jest.fn().mockReturnValue(Promise.resolve('somesign')), + unvetSigningKeys: jest.fn().mockImplementation(() => Promise.resolve()), + getMaxOperatorsPerUnvetting: jest.fn().mockReturnValue(Promise.resolve(2)), + }; + + const mockGuardianMessageService = { + sendUnvetMessage: jest.fn().mockImplementation(() => Promise.resolve()), + }; + + beforeEach(async () => { + const moduleRef: TestingModule = await Test.createTestingModule({ + imports: [ + ConfigModule.forRoot(), + LoggerModule, + PrometheusModule, + MockProviderModule.forRoot(), + SecurityModule, + GuardianMessageModule, + UnvettingModule, + ], + }) + .overrideProvider(SecurityService) + .useValue(mockSecurityService) + .overrideProvider(GuardianMessageService) + .useValue(mockGuardianMessageService) + .compile(); + + service = moduleRef.get(UnvettingService); + securityService = moduleRef.get(SecurityService); + guardianMessageService = moduleRef.get( + GuardianMessageService, + ); + + const loggerService = moduleRef.get(WINSTON_MODULE_NEST_PROVIDER); + jest.spyOn(loggerService, 'warn').mockImplementation(() => undefined); + jest.spyOn(loggerService, 'log').mockImplementation(() => undefined); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('getNewVettedAmount', () => { + it('should correctly pack chunks when maxOperatorsPerUnvetting is 2 with 3 operators', () => { + const result = service['calculateNewStakingLimit'](mockKeys, 2); + + const expected = { + operatorIds: '0x00000000000000010000000000000002', + vettedKeysByOperator: + '0x0000000000000000000000000000000000000000000000000000000000000002', + }; + expect(result).toEqual(expected); + }); + + it('should correctly pack chunks when maxOperatorsPerUnvetting is 2 with 4 operators', () => { + const result = service['calculateNewStakingLimit'](mockKeys2, 2); + + const expected = { + operatorIds: '0x00000000000000010000000000000002', + vettedKeysByOperator: + '0x0000000000000000000000000000000000000000000000000000000000000002', + }; + + expect(result).toEqual(expected); + }); + }); + + describe('handleUnvetting', () => { + it('should send 1 transaction if 3 operators', async () => { + const unvetSigningKeysMock = jest.spyOn( + securityService, + 'unvetSigningKeys', + ); + const sendUnvetMessageMock = jest.spyOn( + guardianMessageService, + 'sendUnvetMessage', + ); + + const blockData = { + blockHash: '0x1', + blockNumber: 1, + guardianAddress: '0x1', + guardianIndex: 1, + securityVersion: 3, + } as any; + + const stakingModuleData = { + invalidKeys: mockKeys, + duplicatedKeys: [], + frontRunKeys: [], + nonce: 1, + stakingModuleId: 1, + } as any; + + await service.handleUnvetting(stakingModuleData, blockData); + + await new Promise((resolve) => setTimeout(resolve, 200)); + + expect(unvetSigningKeysMock).toBeCalledTimes(1); + expect(unvetSigningKeysMock).toBeCalledWith( + 1, + 1, + '0x1', + 1, + '0x00000000000000010000000000000002', + '0x0000000000000000000000000000000000000000000000000000000000000002', + 'somesign', + ); + + expect(sendUnvetMessageMock).toBeCalledTimes(1); + + expect(sendUnvetMessageMock).toBeCalledWith({ + nonce: 1, + blockNumber: 1, + blockHash: '0x1', + guardianAddress: '0x1', + guardianIndex: 1, + stakingModuleId: 1, + operatorIds: '0x00000000000000010000000000000002', + vettedKeysByOperator: + '0x0000000000000000000000000000000000000000000000000000000000000002', + signature: 'somesign', + }); + }); + + it('should send 1 transaction if 4 operators', async () => { + const unvetSigningKeysMock = jest.spyOn( + securityService, + 'unvetSigningKeys', + ); + const sendUnvetMessageMock = jest.spyOn( + guardianMessageService, + 'sendUnvetMessage', + ); + + const blockData = { + blockHash: '0x1', + blockNumber: 1, + guardianAddress: '0x1', + guardianIndex: 1, + securityVersion: 3, + } as any; + + const stakingModuleData = { + invalidKeys: mockKeys2, + duplicatedKeys: [], + frontRunKeys: [], + nonce: 1, + stakingModuleId: 1, + } as any; + + await service.handleUnvetting(stakingModuleData, blockData); + + await new Promise((resolve) => setTimeout(resolve, 200)); + + expect(unvetSigningKeysMock).toBeCalledTimes(1); + expect(unvetSigningKeysMock).toBeCalledWith( + 1, + 1, + '0x1', + 1, + '0x00000000000000010000000000000002', + '0x0000000000000000000000000000000000000000000000000000000000000002', + 'somesign', + ); + + expect(sendUnvetMessageMock).toBeCalledTimes(1); + expect(sendUnvetMessageMock).toBeCalledWith({ + nonce: 1, + blockNumber: 1, + blockHash: '0x1', + guardianAddress: '0x1', + guardianIndex: 1, + stakingModuleId: 1, + operatorIds: '0x00000000000000010000000000000002', + vettedKeysByOperator: + '0x0000000000000000000000000000000000000000000000000000000000000002', + signature: 'somesign', + }); + }); + }); +}); diff --git a/src/guardian/unvetting/unvetting.service.ts b/src/guardian/unvetting/unvetting.service.ts new file mode 100644 index 00000000..aee26607 --- /dev/null +++ b/src/guardian/unvetting/unvetting.service.ts @@ -0,0 +1,214 @@ +import { Inject, Injectable, LoggerService } from '@nestjs/common'; +import { SecurityService } from 'contracts/security'; +import { GuardianMessageService } from 'guardian/guardian-message'; +import { BlockData, StakingModuleData } from 'guardian/interfaces'; +import { RegistryKey } from 'keys-api/interfaces/RegistryKey'; +import { packNodeOperatorIds, packVettedSigningKeysCounts } from './bytes'; +import { WINSTON_MODULE_NEST_PROVIDER } from 'nest-winston'; + +type UnvetData = { operatorIds: string; vettedKeysByOperator: string }; + +@Injectable() +export class UnvettingService { + constructor( + @Inject(WINSTON_MODULE_NEST_PROVIDER) private logger: LoggerService, + private securityService: SecurityService, + private guardianMessageService: GuardianMessageService, + ) {} + + /** + * Handles unvetting of invalid, duplicated, and front-ran keys. + * + * 1. Collects keys flagged for unvetting from `stakingModuleData`. + * 2. Logs and exits if no keys require unvetting. + * 3. Retrieves the maximum operators per unvetting from `SecurityService`. + * 4. Prepares and processes the first chunk to avoid transaction races. + * 5. Sends a transaction to the Security contract and forwards messages to the guardian broker. + * + * @param stakingModuleData - Staking module data, including keys for unvetting. + * @param blockData - Collected data from the current block. + * @returns void + */ + public async handleUnvetting( + stakingModuleData: StakingModuleData, + blockData: BlockData, + ) { + const invalidKeys = this.collectInvalidKeys(stakingModuleData); + + if (!invalidKeys.length) { + this.logNoUnvettingNeeded(blockData, stakingModuleData); + return; + } + + const maxOperatorsPerUnvetting = await this.getMaxOperatorsPerUnvetting(); + const firstChunk = this.calculateNewStakingLimit( + invalidKeys, + maxOperatorsPerUnvetting, + ); + + await this.processUnvetting(stakingModuleData, blockData, firstChunk); + } + + private collectInvalidKeys( + stakingModuleData: StakingModuleData, + ): RegistryKey[] { + return stakingModuleData.invalidKeys.concat( + stakingModuleData.duplicatedKeys, + stakingModuleData.frontRunKeys, + ); + } + + private logNoUnvettingNeeded( + blockData: BlockData, + stakingModuleData: StakingModuleData, + ): void { + this.logger.debug?.('Keys are correct. No need for unvetting', { + blockHash: blockData.blockHash, + stakingModuleId: stakingModuleData.stakingModuleId, + }); + } + + public async processUnvetting( + stakingModuleData: StakingModuleData, + blockData: BlockData, + chunk: UnvetData, + ) { + const { blockNumber, blockHash, guardianAddress, guardianIndex } = + blockData; + const { nonce, stakingModuleId } = stakingModuleData; + const { operatorIds, vettedKeysByOperator } = chunk; + const signature = await this.securityService.signUnvetData( + nonce, + blockNumber, + blockHash, + stakingModuleId, + operatorIds, + vettedKeysByOperator, + ); + + if (!blockData.walletBalanceCritical) { + this.logSufficientBalance(blockData, stakingModuleData); + + this.securityService + .unvetSigningKeys( + nonce, + blockNumber, + blockHash, + stakingModuleId, + operatorIds, + vettedKeysByOperator, + signature, + ) + .catch((error) => + this.logger.error('Failed to send unvet transaction', { + error, + blockHash, + stakingModuleId, + }), + ); + } else { + this.logCriticalBalance(blockData, stakingModuleData); + } + + await this.guardianMessageService.sendUnvetMessage({ + nonce, + blockNumber, + blockHash, + guardianAddress, + guardianIndex, + stakingModuleId, + operatorIds, + vettedKeysByOperator, + signature, + }); + } + + private logSufficientBalance( + blockData: BlockData, + stakingModuleData: StakingModuleData, + ): void { + this.logger.log( + 'Wallet balance is sufficient, sending unvet transaction.', + { + blockHash: blockData.blockHash, + stakingModuleId: stakingModuleData.stakingModuleId, + }, + ); + } + + private logCriticalBalance( + blockData: BlockData, + stakingModuleData: StakingModuleData, + ): void { + this.logger.warn( + 'Wallet balance is critical. Skipping unvet transaction.', + { + blockHash: blockData.blockHash, + stakingModuleId: stakingModuleData.stakingModuleId, + }, + ); + } + + private async getMaxOperatorsPerUnvetting() { + return await this.securityService.getMaxOperatorsPerUnvetting(); + } + + private calculateNewStakingLimit( + keysForUnvetting: RegistryKey[], + maxOperatorsPerUnvetting: number, + ): UnvetData { + const operatorNewVettedAmount = this.findNewVettedAmount(keysForUnvetting); + return this.getFirstChunk( + operatorNewVettedAmount, + maxOperatorsPerUnvetting, + ); + } + + /** + * Finds the key with the smallest index in the list of keys for unvetting. + * It returns a map where each operator's total vetted amount is stored. + * @param keysForUnvetting - Array of RegistryKey objects + * @returns Map of operator indices to their total vetted amount + */ + private findNewVettedAmount( + keysForUnvetting: RegistryKey[], + ): Map { + return keysForUnvetting.reduce((acc, key) => { + const vettedAmount = acc.get(key.operatorIndex); + if (vettedAmount === undefined || key.index < vettedAmount) { + acc.set(key.operatorIndex, key.index); + } + return acc; + }, new Map()); + } + + /** + * Return first chunk from the map of total vetted amounts for operators based on maxOperatorsPerUnvetting. + * Each operator index is packed in 8 bytes and vetted amount in 16 bytes. + * @param operatorNewVettedAmount - Map of operator indices to their total vetted amounts + * @param maxOperatorsPerUnvetting - Maximum number of operators per unvetting chunk + * @returns Object containing packed operatorIds and vettedAmount + */ + private getFirstChunk( + operatorNewVettedAmount: Map, + maxOperatorsPerUnvetting: number, + ): UnvetData { + const chunk = Array.from(operatorNewVettedAmount.entries()).slice( + 0, + maxOperatorsPerUnvetting, + ); + + this.logger.log('Get first chunk for unvetting', { + count: chunk.length, + maxOperatorsPerUnvetting, + totalChunks: Math.ceil( + operatorNewVettedAmount.size / maxOperatorsPerUnvetting, + ), + }); + + return { + operatorIds: packNodeOperatorIds(chunk.map((p) => p[0])), + vettedKeysByOperator: packVettedSigningKeysCounts(chunk.map((p) => p[1])), + }; + } +} diff --git a/src/keys-api/interfaces/RegistryKey.ts b/src/keys-api/interfaces/RegistryKey.ts index 4b133b3f..4fd43f8d 100644 --- a/src/keys-api/interfaces/RegistryKey.ts +++ b/src/keys-api/interfaces/RegistryKey.ts @@ -24,4 +24,8 @@ export type RegistryKey = { * Staking module address */ moduleAddress: string; + /** + * Vetted key status + */ + vetted: boolean; }; diff --git a/src/keys-api/interfaces/SRModuleListResponse.ts b/src/keys-api/interfaces/SRModuleListResponse.ts new file mode 100644 index 00000000..9bbf7f3a --- /dev/null +++ b/src/keys-api/interfaces/SRModuleListResponse.ts @@ -0,0 +1,7 @@ +import { SRModule } from '.'; +import { ELBlockSnapshot } from './ELBlockSnapshot'; + +export type SRModuleListResponse = { + data: Array; + elBlockSnapshot: ELBlockSnapshot; +}; diff --git a/src/keys-api/keys-api.service.ts b/src/keys-api/keys-api.service.ts index 1195a0fe..5097a481 100644 --- a/src/keys-api/keys-api.service.ts +++ b/src/keys-api/keys-api.service.ts @@ -6,6 +6,8 @@ import { KeyListResponse, Status } from './interfaces'; import { WINSTON_MODULE_NEST_PROVIDER } from 'nest-winston'; import { Configuration } from 'common/config'; import { GroupedByModuleOperatorListResponse } from './interfaces/GroupedByModuleOperatorListResponse'; +import { InconsistentLastChangedBlockHash } from 'common/custom-errors'; +import { SRModuleListResponse } from './interfaces/SRModuleListResponse'; @Injectable() export class KeysApiService { @@ -15,6 +17,13 @@ export class KeysApiService { protected readonly fetchService: FetchService, ) {} + private getBaseUrl() { + const baseUrl = + this.config.KEYS_API_URL || + `${this.config.KEYS_API_HOST}:${this.config.KEYS_API_PORT}`; + return baseUrl; + } + protected async fetch(url: string, requestInit?: RequestInit) { const controller = new AbortController(); const { signal } = controller; @@ -23,8 +32,7 @@ export class KeysApiService { controller.abort(); }, FETCH_REQUEST_TIMEOUT); - const baseUrl = `${this.config.KEYS_API_HOST}:${this.config.KEYS_API_PORT}`; - + const baseUrl = this.getBaseUrl(); try { const res: Response = await this.fetchService.fetchJson( `${baseUrl}${url}`, @@ -33,18 +41,17 @@ export class KeysApiService { ...requestInit, }, ); + clearTimeout(timer); return res; - } catch (error) { + } catch (error: any) { clearTimeout(timer); throw error; } } /** - * - * @param The /v1/keys/find API endpoint returns keys along with their duplicates - * @returns + * The /v1/keys/find API endpoint returns keys along with their duplicates */ public async getKeysByPubkeys(pubkeys: string[]) { const result = await this.fetch(`/v1/keys/find`, { @@ -57,11 +64,6 @@ export class KeysApiService { return result; } - public async getUnusedKeys() { - const result = await this.fetch(`/v1/keys?used=false`); - return result; - } - public async getOperatorListWithModule() { const result = await this.fetch( `/v1/operators`, @@ -70,7 +72,6 @@ export class KeysApiService { } /** - * * @param The /v1/status API endpoint returns chainId, appVersion, El and Cl meta * @returns */ @@ -78,4 +79,36 @@ export class KeysApiService { const result = await this.fetch(`/v1/status`); return result; } + + /** + * The /v1/keys endpoint returns full list of keys + */ + public async getKeys() { + const result = await this.fetch(`/v1/keys`); + return result; + } + + public async getModules() { + const result = await this.fetch(`/v1/modules`); + return result; + } + + /** + * Verifies the consistency of metadata by comparing hashes. + * @param firstRequestHash - Hash of the first request + * @param secondRequestHash - Hash of the second request + */ + public verifyMetaDataConsistency( + firstRequestHash: string, + secondRequestHash: string, + ) { + if (firstRequestHash !== secondRequestHash) { + const error = + 'Since the last request, data in Kapi has been updated. This may result in inconsistencies between the data from two separate requests.'; + + this.logger.error(error, { firstRequestHash, secondRequestHash }); + + throw new InconsistentLastChangedBlockHash(); + } + } } diff --git a/src/main.ts b/src/main.ts index e7251a8a..95f903aa 100644 --- a/src/main.ts +++ b/src/main.ts @@ -15,6 +15,7 @@ async function bootstrap() { app.useLogger(logger); process.on('unhandledRejection', async (error) => { + logger.log('Unhandled rejection'); logger.error(error); await app.close(); diff --git a/src/messages/interfaces/message.interface.ts b/src/messages/interfaces/message.interface.ts index 76a4024f..6457e950 100644 --- a/src/messages/interfaces/message.interface.ts +++ b/src/messages/interfaces/message.interface.ts @@ -10,6 +10,7 @@ export enum MessageType { PAUSE = 'pause', DEPOSIT = 'deposit', PING = 'ping', + UNVET = 'unvet', } export interface MessageDeposit extends MessageRequiredFields { @@ -30,7 +31,7 @@ export interface MessageApp { name?: string; } -export interface MessagePause extends MessageRequiredFields { +export interface MessagePauseV2 extends MessageRequiredFields { depositRoot: string; nonce: number; blockNumber: number; @@ -38,3 +39,18 @@ export interface MessagePause extends MessageRequiredFields { signature: Signature; stakingModuleId: number; } + +export interface MessagePauseV3 extends MessageRequiredFields { + blockNumber: number; + signature: Signature; +} + +export interface MessageUnvet extends MessageRequiredFields { + nonce: number; + blockNumber: number; + blockHash: string; + stakingModuleId: number; + signature: Signature; + operatorIds: string; + vettedKeysByOperator: string; +} diff --git a/src/provider/provider.service.spec.ts b/src/provider/provider.service.spec.ts index 226644be..b3d59d57 100644 --- a/src/provider/provider.service.spec.ts +++ b/src/provider/provider.service.spec.ts @@ -100,8 +100,18 @@ describe('ProviderService', () => { it('should fetch recursive if missing response', async () => { const event1 = {} as any; const event2 = {} as any; - const expectedFirst = { events: [event1], startBlock: 0, endBlock: 4 }; - const expectedSecond = { events: [event2], startBlock: 5, endBlock: 10 }; + const expectedFirst = { + events: [event1], + startBlock: 0, + endBlock: 4, + extraField: 'some value', + }; + const expectedSecond = { + events: [event2], + startBlock: 5, + endBlock: 10, + extraField: 'some value', + }; const startBlock = 0; const endBlock = 10; @@ -123,6 +133,7 @@ describe('ProviderService', () => { const { calls, results } = mockFetchEvents.mock; const events = [event1, event2]; + expect(Object.keys(result)).toHaveLength(3); expect(result).toEqual({ events, startBlock, endBlock }); expect(mockFetchEvents).toBeCalledTimes(3); expect(calls[0]).toEqual([startBlock, endBlock]); diff --git a/src/provider/provider.service.ts b/src/provider/provider.service.ts index e1561da3..d591b2d9 100644 --- a/src/provider/provider.service.ts +++ b/src/provider/provider.service.ts @@ -54,8 +54,8 @@ export class ProviderService { /** * Returns current block */ - public async getBlock(): Promise { - return await this.provider.getBlock('latest'); + public async getBlock(tag: string | number = 'latest'): Promise { + return await this.provider.getBlock(tag); } /** @@ -82,9 +82,14 @@ export class ProviderService { startBlock: number, endBlock: number, fetcher: (startBlock: number, endBlock: number) => Promise, - ): Promise { + ): Promise<{ events: E[]; startBlock: number; endBlock: number }> { try { - return await fetcher(startBlock, endBlock); + const data = await fetcher(startBlock, endBlock); + return { + events: data.events, + startBlock: data.startBlock, + endBlock: data.endBlock, + }; } catch (error: any) { const errorCode = error?.error?.code; const isLimitExceeded = ERRORS_LIMIT_EXCEEDED.includes(errorCode); @@ -109,9 +114,9 @@ export class ProviderService { this.fetchEventsFallOver(center, endBlock, fetcher), ]); - const events = first.events.concat(second.events); + const events = first.events.concat(second.events) as E[]; - return { events, startBlock, endBlock } as T; + return { events, startBlock, endBlock }; } else { this.logger.warn('Fetch error. Retry', error); diff --git a/src/staking-module-data-collector/index.ts b/src/staking-module-data-collector/index.ts new file mode 100644 index 00000000..3734cbb3 --- /dev/null +++ b/src/staking-module-data-collector/index.ts @@ -0,0 +1,2 @@ +export * from './staking-module-data-collector.module'; +export * from './staking-module-data-collector.service'; diff --git a/src/staking-router/keys.fixtures.ts b/src/staking-module-data-collector/keys.fixtures.ts similarity index 100% rename from src/staking-router/keys.fixtures.ts rename to src/staking-module-data-collector/keys.fixtures.ts diff --git a/src/staking-router/operators.fixtures.ts b/src/staking-module-data-collector/operators.fixtures.ts similarity index 100% rename from src/staking-router/operators.fixtures.ts rename to src/staking-module-data-collector/operators.fixtures.ts diff --git a/src/staking-module-data-collector/staking-module-data-collector.module.ts b/src/staking-module-data-collector/staking-module-data-collector.module.ts new file mode 100644 index 00000000..4f1dc34a --- /dev/null +++ b/src/staking-module-data-collector/staking-module-data-collector.module.ts @@ -0,0 +1,20 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule } from 'common/config'; +import { StakingModuleDataCollectorService } from './staking-module-data-collector.service'; +import { StakingModuleGuardModule } from 'guardian/staking-module-guard'; +import { KeysDuplicationCheckerModule } from 'guardian/duplicates'; +import { GuardianMetricsModule } from 'guardian/guardian-metrics'; +import { StakingRouterModule } from 'contracts/staking-router'; + +@Module({ + imports: [ + ConfigModule, + StakingModuleGuardModule, + KeysDuplicationCheckerModule, + GuardianMetricsModule, + StakingRouterModule, + ], + providers: [StakingModuleDataCollectorService], + exports: [StakingModuleDataCollectorService], +}) +export class StakingModuleDataCollectorModule {} diff --git a/src/staking-module-data-collector/staking-module-data-collector.service.ts b/src/staking-module-data-collector/staking-module-data-collector.service.ts new file mode 100644 index 00000000..1ac12067 --- /dev/null +++ b/src/staking-module-data-collector/staking-module-data-collector.service.ts @@ -0,0 +1,194 @@ +import { Injectable, LoggerService, Inject } from '@nestjs/common'; +import { WINSTON_MODULE_NEST_PROVIDER } from 'nest-winston'; +import { StakingModuleData, BlockData } from 'guardian'; +import { RegistryKey } from 'keys-api/interfaces/RegistryKey'; +import { StakingModuleGuardService } from 'guardian/staking-module-guard'; +import { KeysDuplicationCheckerService } from 'guardian/duplicates'; +import { GuardianMetricsService } from 'guardian/guardian-metrics'; +import { StakingRouterService } from 'contracts/staking-router'; +import { SRModule } from 'keys-api/interfaces'; +import { ELBlockSnapshot } from 'keys-api/interfaces/ELBlockSnapshot'; + +type State = { + stakingModules: SRModule[]; + meta: ELBlockSnapshot; + lidoKeys: RegistryKey[]; +}; + +@Injectable() +export class StakingModuleDataCollectorService { + constructor( + @Inject(WINSTON_MODULE_NEST_PROVIDER) private logger: LoggerService, + private stakingModuleGuardService: StakingModuleGuardService, + private keysDuplicationCheckerService: KeysDuplicationCheckerService, + private guardianMetricsService: GuardianMetricsService, + private stakingRouterService: StakingRouterService, + ) {} + + /** + * Collects basic data about the staking module, including activity status, vetted unused keys list, ID, address, and nonce. + */ + public async collectStakingModuleData({ + stakingModules, + meta, + lidoKeys, + }: State): Promise { + return await Promise.all( + stakingModules.map(async (stakingModule) => { + return { + isModuleDepositsPaused: + await this.stakingRouterService.isModuleDepositsPaused( + stakingModule.id, + { + blockHash: meta.blockHash, + }, + ), + nonce: stakingModule.nonce, + stakingModuleId: stakingModule.id, + stakingModuleAddress: stakingModule.stakingModuleAddress, + blockHash: meta.blockHash, + lastChangedBlockHash: meta.lastChangedBlockHash, + vettedUnusedKeys: this.getModuleVettedUnusedKeys( + stakingModule.stakingModuleAddress, + lidoKeys, + ), + duplicatedKeys: [], + invalidKeys: [], + frontRunKeys: [], + unresolvedDuplicatedKeys: [], + }; + }), + ); + } + + /** + * Check for duplicated, invalid, and front-run attempts + */ + public async checkKeys( + stakingModulesData: StakingModuleData[], + lidoKeys: RegistryKey[], + blockData: BlockData, + ): Promise { + const { duplicates, unresolved } = + await this.keysDuplicationCheckerService.getDuplicatedKeys( + lidoKeys, + blockData, + ); + + await Promise.all( + stakingModulesData.map(async (stakingModuleData) => { + // identify keys that were front-run withing vetted unused keys + stakingModuleData.frontRunKeys = + this.stakingModuleGuardService.getFrontRunAttempts( + stakingModuleData, + blockData, + ); + // identify keys with invalid signatures within vetted unused keys + stakingModuleData.invalidKeys = + await this.stakingModuleGuardService.getInvalidKeys( + stakingModuleData, + blockData, + ); + + // Filter all keys for the module to get the total number of duplicated keys, + // for Prometheus metrics + const allModuleDuplicatedKeys = this.getModuleKeys( + stakingModuleData.stakingModuleAddress, + duplicates, + ); + // Filter vetted and unused duplicated keys for the module + stakingModuleData.duplicatedKeys = this.getModuleVettedUnusedKeys( + stakingModuleData.stakingModuleAddress, + duplicates, + ); + + // Filter all unresolved keys (keys without a SigningKeyAdded event) for the module, + // including both vetted and unvetted keys, to show the total count of unresolved keys + // for Prometheus metrics + const allModuleUnresolved = this.getModuleKeys( + stakingModuleData.stakingModuleAddress, + unresolved, + ); + // Filter vetted and unused duplicated keys for the module + stakingModuleData.unresolvedDuplicatedKeys = + this.getModuleVettedUnusedKeys( + stakingModuleData.stakingModuleAddress, + unresolved, + ); + + this.collectModuleMetric( + stakingModuleData, + allModuleUnresolved, + stakingModuleData.unresolvedDuplicatedKeys, + allModuleDuplicatedKeys, + stakingModuleData.duplicatedKeys, + ); + + this.logKeysCheckState(stakingModuleData); + }), + ); + } + + private collectModuleMetric( + stakingModuleData: StakingModuleData, + unresolvedKeys: RegistryKey[], + vettedUnusedUnresolvedKeys: RegistryKey[], + duplicatedKeys: RegistryKey[], + vettedUnusedDuplcaitedKeys: RegistryKey[], + ) { + const { invalidKeys, stakingModuleId } = stakingModuleData; + + // Collect metrics for unresolved and duplicated keys in the staking module: + // - Total unresolved keys (keys without a corresponding SigningKeyAdded event) + // - Subset of unresolved keys that are vetted and unused + // - Total duplicated keys + // - Subset of duplicated keys that are vetted and unused + this.guardianMetricsService.collectDuplicatedKeysMetrics( + stakingModuleId, + unresolvedKeys.length, + vettedUnusedUnresolvedKeys.length, + duplicatedKeys.length, + vettedUnusedDuplcaitedKeys.length, + ); + + // Collect metrics for the total number of vetted unused keys with invalid signatures within the staking module + this.guardianMetricsService.collectInvalidKeysMetrics( + stakingModuleId, + invalidKeys.length, + ); + } + + private logKeysCheckState(stakingModuleData: StakingModuleData) { + const { + stakingModuleId, + blockHash, + frontRunKeys, + invalidKeys, + duplicatedKeys, + unresolvedDuplicatedKeys, + } = stakingModuleData; + this.logger.log('Keys check state', { + stakingModuleId: stakingModuleId, + frontRunAttempt: frontRunKeys.length, + invalid: invalidKeys.length, + duplicated: duplicatedKeys.length, + unresolvedDuplicated: unresolvedDuplicatedKeys.length, + blockHash: blockHash, + }); + } + + private getModuleKeys(stakingModuleAddress: string, keys: RegistryKey[]) { + return keys.filter((key) => key.moduleAddress === stakingModuleAddress); + } + + private getModuleVettedUnusedKeys( + stakingModuleAddress: string, + lidoKeys: RegistryKey[], + ) { + const vettedUnusedKeys = lidoKeys.filter( + (key) => + !key.used && key.vetted && key.moduleAddress === stakingModuleAddress, + ); + return vettedUnusedKeys; + } +} diff --git a/src/staking-router/staking-router.service.ts b/src/staking-router/staking-router.service.ts deleted file mode 100644 index 6251921e..00000000 --- a/src/staking-router/staking-router.service.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { Injectable, LoggerService, Inject } from '@nestjs/common'; -import { WINSTON_MODULE_NEST_PROVIDER } from 'nest-winston'; -import { Configuration } from 'common/config'; -import { KeysApiService } from 'keys-api/keys-api.service'; -import { StakingModuleData } from 'guardian'; -import { getVettedUnusedKeys } from './vetted-keys'; -import { RegistryOperator } from 'keys-api/interfaces/RegistryOperator'; -import { RegistryKey } from 'keys-api/interfaces/RegistryKey'; -import { SRModule } from 'keys-api/interfaces'; -import { InconsistentLastChangedBlockHash } from 'common/custom-errors'; -import { GroupedByModuleOperatorListResponse } from 'keys-api/interfaces/GroupedByModuleOperatorListResponse'; - -@Injectable() -export class StakingRouterService { - constructor( - @Inject(WINSTON_MODULE_NEST_PROVIDER) protected logger: LoggerService, - protected readonly config: Configuration, - protected readonly keysApiService: KeysApiService, - ) {} - - async getOperatorsAndModules() { - const { data: operatorsByModules, meta: operatorsMeta } = - await this.keysApiService.getOperatorListWithModule(); - - return { data: operatorsByModules, meta: operatorsMeta }; - } - - /** - * Return staking module data and block information - */ - public async getStakingModulesData( - data: GroupedByModuleOperatorListResponse, - ): Promise { - const { data: operatorsByModules, meta: operatorsMeta } = data; - - const { data: unusedKeys, meta: unusedKeysMeta } = - await this.keysApiService.getUnusedKeys(); - - const blockHash = operatorsMeta.elBlockSnapshot.blockHash; - const lastChangedBlockHash = - operatorsMeta.elBlockSnapshot.lastChangedBlockHash; - - this.isEqualLastChangedBlockHash( - lastChangedBlockHash, - unusedKeysMeta.elBlockSnapshot.lastChangedBlockHash, - ); - - const stakingModulesData = operatorsByModules.map( - ({ operators, module: stakingModule }) => - this.processModuleData({ - operators, - stakingModule, - unusedKeys, - blockHash, - // Will set the lastChangedBlockHash for the module in KAPI, not the personal module's lastChangedBlockHash - lastChangedBlockHash, - }), - ); - - return stakingModulesData; - } - - public isEqualLastChangedBlockHash( - firstRequestHash: string, - secondRequestHash: string, - ) { - if (firstRequestHash !== secondRequestHash) { - const error = - 'Since the last request, data in Kapi has been updated. This may result in inconsistencies between the data from two separate requests.'; - - this.logger.error(error, { firstRequestHash, secondRequestHash }); - - throw new InconsistentLastChangedBlockHash(); - } - } - - private processModuleData({ - operators, - stakingModule, - unusedKeys, - blockHash, - lastChangedBlockHash, - }: { - operators: RegistryOperator[]; - stakingModule: SRModule; - unusedKeys: RegistryKey[]; - blockHash: string; - lastChangedBlockHash: string; - }): StakingModuleData { - const moduleUnusedKeys = unusedKeys.filter( - (key) => key.moduleAddress === stakingModule.stakingModuleAddress, - ); - - const moduleVettedUnusedKeys = getVettedUnusedKeys( - operators, - moduleUnusedKeys, - ); - - return { - unusedKeys: moduleUnusedKeys.map((srKey) => srKey.key), - nonce: stakingModule.nonce, - stakingModuleId: stakingModule.id, - blockHash, - lastChangedBlockHash, - vettedUnusedKeys: moduleVettedUnusedKeys, - }; - } - - public async getKeysByPubkeys(pubkeys: string[]) { - return await this.keysApiService.getKeysByPubkeys(pubkeys); - } -} diff --git a/src/staking-router/staking-router.spec.ts b/src/staking-router/staking-router.spec.ts deleted file mode 100644 index 66122f3e..00000000 --- a/src/staking-router/staking-router.spec.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { KeysApiService } from '../keys-api/keys-api.service'; -import { StakingRouterService } from './staking-router.service'; -import { groupedByModulesOperators } from './operators.fixtures'; -import { keysAllStakingModules } from './keys.fixtures'; -import { ConfigModule } from 'common/config'; -import { LoggerModule } from 'common/logger'; -import { InconsistentLastChangedBlockHash } from 'common/custom-errors'; - -describe('StakingRouter', () => { - let stakingRouterService: StakingRouterService; - let keysApiService: KeysApiService; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - imports: [ConfigModule.forRoot(), LoggerModule], - providers: [ - StakingRouterService, - { - provide: KeysApiService, - useValue: { - getOperatorListWithModule: jest.fn(), - getUnusedKeys: jest.fn(), - }, - }, - ], - }).compile(); - - stakingRouterService = - module.get(StakingRouterService); - keysApiService = module.get(KeysApiService); - }); - - it("should return correct data when 'lastChangedBlockHash' values of two requests are identical", async () => { - (keysApiService.getUnusedKeys as jest.Mock).mockResolvedValue( - keysAllStakingModules, - ); - - const result = await stakingRouterService.getStakingModulesData( - groupedByModulesOperators, - ); - - // Assertions - expect(result).toEqual([ - { - unusedKeys: [ - '0x9948d2becf42e9f76922bc6f664545e6f50401050af95785a984802d32a95c4c61f8e3de312b78167f86e047f83a7796', - '0x911dd3091cfb1b42c960e4f343ea98d9ee6a1dc8ef215afa976fb557bd627a901717c0008bc33a0bfea15f0dfe9c5d01', - '0x8d12ec44816f108df84ef9b03e423a6d8fb0f0a1823c871b123ff41f893a7b372eb038a1ed1ff15083e07a777a5cba50', - ], - vettedUnusedKeys: [ - { - key: '0x9948d2becf42e9f76922bc6f664545e6f50401050af95785a984802d32a95c4c61f8e3de312b78167f86e047f83a7796', - depositSignature: - '0x8bf4401a354de243a3716ee2efc0bde1ded56a40e2943ac7c50290bec37e935d6170b21e7c0872f203199386143ef12612a1488a8e9f1cdf1229c382f29c326bcbf6ed6a87d8fbfe0df87dacec6632fc4709d9d338f4cf81e861d942c23bba1e', - operatorIndex: 0, - used: false, - moduleAddress: '0x595F64Ddc3856a3b5Ff4f4CC1d1fb4B46cFd2bAC', - index: 100, - }, - ], - blockHash: - '0x40c697def4d4f7233b75149ab941462582bb5f035b5089f7c6a3d7849222f47c', - lastChangedBlockHash: - '0x194ac4fd960ed44cb3db53fe1f5a53e983280fd438aeba607ae04f1bb416b4a1', - stakingModuleId: 1, - nonce: 364, - }, - { - unusedKeys: [ - '0x83fc58f68d913481e065c928b040ae8b157ef2b32371b7df93d40188077c619dc789d443c18ac4a9b7e76de5ed6c8247', - '0x84e85db03bee714dbecf01914460d9576b7f7226030bdbeae9ee923bf5f8e01eec4f7dfe54aa7eca6f4bccce59a0bf42', - '0x84ff489c1e07c75ac635914d4fa20bb37b30f7cf37a8fb85298a88e6f45daab122b43a352abce2132bdde96fd4a01599', - ], - vettedUnusedKeys: [ - { - key: '0x83fc58f68d913481e065c928b040ae8b157ef2b32371b7df93d40188077c619dc789d443c18ac4a9b7e76de5ed6c8247', - depositSignature: - '0xa13833d96f4b98291dbf428cb69e7a3bdce61c9d20efcdb276423c7d6199ebd10cf1728dbd418c592701a41983cb02330e736610be254f617140af48a9d20b31cdffdd1d4fc8c0776439fca3330337d33042768acf897000b9e5da386077be44', - operatorIndex: 28, - used: false, - moduleAddress: '0x11a93807078f8BB880c1BD0ee4C387537de4b4b6', - index: 4, - }, - ], - nonce: 69, - blockHash: - '0x40c697def4d4f7233b75149ab941462582bb5f035b5089f7c6a3d7849222f47c', - lastChangedBlockHash: - '0x194ac4fd960ed44cb3db53fe1f5a53e983280fd438aeba607ae04f1bb416b4a1', - stakingModuleId: 2, - }, - ]); - }); - - it("should throw error when 'lastChangedBlockHash' values of two requests are different", async () => { - (keysApiService.getUnusedKeys as jest.Mock).mockResolvedValue({ - ...keysAllStakingModules, - ...{ - meta: { - elBlockSnapshot: { - lastChangedBlockHash: - '0xabf3d64e85527d0c80eb6b0378316caceed9a24f535f6f28dad008fdfebe82b8', - }, - }, - }, - }); - - expect( - stakingRouterService.getStakingModulesData(groupedByModulesOperators), - ).rejects.toThrowError(new InconsistentLastChangedBlockHash()); - }); -}); diff --git a/src/staking-router/vetted-keys.spec.ts b/src/staking-router/vetted-keys.spec.ts deleted file mode 100644 index caa6360e..00000000 --- a/src/staking-router/vetted-keys.spec.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { getVettedUnusedKeys } from './vetted-keys'; // Replace with your actual module path - -describe('getVettedUnusedKeys', () => { - test('should return an empty array for empty input arrays', () => { - expect(getVettedUnusedKeys([], [])).toEqual([]); - }); - - test('should correctly filter and sort keys for multiple operators', () => { - // totalSigningKeys is used here only to describe cases, - // we don't use is in algorithm in function to determine vetted unused keys - const operators = [ - // 2 vetted unused keys, have some available limit - { index: 1, stakingLimit: 3, usedSigningKeys: 1, totalSigningKeys: 4 }, - // 1 vetted unused key, have some available limit - { index: 2, stakingLimit: 1, usedSigningKeys: 0, totalSigningKeys: 2 }, - // 0 vetted unused keys, staking limit wasnt increased - { index: 3, stakingLimit: 0, usedSigningKeys: 0, totalSigningKeys: 1 }, - // 0 vetted unused keys, staking limit exceeded have one used key - { index: 4, stakingLimit: 1, usedSigningKeys: 1, totalSigningKeys: 2 }, - // 0 vetted unused keys, have staking limit, but don't have keys to deposit - { index: 5, stakingLimit: 1, usedSigningKeys: 0, totalSigningKeys: 0 }, - ] as any; - - const unusedKeys = [ - // operator 1 unused keys - { operatorIndex: 1, index: 1 }, - { operatorIndex: 1, index: 0 }, - { operatorIndex: 1, index: 2 }, - // operator 2 unused keys - { operatorIndex: 2, index: 0 }, - { operatorIndex: 2, index: 1 }, - // operator 3 unused keys - { operatorIndex: 3, index: 0 }, - // operator 4 unused keys - { operatorIndex: 4, index: 0 }, - ] as any; - - const expected = [ - { operatorIndex: 1, index: 0 }, - { operatorIndex: 1, index: 1 }, - { operatorIndex: 2, index: 0 }, - ]; - const result = getVettedUnusedKeys(operators, unusedKeys); - expect(result.length).toEqual(expected.length); - expect(getVettedUnusedKeys(operators, unusedKeys)).toEqual(expected); - }); - - test('should correctly sort keys within operators', () => { - const operators = [ - { index: 1, stakingLimit: 4, usedSigningKeys: 1, totalSigningKeys: 5 }, - ] as any; - const unusedKeys = [ - { operatorIndex: 1, index: 3 }, - { operatorIndex: 1, index: 1 }, - { operatorIndex: 1, index: 2 }, - { operatorIndex: 1, index: 4 }, - ] as any; - const expected = [ - { operatorIndex: 1, index: 1 }, - { operatorIndex: 1, index: 2 }, - { operatorIndex: 1, index: 3 }, - ]; - expect(getVettedUnusedKeys(operators, unusedKeys)).toEqual(expected); - }); -}); diff --git a/src/staking-router/vetted-keys.ts b/src/staking-router/vetted-keys.ts deleted file mode 100644 index cfdc0671..00000000 --- a/src/staking-router/vetted-keys.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { RegistryKey } from 'keys-api/interfaces/RegistryKey'; -import { RegistryOperator } from 'keys-api/interfaces/RegistryOperator'; - -export function getVettedUnusedKeys( - operators: RegistryOperator[], - unusedKeys: RegistryKey[], -): RegistryKey[] { - return operators.flatMap((operator) => { - const operatorKeys = unusedKeys - .filter((key) => key.operatorIndex === operator.index) - .sort((a, b) => a.index - b.index) - // stakingLimit limit cant be less than usedSigningKeys - .slice(0, operator.stakingLimit - operator.usedSigningKeys); - - return operatorKeys; - }); -} diff --git a/src/transport/kafka/kafka.transport.e2e-spec.ts b/src/transport/kafka/kafka.transport.e2e-spec.ts index 3f9e99b5..bd641506 100644 --- a/src/transport/kafka/kafka.transport.e2e-spec.ts +++ b/src/transport/kafka/kafka.transport.e2e-spec.ts @@ -2,14 +2,23 @@ import { Test, TestingModule } from '@nestjs/testing'; import { LoggerService } from '@nestjs/common'; import { LoggerModule } from 'common/logger'; import { ConfigModule } from 'common/config'; -import { sleep } from 'utils'; import { MockProviderModule } from 'provider'; import { KafkaTransport } from './kafka.transport'; -import { Kafka } from 'kafkajs'; +import { Kafka, logLevel } from 'kafkajs'; import { WINSTON_MODULE_NEST_PROVIDER } from 'nest-winston'; import { MessageType } from '../../messages'; -describe.skip('KafkaTransport', () => { +const waitFor = async (cb: () => boolean) => { + return new Promise((res) => { + const interval = setInterval(() => { + if (!cb()) return; + clearInterval(interval); + res(true); + }, 1000); + }); +}; + +describe('KafkaTransport', () => { let transport: KafkaTransport; let moduleRef: TestingModule; let loggerService: LoggerService; @@ -27,6 +36,7 @@ describe.skip('KafkaTransport', () => { provide: Kafka, useFactory: async () => new Kafka({ + logLevel: logLevel.DEBUG, clientId: 'test-client', brokers: ['localhost:9092'], logCreator: () => () => void 0, @@ -58,13 +68,13 @@ describe.skip('KafkaTransport', () => { await transport.publish('test', { label: 'first' }, MessageType.PING); await transport.publish('test', { label: 'second' }, MessageType.PING); - await sleep(10_000); + await waitFor(() => receivedMessages.length > 1); expect(receivedMessages.length).toBe(2); expect(receivedMessages[0]).toHaveProperty('label'); expect(receivedMessages[0].label).toBe('first'); expect(receivedMessages[1]).toHaveProperty('label'); expect(receivedMessages[1].label).toBe('second'); - }, 20_000); + }, 60_000); }); }); diff --git a/src/wallet/wallet.constants.ts b/src/wallet/wallet.constants.ts index 19d3eff4..b0741a97 100644 --- a/src/wallet/wallet.constants.ts +++ b/src/wallet/wallet.constants.ts @@ -1,6 +1,2 @@ -import { WeiPerEther } from '@ethersproject/constants'; - export const WALLET_PRIVATE_KEY = 'walletPrivateKey'; -export const WALLET_MIN_BALANCE = WeiPerEther.div(2); - export const WALLET_BALANCE_UPDATE_BLOCK_RATE = 50; diff --git a/src/wallet/wallet.interfaces.ts b/src/wallet/wallet.interfaces.ts index c629c6e1..26cdf8cd 100644 --- a/src/wallet/wallet.interfaces.ts +++ b/src/wallet/wallet.interfaces.ts @@ -3,12 +3,27 @@ export interface SignDepositDataParams { blockNumber: number; blockHash: string; depositRoot: string; - keysOpIndex: number; + nonce: number; stakingModuleId: number; } export interface SignPauseDataParams { prefix: string; blockNumber: number; +} + +export interface SignModulePauseDataParams { + prefix: string; + blockNumber: number; + stakingModuleId: number; +} + +export interface SignUnvetDataParams { + prefix: string; + blockNumber: number; + blockHash: string; stakingModuleId: number; + nonce: number; + operatorIds: string; + vettedKeysByOperator: string; } diff --git a/src/wallet/wallet.module.ts b/src/wallet/wallet.module.ts index 62366528..634afde4 100644 --- a/src/wallet/wallet.module.ts +++ b/src/wallet/wallet.module.ts @@ -1,9 +1,10 @@ import { Module } from '@nestjs/common'; -import { Configuration } from 'common/config'; +import { ConfigModule, Configuration } from 'common/config'; import { WALLET_PRIVATE_KEY } from './wallet.constants'; import { WalletService } from './wallet.service'; @Module({ + imports: [ConfigModule], providers: [ WalletService, { diff --git a/src/wallet/wallet.service.spec.ts b/src/wallet/wallet.service.spec.ts index 05cd8785..159ff943 100644 --- a/src/wallet/wallet.service.spec.ts +++ b/src/wallet/wallet.service.spec.ts @@ -11,6 +11,12 @@ import { ProviderService } from 'provider'; import { WalletModule } from 'wallet'; import { WALLET_PRIVATE_KEY } from './wallet.constants'; import { WalletService } from './wallet.service'; +import { + keccak256, + recoverAddress, + solidityKeccak256, + solidityPack, +} from 'ethers/lib/utils'; const TEST_MODULE_ID = 1; @@ -73,13 +79,13 @@ describe('WalletService', () => { it('should sign deposit data', async () => { const prefix = hexZeroPad('0x1', 32); const depositRoot = hexZeroPad('0x2', 32); - const keysOpIndex = 1; + const nonce = 1; const blockNumber = 1; const blockHash = hexZeroPad('0x3', 32); const signature = await walletService.signDepositData({ prefix, depositRoot, - keysOpIndex, + nonce, blockNumber, blockHash, stakingModuleId: TEST_MODULE_ID, @@ -96,11 +102,11 @@ describe('WalletService', () => { }); }); - describe('signPauseData', () => { + describe('signPauseDataV2', () => { it('should sign pause data', async () => { const prefix = hexZeroPad('0x1', 32); const blockNumber = 1; - const signature = await walletService.signPauseData({ + const signature = await walletService.signPauseDataV2({ prefix, blockNumber, stakingModuleId: TEST_MODULE_ID, @@ -116,4 +122,68 @@ describe('WalletService', () => { ); }); }); + + describe('signUnvetData', () => { + it('should return valid signature', async () => { + const UNVET_MESSAGE_PREFIX = createUnvetMessagePrefix( + '0xB8ae82F7BFF2553bAF158B7a911DC10162045C53', + ); + + // use method underhood that do non-standart data packing + const signature = await walletService.signUnvetData({ + prefix: UNVET_MESSAGE_PREFIX, + blockNumber: 1429451, + blockHash: + '0x528b085cf0951e7c3003deb40db355cd35c77018f4cdc937bd10783e1c15588c', + nonce: 11, + stakingModuleId: 1, + operatorIds: '0x0000000000000000', + vettedKeysByOperator: '0x00000000000000000000000000000032', + }); + + const encodedData = solidityKeccak256( + [ + 'bytes32', + 'uint256', + 'bytes32', + 'uint256', + 'uint256', + 'bytes', + 'bytes', + ], + [ + UNVET_MESSAGE_PREFIX, + 1429451, + '0x528b085cf0951e7c3003deb40db355cd35c77018f4cdc937bd10783e1c15588c', + 1, + 11, + '0x0000000000000000', + '0x00000000000000000000000000000032', + ], + ); + + const signer = recoverAddress(encodedData, signature); + + expect(signer).toEqual(walletService.address); + }); + }); + + function createUnvetMessagePrefix(contractAddress: string) { + const HOLESKY_CHAIN_ID = 17000; + + // Precomputed hash value as bytes32 + const precomputedHash = + '0x2dd9727393562ed11c29080a884630e2d3a7078e71b313e713a8a1ef68948f6a'; + + // Packing data similarly to Solidity's `abi.encodePacked` + const data = solidityPack( + ['bytes32', 'uint256', 'address'], + [precomputedHash, HOLESKY_CHAIN_ID, contractAddress], + ); + + // Hashing the packed data + const UNVET_MESSAGE_PREFIX = keccak256(data); + + return UNVET_MESSAGE_PREFIX; + } }); diff --git a/src/wallet/wallet.service.ts b/src/wallet/wallet.service.ts index 67dff510..1292dffd 100644 --- a/src/wallet/wallet.service.ts +++ b/src/wallet/wallet.service.ts @@ -3,6 +3,7 @@ import { Signature } from '@ethersproject/bytes'; import { keccak256 } from '@ethersproject/keccak256'; import { formatEther } from '@ethersproject/units'; import { Wallet } from '@ethersproject/wallet'; +import { BigNumber } from '@ethersproject/bignumber'; import { Inject, Injectable, @@ -17,13 +18,16 @@ import { Gauge, register } from 'prom-client'; import { ProviderService } from 'provider'; import { WALLET_BALANCE_UPDATE_BLOCK_RATE, - WALLET_MIN_BALANCE, WALLET_PRIVATE_KEY, } from './wallet.constants'; import { SignDepositDataParams, + SignModulePauseDataParams, SignPauseDataParams, + SignUnvetDataParams, } from './wallet.interfaces'; +import { utils } from 'ethers'; +import { Configuration } from 'common/config'; @Injectable() export class WalletService implements OnModuleInit { @@ -32,6 +36,7 @@ export class WalletService implements OnModuleInit { @Inject(WINSTON_MODULE_NEST_PROVIDER) private logger: LoggerService, @Inject(WALLET_PRIVATE_KEY) private privateKey: string, private providerService: ProviderService, + protected readonly config: Configuration, ) {} async onModuleInit() { @@ -39,7 +44,7 @@ export class WalletService implements OnModuleInit { register.setDefaultLabels({ guardianAddress }); try { - await this.updateBalance(); + await this.monitorGuardianBalance(); this.subscribeToEthereumUpdates(); } catch (error) { this.logger.error(error); @@ -53,29 +58,71 @@ export class WalletService implements OnModuleInit { const provider = this.providerService.provider; provider.on('block', async (blockNumber) => { if (blockNumber % WALLET_BALANCE_UPDATE_BLOCK_RATE !== 0) return; - this.updateBalance().catch((error) => this.logger.error(error)); + await this.monitorGuardianBalance().catch((error) => + this.logger.error(error), + ); }); this.logger.log('WalletService subscribed to Ethereum events'); } /** - * Updates the guardian account balance + * Monitors the guardian account balance to ensure it is sufficient for transactions. + * Updates the account balance metric. */ @OneAtTime() - public async updateBalance() { + public async monitorGuardianBalance() { + const balanceWei = await this.getAccountBalance(); + const balanceETH = formatEther(balanceWei); + this.accountBalance.set(Number(balanceETH)); + this.isBalanceSufficient(balanceWei); + } + + /** + * Retrieves the account balance in Wei. + * @returns The account balance in Wei. + */ + public async getAccountBalance(): Promise { const provider = this.providerService.provider; - const balanceWei = await provider.getBalance(this.address); - const formatted = `${formatEther(balanceWei)} ETH`; - const isSufficient = balanceWei.gte(WALLET_MIN_BALANCE); + return await provider.getBalance(this.address); + } + + /** + * Checks if the balance is at or below the critical threshold, + * indicating that the balance is critical and may require intervention. + * + * @returns True if the balance is at or below the critical value, otherwise false. + */ + public async isBalanceCritical(): Promise { + const balanceWei = await this.getAccountBalance(); + const balanceETH = formatEther(balanceWei); + const formatted = `${balanceETH} ETH`; + const isCritical = balanceWei.lte(this.config.WALLET_CRITICAL_BALANCE); + + if (isCritical) { + this.logger.log('Account balance is critical', { balance: formatted }); + } + + return isCritical; + } - this.accountBalance.set(Number(formatEther(balanceWei))); + /** + * Checks if the balance is sufficient to perform at least 10 unvetting operations. + * @param balanceWei The current balance in Wei. + * @returns True if the balance is sufficient, otherwise false. + */ + public isBalanceSufficient(balanceWei): boolean { + const balanceETH = formatEther(balanceWei); + const formatted = `${balanceETH} ETH`; + const isSufficient = balanceWei.gte(this.config.WALLET_MIN_BALANCE); if (isSufficient) { this.logger.log('Account balance is sufficient', { balance: formatted }); } else { this.logger.warn('Account balance is too low', { balance: formatted }); } + + return isSufficient; } /** @@ -120,7 +167,7 @@ export class WalletService implements OnModuleInit { * @param signDepositDataParams - parameters for signing deposit message * @param signDepositDataParams.prefix - unique prefix from the contract for this type of message * @param signDepositDataParams.depositRoot - current deposit root from the deposit contract - * @param signDepositDataParams.keysOpIndex - current index of keys operations from the registry contract + * @param signDepositDataParams.nonce - current index of keys operations from the registry contract * @param signDepositDataParams.blockNumber - current block number * @param signDepositDataParams.blockHash - current block hash * @param signDepositDataParams.stakingModuleId - target module id @@ -131,25 +178,38 @@ export class WalletService implements OnModuleInit { blockNumber, blockHash, depositRoot, - keysOpIndex, + nonce, stakingModuleId, }: SignDepositDataParams): Promise { const encodedData = defaultAbiCoder.encode( ['bytes32', 'uint256', 'bytes32', 'bytes32', 'uint256', 'uint256'], - [ - prefix, - blockNumber, - blockHash, - depositRoot, - stakingModuleId, - keysOpIndex, - ], + [prefix, blockNumber, blockHash, depositRoot, stakingModuleId, nonce], ); const messageHash = keccak256(encodedData); return await this.signMessage(messageHash); } + /** + * Signs a message to pause deposits + * @param signPauseDataParams - parameters for signing pause message + * @param signPauseDataParams.prefix - unique prefix from the contract for this type of message + * @param signPauseDataParams.blockNumber - block number that is signed + * @returns signature + */ + public async signPauseDataV3({ + prefix, + blockNumber, + }: SignPauseDataParams): Promise { + const encodedData = defaultAbiCoder.encode( + ['bytes32', 'uint256'], + [prefix, blockNumber], + ); + + const messageHash = keccak256(encodedData); + return this.signMessage(messageHash); + } + /** * Signs a message to pause deposits * @param signPauseDataParams - parameters for signing pause message @@ -158,11 +218,11 @@ export class WalletService implements OnModuleInit { * @param signPauseDataParams.stakingModuleId - target staking module id * @returns signature */ - public async signPauseData({ + public async signPauseDataV2({ prefix, blockNumber, stakingModuleId, - }: SignPauseDataParams): Promise { + }: SignModulePauseDataParams): Promise { const encodedData = defaultAbiCoder.encode( ['bytes32', 'uint256', 'uint256'], [prefix, blockNumber, stakingModuleId], @@ -171,4 +231,59 @@ export class WalletService implements OnModuleInit { const messageHash = keccak256(encodedData); return this.signMessage(messageHash); } + + /** + * Sign a message to unvet signing keys + * @param signUnvetDataParams - parameters for signing unvet message + * @param signUnvetDataParams.prefix - unique prefix from the contract for this type of message + * @param signUnvetDataParams.blockNumber - block number that is signed + * @param signUnvetDataParams.blockHash - current block hash + * @param signUnvetDataParams.nonce - current index of keys operations from the registry contract + * @param signUnvetDataParams.stakingModuleId - target staking module id + * @param signDepositDataParams.operatorIds - list of operators ids for unvetting + * @param signDepositDataParams.vettedKeysByOperator - list of new values for vetted validators amount for operator + * @returns + */ + public async signUnvetData({ + prefix, + blockNumber, + blockHash, + nonce, + stakingModuleId, + operatorIds, + vettedKeysByOperator, + }: SignUnvetDataParams): Promise { + const encodedData = utils.solidityPack( + ['bytes32', 'uint256', 'bytes32', 'uint256', 'uint256', 'bytes', 'bytes'], + [ + prefix, + blockNumber, + blockHash, + stakingModuleId, + nonce, + operatorIds, + vettedKeysByOperator, + ], + ); + + this.logger.debug?.('Sign data:', { + prefix, + blockNumber, + blockHash, + stakingModuleId, + nonce, + operatorIds, + vettedKeysByOperator, + }); + + const messageHash = keccak256(encodedData); + + this.logger.debug?.('Message hash:', { + messageHash, + blockHash, + blockNumber, + }); + + return this.signMessage(messageHash); + } } diff --git a/test/constants.ts b/test/constants.ts index 1e634fda..fed8229e 100644 --- a/test/constants.ts +++ b/test/constants.ts @@ -10,23 +10,36 @@ export const TESTS_TIMEOUT = 30_000; export const SLEEP_FOR_RESULT = 3_000; // Addresses -export const SECURITY_MODULE = '0xe57025E250275cA56f92d76660DEcfc490C7E79A'; +export const SECURITY_MODULE = '0x808DE3b26Be9438F12E9B45528955EA94C17f217'; +// https://holesky.etherscan.io/address/0x808DE3b26Be9438F12E9B45528955EA94C17f217#readContract +// getOwner export const SECURITY_MODULE_OWNER = - '0xa5F1d7D49F581136Cf6e58B32cBE9a2039C48bA1'; -export const STAKING_ROUTER = '0xa3Dbd317E53D363176359E10948BA0b1c0A4c820'; -export const NOP_REGISTRY = '0x9D4AF1Ee19Dad8857db3a45B0374c81c8A1C6320'; -export const DEPOSIT_CONTRACT = '0xff50ed3d0ec03aC01D4C79aAd74928BFF48a7b2b'; -export const FAKE_SIMPLE_DVT = '0x0000000000000000000000000000000000000123'; + '0xE92329EC7ddB11D25e25b3c21eeBf11f15eB325d'; + +export const SECURITY_MODULE_V2 = '0x045dd46212a178428c088573a7d102b9d89a022a'; +// https://holesky.etherscan.io/address/0x045dd46212a178428c088573a7d102b9d89a022a#readContract +// getOwner +export const SECURITY_MODULE_OWNER_V2 = + '0xDA6bEE5441f2e6b364F3b25E85d5f3C29Bfb669E'; +export const STAKING_ROUTER = '0xd6EbF043D30A7fe46D1Db32BA90a0A51207FE229'; +export const NOP_REGISTRY = '0x595F64Ddc3856a3b5Ff4f4CC1d1fb4B46cFd2bAC'; +export const DEPOSIT_CONTRACT = '0x4242424242424242424242424242424242424242'; +export const SIMPLE_DVT = '0x11a93807078f8BB880c1BD0ee4C387537de4b4b6'; +export const CSM = '0x4562c3e63c2e586cD1651B958C22F88135aCAd4f'; +export const SANDBOX = '0xD6C2ce3BB8bea2832496Ac8b5144819719f343AC'; // Withdrawal credentials -export const GOOD_WC = - '0x010000000000000000000000dc62f9e8c34be08501cdef4ebde0a280f576d762'; +export const LIDO_WC = + '0x010000000000000000000000f0179dec45a37423ead4fad5fcb136197872ead9'; export const BAD_WC = - '0x010000000000000000000000dc62f9e8c34be08501cdef4ebde0a280f576d763'; + '0x010000000000000000000000b9d7934878b5fb9610b3fe8a5e441e8fad7e291f'; // Fork node config -export const CHAIN_ID = CHAINS.Goerli; -export const FORK_BLOCK = 8895800; +export const CHAIN_ID = CHAINS.Holesky; + +export const FORK_BLOCK = 1894357; +export const FORK_BLOCK_V2 = 1817726; export const UNLOCKED_ACCOUNTS = [SECURITY_MODULE_OWNER]; +export const UNLOCKED_ACCOUNTS_V2 = [SECURITY_MODULE_OWNER_V2]; export const GANACHE_PORT = 8545; // BLS key for the validator diff --git a/test/duplicates-v3.e2e-spec.ts b/test/duplicates-v3.e2e-spec.ts new file mode 100644 index 00000000..0140e624 --- /dev/null +++ b/test/duplicates-v3.e2e-spec.ts @@ -0,0 +1,969 @@ +// Constants +import { + TESTS_TIMEOUT, + SLEEP_FOR_RESULT, + STAKING_ROUTER, + CHAIN_ID, + FORK_BLOCK, + GANACHE_PORT, + NOP_REGISTRY, + SIMPLE_DVT, + UNLOCKED_ACCOUNTS, +} from './constants'; + +// Contract Factories +import { StakingRouterAbi__factory } from '../src/generated'; + +// Mock rabbit straight away +jest.mock('../src/transport/stomp/stomp.client.ts'); + +jest.setTimeout(10_000); + +import { + setupTestingModule, + closeServer, + initLevelDB, +} from './helpers/test-setup'; +import { getWalletAddress } from './helpers/deposit'; +import { SigningKeysRegistryService } from 'contracts/signing-keys-registry'; +import { DepositsRegistryStoreService } from 'contracts/deposits-registry/store'; +import { ProviderService } from 'provider'; +import { GuardianService } from 'guardian'; +import { KeysApiService } from 'keys-api/keys-api.service'; +import { SecurityService } from 'contracts/security'; +import { Server } from 'ganache'; +import { GuardianMessageService } from 'guardian/guardian-message'; +import { SigningKeysStoreService as SignKeyLevelDBService } from 'contracts/signing-keys-registry/store'; +import { makeServer } from './server'; +import { addGuardians } from './helpers/dsm'; +import { BlsService } from 'bls'; +import { mockKey, mockKey2, mockKeyEvent } from './helpers/keys-fixtures'; +import { + keysApiMockGetAllKeys, + keysApiMockGetModules, + mockedModuleCurated, + mockedModuleDvt, + mockMeta, +} from './helpers'; +import { DepositIntegrityCheckerService } from 'contracts/deposits-registry/sanity-checker'; + +describe('Deposits in case of duplicates', () => { + let server: Server<'ethereum'>; + let providerService: ProviderService; + let keysApiService: KeysApiService; + let guardianService: GuardianService; + let securityService: SecurityService; + + let levelDBService: DepositsRegistryStoreService; + let depositIntegrityCheckerService: DepositIntegrityCheckerService; + + let signKeyLevelDBService: SignKeyLevelDBService; + let signingKeysRegistryService: SigningKeysRegistryService; + + let guardianMessageService: GuardianMessageService; + // methods mocks + let sendDepositMessage: jest.SpyInstance; + let sendUnvetMessage: jest.SpyInstance; + let sendPauseMessage: jest.SpyInstance; + let unvetSigningKeys: jest.SpyInstance; + + const setupServer = async () => { + server = makeServer(FORK_BLOCK, CHAIN_ID, UNLOCKED_ACCOUNTS); + await server.listen(GANACHE_PORT); + }; + + const setupGuardians = async () => { + await addGuardians(); + }; + + const setupMocks = () => { + // broker messages + sendDepositMessage = jest + .spyOn(guardianMessageService, 'sendDepositMessage') + .mockImplementation(() => Promise.resolve()); + jest + .spyOn(guardianMessageService, 'pingMessageBroker') + .mockImplementation(() => Promise.resolve()); + sendPauseMessage = jest + .spyOn(guardianMessageService, 'sendPauseMessageV3') + .mockImplementation(() => Promise.resolve()); + sendUnvetMessage = jest + .spyOn(guardianMessageService, 'sendUnvetMessage') + .mockImplementation(() => Promise.resolve()); + + // deposit cache mocks + jest + .spyOn(depositIntegrityCheckerService, 'putEventsToTree') + .mockImplementation(() => Promise.resolve()); + jest + .spyOn(depositIntegrityCheckerService, 'checkLatestRoot') + .mockImplementation(() => Promise.resolve(true)); + jest + .spyOn(depositIntegrityCheckerService, 'checkFinalizedRoot') + .mockImplementation(() => Promise.resolve(true)); + + // mock unvetting method of contract + // as we dont use real keys api and work with fixtures of operators and keys + // we cant make real unvetting + unvetSigningKeys = jest + .spyOn(securityService, 'unvetSigningKeys') + .mockImplementation(() => Promise.resolve(null as any)); + }; + + const setupTestingServices = async (moduleRef) => { + // leveldb service + levelDBService = moduleRef.get(DepositsRegistryStoreService); + signKeyLevelDBService = moduleRef.get(SignKeyLevelDBService); + + await initLevelDB(levelDBService, signKeyLevelDBService); + + // deposit events related services + depositIntegrityCheckerService = moduleRef.get( + DepositIntegrityCheckerService, + ); + + const blsService = moduleRef.get(BlsService); + await blsService.onModuleInit(); + + // keys events service + signingKeysRegistryService = moduleRef.get(SigningKeysRegistryService); + + providerService = moduleRef.get(ProviderService); + + // dsm methods and council sign services + securityService = moduleRef.get(SecurityService); + + // keys api servies + keysApiService = moduleRef.get(KeysApiService); + + // rabbitmq message sending methods + guardianMessageService = moduleRef.get(GuardianMessageService); + + // main service that check keys and make decision + guardianService = moduleRef.get(GuardianService); + }; + + beforeEach(async () => { + await setupServer(); + await setupGuardians(); + const moduleRef = await setupTestingModule(); + await setupTestingServices(moduleRef); + setupMocks(); + }, 20000); + + afterEach(async () => { + await closeServer(server, levelDBService, signKeyLevelDBService); + }, 15000); + + test( + 'skip deposits for module if find duplicated key across operator', + async () => { + const currentBlock = await providerService.provider.getBlock('latest'); + + // Set deposit cache + await levelDBService.setCachedEvents({ + data: [], + headers: { + startBlock: currentBlock.number, + endBlock: currentBlock.number, + }, + }); + + const earliestKey = { + ...mockKey, + operatorIndex: 0, + moduleAddress: NOP_REGISTRY, + index: 0, + }; + const duplicates = [ + earliestKey, + { ...mockKey, operatorIndex: 0, moduleAddress: NOP_REGISTRY, index: 1 }, + { ...mockKey, operatorIndex: 0, moduleAddress: NOP_REGISTRY, index: 2 }, + ]; + // Mock Keys API + const vettedUnusedKeys = [ + ...duplicates, + { + ...mockKey2, + moduleAddress: SIMPLE_DVT, + }, + ]; + + // setup elBlockSnapshot + const meta = mockMeta(currentBlock, currentBlock.hash); + // setup /v1/modules + const stakingModules = [mockedModuleCurated, mockedModuleDvt]; + keysApiMockGetModules(keysApiService, stakingModules, meta); + // setup /v1/keys + keysApiMockGetAllKeys(keysApiService, vettedUnusedKeys, meta); + + // mock events cache to check + await signingKeysRegistryService.setCachedEvents({ + data: [], // dont need events in this test + headers: { + startBlock: currentBlock.number - 2, + endBlock: currentBlock.number, + stakingModulesAddresses: [NOP_REGISTRY, SIMPLE_DVT], + }, + }); + + // Check that module was not paused + const routerContract = StakingRouterAbi__factory.connect( + STAKING_ROUTER, + providerService.provider, + ); + const isOnPause = await routerContract.getStakingModuleIsDepositsPaused( + 1, + ); + expect(isOnPause).toBe(false); + await guardianService.handleNewBlock(); + + await new Promise((res) => setTimeout(res, SLEEP_FOR_RESULT)); + + const walletAddress = getWalletAddress(); + // just skip on this iteration deposit for Curated staking module + expect(sendDepositMessage).toBeCalledTimes(1); + expect(sendDepositMessage).toHaveBeenCalledWith( + expect.objectContaining({ + blockNumber: currentBlock.number, + guardianAddress: walletAddress, + guardianIndex: 7, + stakingModuleId: 2, + }), + ); + expect(sendPauseMessage).toBeCalledTimes(0); + expect(sendUnvetMessage).toBeCalledTimes(1); + expect(sendUnvetMessage).toHaveBeenCalledWith( + expect.objectContaining({ + blockNumber: currentBlock.number, + guardianAddress: walletAddress, + guardianIndex: 7, + stakingModuleId: 1, + operatorIds: '0x0000000000000000', + vettedKeysByOperator: '0x00000000000000000000000000000001', + }), + ); + expect(unvetSigningKeys).toBeCalledTimes(1); + + // Mine a new block + await providerService.provider.send('evm_mine', []); + + // after deleting duplicates in staking module, + // council will resume deposits to module + const unusedKeysWithoutDuplicates = [ + earliestKey, + { + ...mockKey2, + moduleAddress: SIMPLE_DVT, + }, + ]; + + await providerService.provider.send('evm_mine', []); + const newBlock = await providerService.provider.getBlock('latest'); + expect(newBlock.number).toBeGreaterThan(currentBlock.number); + + const newMeta = mockMeta(newBlock, newBlock.hash); + // setup /v1/modules + keysApiMockGetModules(keysApiService, stakingModules, newMeta); + // setup /v1/keys + keysApiMockGetAllKeys( + keysApiService, + unusedKeysWithoutDuplicates, + newMeta, + ); + + sendDepositMessage.mockClear(); + sendUnvetMessage.mockClear(); + await guardianService.handleNewBlock(); + await new Promise((res) => setTimeout(res, SLEEP_FOR_RESULT)); + + expect(sendDepositMessage).toBeCalledTimes(2); + expect(sendDepositMessage.mock.calls[0][0]).toEqual( + expect.objectContaining({ + blockNumber: newBlock.number, + guardianAddress: walletAddress, + guardianIndex: 7, + stakingModuleId: 1, + }), + ); + expect(sendDepositMessage.mock.calls[1][0]).toEqual( + expect.objectContaining({ + blockNumber: newBlock.number, + guardianAddress: walletAddress, + guardianIndex: 7, + stakingModuleId: 2, + }), + ); + expect(sendUnvetMessage).toBeCalledTimes(0); + }, + TESTS_TIMEOUT, + ); + + test( + 'skip deposits for module if find duplicated key across operators of two modules', + async () => { + const currentBlock = await providerService.provider.getBlock('latest'); + const walletAddress = getWalletAddress(); + await levelDBService.setCachedEvents({ + data: [], + headers: { + startBlock: currentBlock.number, + endBlock: currentBlock.number, + }, + }); + + const duplicates = [ + { ...mockKey, moduleAddress: NOP_REGISTRY }, + { + ...mockKey, + moduleAddress: SIMPLE_DVT, + }, + ]; + + // setup elBlockSnapshot + const meta = mockMeta(currentBlock, currentBlock.hash); + // setup /v1/modules + const stakingModules = [mockedModuleCurated, mockedModuleDvt]; + keysApiMockGetModules(keysApiService, stakingModules, meta); + // setup /v1/keys + keysApiMockGetAllKeys(keysApiService, duplicates, meta); + + await signingKeysRegistryService.setCachedEvents({ + data: [ + mockKeyEvent, + // key of second module was added later + { + ...mockKeyEvent, + moduleAddress: SIMPLE_DVT, + blockNumber: mockKeyEvent.blockNumber + 1, + blockHash: 'somefakehash', + }, + ], + headers: { + startBlock: currentBlock.number - 2, + endBlock: currentBlock.number - 1, + stakingModulesAddresses: [NOP_REGISTRY, SIMPLE_DVT], + }, + }); + + // Check that module was not paused + const routerContract = StakingRouterAbi__factory.connect( + STAKING_ROUTER, + providerService.provider, + ); + const isOnPause = await routerContract.getStakingModuleIsDepositsPaused( + 1, + ); + expect(isOnPause).toBe(false); + + await guardianService.handleNewBlock(); + + await new Promise((res) => setTimeout(res, SLEEP_FOR_RESULT)); + + // just skip on this iteration deposit for Curated staking module + expect(sendDepositMessage).toBeCalledTimes(1); + expect(sendDepositMessage).toHaveBeenCalledWith( + expect.objectContaining({ + blockNumber: currentBlock.number, + guardianAddress: walletAddress, + guardianIndex: 7, + stakingModuleId: 1, + }), + ); + // check that duplicates problem didn't trigger pause + expect(sendPauseMessage).toBeCalledTimes(0); + expect(sendUnvetMessage).toBeCalledTimes(1); + expect(sendUnvetMessage).toHaveBeenCalledWith( + expect.objectContaining({ + blockNumber: currentBlock.number, + guardianAddress: walletAddress, + guardianIndex: 7, + stakingModuleId: 2, + operatorIds: '0x0000000000000000', + vettedKeysByOperator: '0x00000000000000000000000000000000', + }), + ); + expect(unvetSigningKeys).toBeCalledTimes(1); + + // after deleting duplicates in staking module, + // council will resume deposits to module + const unusedKeysWithoutDuplicates = [ + { ...mockKey, moduleAddress: NOP_REGISTRY }, + ]; + + // Mine a new block + await providerService.provider.send('evm_mine', []); + const newBlock = await providerService.provider.getBlock('latest'); + // setup elBlockSnapshot + const newMeta = mockMeta(newBlock, newBlock.hash); + // setup /v1/modules + keysApiMockGetModules(keysApiService, stakingModules, newMeta); + // setup /v1/keys + keysApiMockGetAllKeys( + keysApiService, + unusedKeysWithoutDuplicates, + newMeta, + ); + + sendDepositMessage.mockClear(); + sendUnvetMessage.mockClear(); + sendPauseMessage.mockClear(); + + await guardianService.handleNewBlock(); + + await new Promise((res) => setTimeout(res, SLEEP_FOR_RESULT)); + + expect(sendDepositMessage).toBeCalledTimes(2); + expect(sendDepositMessage).toHaveBeenCalledWith( + expect.objectContaining({ + blockNumber: newBlock.number, + guardianAddress: walletAddress, + guardianIndex: 7, + stakingModuleId: 1, + }), + ); + expect(sendDepositMessage).toHaveBeenCalledWith( + expect.objectContaining({ + blockNumber: newBlock.number, + guardianAddress: walletAddress, + guardianIndex: 7, + stakingModuleId: 2, + }), + ); + expect(sendUnvetMessage).toBeCalledTimes(0); + expect(sendPauseMessage).toBeCalledTimes(0); + }, + TESTS_TIMEOUT, + ); + + test( + 'skip deposits for module if find duplicated key across operators of one modules', + async () => { + const currentBlock = await providerService.provider.getBlock('latest'); + const walletAddress = await getWalletAddress(); + + await levelDBService.setCachedEvents({ + data: [], + headers: { + startBlock: currentBlock.number, + endBlock: currentBlock.number, + }, + }); + + const duplicates = [ + { ...mockKey, index: 0, operatorIndex: 0 }, + { + ...mockKey, + index: 0, + operatorIndex: 1, + }, + ]; + + const meta = mockMeta(currentBlock, currentBlock.hash); + // setup /v1/modules + const stakingModules = [mockedModuleCurated, mockedModuleDvt]; + keysApiMockGetModules(keysApiService, stakingModules, meta); + // setup /v1/keys + keysApiMockGetAllKeys(keysApiService, duplicates, meta); + + await signingKeysRegistryService.setCachedEvents({ + data: [ + { + ...mockKeyEvent, + blockNumber: currentBlock.number - 4, + blockHash: 'somefakehash1', + operatorIndex: 0, + }, + // key of second operator was added later + { + ...mockKeyEvent, + blockNumber: currentBlock.number - 3, + blockHash: 'somefakehash2', + operatorIndex: 1, + }, + ], + headers: { + startBlock: currentBlock.number - 5, + endBlock: currentBlock.number - 1, + stakingModulesAddresses: [NOP_REGISTRY, SIMPLE_DVT], + }, + }); + + // Check that module was not paused + const routerContract = StakingRouterAbi__factory.connect( + STAKING_ROUTER, + providerService.provider, + ); + const isOnPause = await routerContract.getStakingModuleIsDepositsPaused( + 1, + ); + expect(isOnPause).toBe(false); + + await guardianService.handleNewBlock(); + + await new Promise((res) => setTimeout(res, SLEEP_FOR_RESULT)); + + // just skip on this iteration deposit for Curated staking module + expect(sendDepositMessage).toBeCalledTimes(1); + expect(sendDepositMessage).toHaveBeenCalledWith( + expect.objectContaining({ + blockNumber: currentBlock.number, + guardianAddress: walletAddress, + guardianIndex: 7, + stakingModuleId: 2, + }), + ); + // check that duplicates problem didnt trigger pause + expect(sendPauseMessage).toBeCalledTimes(0); + expect(sendUnvetMessage).toBeCalledTimes(1); + expect(sendUnvetMessage).toHaveBeenCalledWith( + expect.objectContaining({ + blockNumber: currentBlock.number, + guardianAddress: walletAddress, + guardianIndex: 7, + stakingModuleId: 1, + operatorIds: '0x0000000000000001', + vettedKeysByOperator: '0x00000000000000000000000000000000', + }), + ); + expect(unvetSigningKeys).toBeCalledTimes(1); + expect(sendPauseMessage).toBeCalledTimes(0); + + // after deleting duplicates in staking module, + // council will resume deposits to module + const noDuplicatesKeys = [{ ...mockKey, operatorIndex: 0 }]; + + // Mine a new block + await providerService.provider.send('evm_mine', []); + const newBlock = await providerService.provider.getBlock('latest'); + // setup elBlockSnapshot + const newMeta = mockMeta(newBlock, newBlock.hash); + // setup /v1/modules + keysApiMockGetModules(keysApiService, stakingModules, newMeta); + // setup /v1/keys + keysApiMockGetAllKeys(keysApiService, noDuplicatesKeys, newMeta); + + sendDepositMessage.mockClear(); + sendUnvetMessage.mockClear(); + sendPauseMessage.mockClear(); + + await guardianService.handleNewBlock(); + + await new Promise((res) => setTimeout(res, SLEEP_FOR_RESULT)); + + expect(sendDepositMessage).toBeCalledTimes(2); + expect(sendDepositMessage).toHaveBeenCalledWith( + expect.objectContaining({ + blockNumber: newBlock.number, + guardianAddress: walletAddress, + guardianIndex: 7, + stakingModuleId: 1, + }), + ); + + expect(sendDepositMessage).toHaveBeenCalledWith( + expect.objectContaining({ + blockNumber: newBlock.number, + guardianAddress: walletAddress, + guardianIndex: 7, + stakingModuleId: 2, + }), + ); + expect(sendPauseMessage).toBeCalledTimes(0); + expect(sendPauseMessage).toBeCalledTimes(0); + }, + TESTS_TIMEOUT, + ); + + test( + 'added unused keys for that deposit was already made', + async () => { + const currentBlock = await providerService.provider.getBlock('latest'); + const walletAddress = getWalletAddress(); + + await levelDBService.setCachedEvents({ + data: [], + headers: { + startBlock: currentBlock.number, + endBlock: currentBlock.number, + }, + }); + + const duplicates = [ + { ...mockKey, operatorIndex: 0, used: true }, + { + ...mockKey, + operatorIndex: 1, + used: false, + }, + ]; + + // setup elBlockSnapshot + const meta = mockMeta(currentBlock, currentBlock.hash); + // setup /v1/modules + const stakingModules = [mockedModuleCurated, mockedModuleDvt]; + keysApiMockGetModules(keysApiService, stakingModules, meta); + // setup /v1/keys + keysApiMockGetAllKeys(keysApiService, duplicates, meta); + + await signingKeysRegistryService.setCachedEvents({ + data: [], + headers: { + startBlock: currentBlock.number - 2, + endBlock: currentBlock.number - 1, + stakingModulesAddresses: [NOP_REGISTRY, SIMPLE_DVT], + }, + }); + + // Check that module was not paused + const routerContract = StakingRouterAbi__factory.connect( + STAKING_ROUTER, + providerService.provider, + ); + const isOnPause = await routerContract.getStakingModuleIsDepositsPaused( + 1, + ); + expect(isOnPause).toBe(false); + + await guardianService.handleNewBlock(); + + await new Promise((res) => setTimeout(res, SLEEP_FOR_RESULT)); + + // deposit will be skipped until unvetting + expect(sendDepositMessage).toBeCalledTimes(1); + expect(sendDepositMessage).toHaveBeenCalledWith( + expect.objectContaining({ + blockNumber: currentBlock.number, + guardianAddress: walletAddress, + guardianIndex: 7, + stakingModuleId: 2, + }), + ); + expect(sendPauseMessage).toBeCalledTimes(0); + expect(sendUnvetMessage).toBeCalledTimes(1); + expect(sendUnvetMessage).toHaveBeenCalledWith( + expect.objectContaining({ + blockNumber: currentBlock.number, + guardianAddress: walletAddress, + guardianIndex: 7, + stakingModuleId: 1, + operatorIds: '0x0000000000000001', + vettedKeysByOperator: '0x00000000000000000000000000000000', + }), + ); + expect(unvetSigningKeys).toBeCalledTimes(1); + + // after deleting duplicates in staking module, + // council will resume deposits to module + const noDuplicatesKeys = [{ ...mockKey, operatorIndex: 0, used: true }]; + // Mine a new block + await providerService.provider.send('evm_mine', []); + const newBlock = await providerService.provider.getBlock('latest'); + + const newMeta = mockMeta(newBlock, newBlock.hash); + // setup /v1/modules + keysApiMockGetModules(keysApiService, stakingModules, newMeta); + // setup /v1/keys + keysApiMockGetAllKeys(keysApiService, noDuplicatesKeys, newMeta); + + sendDepositMessage.mockClear(); + sendUnvetMessage.mockClear(); + sendPauseMessage.mockClear(); + + await guardianService.handleNewBlock(); + await new Promise((res) => setTimeout(res, SLEEP_FOR_RESULT)); + + expect(sendDepositMessage).toBeCalledTimes(2); + expect(sendDepositMessage).toHaveBeenCalledWith( + expect.objectContaining({ + blockNumber: newBlock.number, + guardianAddress: walletAddress, + guardianIndex: 7, + stakingModuleId: 1, + }), + ); + + expect(sendDepositMessage).toHaveBeenCalledWith( + expect.objectContaining({ + blockNumber: newBlock.number, + guardianAddress: walletAddress, + guardianIndex: 7, + stakingModuleId: 2, + }), + ); + expect(sendUnvetMessage).toBeCalledTimes(0); + expect(sendPauseMessage).toBeCalledTimes(0); + }, + TESTS_TIMEOUT, + ); + + test('adding not vetted duplicate will not set on soft pause module', async () => { + const currentBlock = await providerService.provider.getBlock('latest'); + const walletAddress = await getWalletAddress(); + + await levelDBService.setCachedEvents({ + data: [], + headers: { + startBlock: currentBlock.number, + endBlock: currentBlock.number, + }, + }); + + const duplicates = [ + { ...mockKey, index: 0, operatorIndex: 1, used: false, vetted: true }, + { + ...mockKey, + index: 1, + operatorIndex: 1, + used: false, + vetted: false, + }, + ]; + + // setup elBlockSnapshot + const meta = mockMeta(currentBlock, currentBlock.hash); + // setup /v1/modules + const stakingModules = [mockedModuleCurated, mockedModuleDvt]; + keysApiMockGetModules(keysApiService, stakingModules, meta); + // setup /v1/keys + keysApiMockGetAllKeys(keysApiService, duplicates, meta); + + await signingKeysRegistryService.setCachedEvents({ + data: [], + headers: { + startBlock: currentBlock.number - 2, + endBlock: currentBlock.number - 1, + stakingModulesAddresses: [NOP_REGISTRY, SIMPLE_DVT], + }, + }); + + await guardianService.handleNewBlock(); + await new Promise((res) => setTimeout(res, SLEEP_FOR_RESULT)); + + expect(sendDepositMessage).toBeCalledTimes(2); + expect(sendDepositMessage).toHaveBeenCalledWith( + expect.objectContaining({ + blockNumber: currentBlock.number, + guardianAddress: walletAddress, + guardianIndex: 7, + stakingModuleId: 1, + }), + ); + expect(sendDepositMessage).toHaveBeenCalledWith( + expect.objectContaining({ + blockNumber: currentBlock.number, + guardianAddress: walletAddress, + guardianIndex: 7, + stakingModuleId: 2, + }), + ); + expect(sendPauseMessage).toBeCalledTimes(0); + expect(unvetSigningKeys).toBeCalledTimes(0); + }); + + test( + 'skip deposits if cannot resolve duplicates', + async () => { + const currentBlock = await providerService.provider.getBlock('latest'); + const walletAddress = await getWalletAddress(); + + await levelDBService.setCachedEvents({ + data: [], + headers: { + startBlock: currentBlock.number, + endBlock: currentBlock.number, + }, + }); + + const duplicates = [ + { ...mockKey, moduleAddress: NOP_REGISTRY }, + { + ...mockKey, + moduleAddress: SIMPLE_DVT, + }, + ]; + + const meta = mockMeta(currentBlock, currentBlock.hash); + // setup /v1/modules + const stakingModules = [mockedModuleCurated, mockedModuleDvt]; + keysApiMockGetModules(keysApiService, stakingModules, meta); + // setup /v1/keys + keysApiMockGetAllKeys(keysApiService, duplicates, meta); + + await signingKeysRegistryService.setCachedEvents({ + data: [mockKeyEvent], + headers: { + startBlock: currentBlock.number - 2, + endBlock: currentBlock.number - 1, + stakingModulesAddresses: [NOP_REGISTRY, SIMPLE_DVT], + }, + }); + + // Check that module was not paused + const routerContract = StakingRouterAbi__factory.connect( + STAKING_ROUTER, + providerService.provider, + ); + const isOnPause = await routerContract.getStakingModuleIsDepositsPaused( + 1, + ); + expect(isOnPause).toBe(false); + + await guardianService.handleNewBlock(); + + await new Promise((res) => setTimeout(res, SLEEP_FOR_RESULT)); + + // just skip on this iteration deposit for Curated staking module + expect(sendDepositMessage).toBeCalledTimes(0); + // check that duplicates problem didnt trigger pause + expect(sendPauseMessage).toBeCalledTimes(0); + expect(sendUnvetMessage).toBeCalledTimes(0); + expect(unvetSigningKeys).toBeCalledTimes(0); + expect(sendPauseMessage).toBeCalledTimes(0); + + // after deleting duplicates in staking module, + // council will resume deposits to module + await providerService.provider.send('evm_mine', []); + const noDuplicatesKeys = [{ ...mockKey, moduleAddress: NOP_REGISTRY }]; + const newBlock = await providerService.provider.getBlock('latest'); + const newMeta = mockMeta(newBlock, newBlock.hash); + // setup /v1/modules + keysApiMockGetModules(keysApiService, stakingModules, newMeta); + // setup /v1/keys + keysApiMockGetAllKeys(keysApiService, noDuplicatesKeys, newMeta); + + sendDepositMessage.mockClear(); + sendUnvetMessage.mockClear(); + sendPauseMessage.mockClear(); + + await guardianService.handleNewBlock(); + + await new Promise((res) => setTimeout(res, SLEEP_FOR_RESULT)); + + expect(sendDepositMessage).toBeCalledTimes(2); + expect(sendDepositMessage).toHaveBeenCalledWith( + expect.objectContaining({ + blockNumber: newBlock.number, + guardianAddress: walletAddress, + guardianIndex: 7, + stakingModuleId: 1, + }), + ); + + expect(sendDepositMessage).toHaveBeenCalledWith( + expect.objectContaining({ + blockNumber: newBlock.number, + guardianAddress: walletAddress, + guardianIndex: 7, + stakingModuleId: 2, + }), + ); + expect(sendUnvetMessage).toBeCalledTimes(0); + expect(sendPauseMessage).toBeCalledTimes(0); + }, + TESTS_TIMEOUT, + ); + + test( + 'if duplicates in both modules, skip deposits for modules and unvet only for first on first iteration', + async () => { + const currentBlock = await providerService.provider.getBlock('latest'); + // await providerService.provider.send('evm_mine', []); + + // Set deposit cache + await levelDBService.setCachedEvents({ + data: [], + headers: { + startBlock: currentBlock.number, + endBlock: currentBlock.number, + }, + }); + + const earliestKey = { + ...mockKey, + operatorIndex: 0, + moduleAddress: NOP_REGISTRY, + index: 0, + }; + const duplicatesСurated = [ + earliestKey, + { ...mockKey, operatorIndex: 0, moduleAddress: NOP_REGISTRY, index: 1 }, + { ...mockKey, operatorIndex: 0, moduleAddress: NOP_REGISTRY, index: 2 }, + ]; + + const duplicatesSimpleDVT = [ + { + ...mockKey2, + operatorIndex: 0, + moduleAddress: SIMPLE_DVT, + index: 0, + }, + { + ...mockKey2, + operatorIndex: 0, + moduleAddress: SIMPLE_DVT, + index: 1, + }, + ]; + + // Mock Keys API + const vettedUnusedKeys = [...duplicatesСurated, ...duplicatesSimpleDVT]; + + // setup elBlockSnapshot + const meta = mockMeta(currentBlock, currentBlock.hash); + // setup /v1/modules + const stakingModules = [mockedModuleCurated, mockedModuleDvt]; + keysApiMockGetModules(keysApiService, stakingModules, meta); + // setup /v1/keys + keysApiMockGetAllKeys(keysApiService, vettedUnusedKeys, meta); + + // mock events cache to check + await signingKeysRegistryService.setCachedEvents({ + data: [], // dont need events in this test + headers: { + startBlock: currentBlock.number - 2, + endBlock: currentBlock.number, + stakingModulesAddresses: [NOP_REGISTRY, SIMPLE_DVT], + }, + }); + + // Check that module was not paused + const routerContract = StakingRouterAbi__factory.connect( + STAKING_ROUTER, + providerService.provider, + ); + const isOnPause = await routerContract.getStakingModuleIsDepositsPaused( + 1, + ); + expect(isOnPause).toBe(false); + await guardianService.handleNewBlock(); + + await new Promise((res) => setTimeout(res, SLEEP_FOR_RESULT)); + + const walletAddress = getWalletAddress(); + // just skip on this iteration deposit for Curated staking module + expect(sendDepositMessage).toBeCalledTimes(1); + expect(sendDepositMessage).toHaveBeenCalledWith( + expect.objectContaining({ + blockNumber: currentBlock.number, + guardianAddress: walletAddress, + guardianIndex: 7, + stakingModuleId: 2, + }), + ); + expect(sendPauseMessage).toBeCalledTimes(0); + expect(sendUnvetMessage).toBeCalledTimes(1); + expect(sendUnvetMessage).toHaveBeenCalledWith( + expect.objectContaining({ + blockNumber: currentBlock.number, + guardianAddress: walletAddress, + guardianIndex: 7, + stakingModuleId: 1, + operatorIds: '0x0000000000000000', + vettedKeysByOperator: '0x00000000000000000000000000000001', + }), + ); + }, + TESTS_TIMEOUT, + ); +}); diff --git a/test/duplicates.e2e-spec.ts b/test/duplicates.e2e-spec.ts new file mode 100644 index 00000000..7ce4d974 --- /dev/null +++ b/test/duplicates.e2e-spec.ts @@ -0,0 +1,521 @@ +import { + mockMeta, + keysApiMockGetModules, + keysApiMockGetAllKeys, + mockedModuleCurated, + mockedModuleDvt, +} from './helpers'; + +// Constants +import { + TESTS_TIMEOUT, + SLEEP_FOR_RESULT, + STAKING_ROUTER, + CHAIN_ID, + GANACHE_PORT, + NOP_REGISTRY, + SIMPLE_DVT, + UNLOCKED_ACCOUNTS_V2, + FORK_BLOCK_V2, + SECURITY_MODULE_V2, + SECURITY_MODULE_OWNER_V2, +} from './constants'; + +// Contract Factories +import { StakingRouterAbi__factory } from './../src/generated'; + +// Mock rabbit straight away +jest.mock('../src/transport/stomp/stomp.client.ts'); + +jest.setTimeout(10_000); + +import { + setupTestingModule, + closeServer, + initLevelDB, +} from './helpers/test-setup'; +import { getWalletAddress } from './helpers/deposit'; +import { SigningKeysRegistryService } from 'contracts/signing-keys-registry'; +import { DepositsRegistryStoreService } from 'contracts/deposits-registry/store'; +import { ProviderService } from 'provider'; +import { GuardianService } from 'guardian'; +import { KeysApiService } from 'keys-api/keys-api.service'; +import { Server } from 'ganache'; +import { GuardianMessageService } from 'guardian/guardian-message'; +import { SigningKeysStoreService as SignKeyLevelDBService } from 'contracts/signing-keys-registry/store'; +import { addGuardians } from './helpers/dsm'; +import { makeServer } from './server'; +import { DepositIntegrityCheckerService } from 'contracts/deposits-registry/sanity-checker'; +import { BlsService } from 'bls'; +import { mockKey, mockKey2, mockKeyEvent } from './helpers/keys-fixtures'; + +describe('ganache e2e tests', () => { + let server: Server<'ethereum'>; + let providerService: ProviderService; + let keysApiService: KeysApiService; + let guardianService: GuardianService; + let sendDepositMessage: jest.SpyInstance; + let sendPauseMessage: jest.SpyInstance; + let levelDBService: DepositsRegistryStoreService; + let signKeyLevelDBService: SignKeyLevelDBService; + let signingKeysRegistryService: SigningKeysRegistryService; + let guardianMessageService: GuardianMessageService; + let depositIntegrityCheckerService: DepositIntegrityCheckerService; + + const setupServer = async () => { + server = makeServer(FORK_BLOCK_V2, CHAIN_ID, UNLOCKED_ACCOUNTS_V2); + await server.listen(GANACHE_PORT); + }; + + const setupGuardians = async () => { + await addGuardians({ + securityModule: SECURITY_MODULE_V2, + securityModuleOwner: SECURITY_MODULE_OWNER_V2, + }); + }; + + const setupMocks = () => { + // broker messages + sendDepositMessage = jest + .spyOn(guardianMessageService, 'sendDepositMessage') + .mockImplementation(() => Promise.resolve()); + jest + .spyOn(guardianMessageService, 'pingMessageBroker') + .mockImplementation(() => Promise.resolve()); + sendPauseMessage = jest + .spyOn(guardianMessageService, 'sendPauseMessageV3') + .mockImplementation(() => Promise.resolve()); + + // deposit cache mocks + jest + .spyOn(depositIntegrityCheckerService, 'putEventsToTree') + .mockImplementation(() => Promise.resolve()); + jest + .spyOn(depositIntegrityCheckerService, 'checkLatestRoot') + .mockImplementation(() => Promise.resolve(true)); + jest + .spyOn(depositIntegrityCheckerService, 'checkFinalizedRoot') + .mockImplementation(() => Promise.resolve(true)); + }; + + const setupTestingServices = async (moduleRef) => { + // leveldb service + levelDBService = moduleRef.get(DepositsRegistryStoreService); + signKeyLevelDBService = moduleRef.get(SignKeyLevelDBService); + + await initLevelDB(levelDBService, signKeyLevelDBService); + + // deposit events related services + depositIntegrityCheckerService = moduleRef.get( + DepositIntegrityCheckerService, + ); + + const blsService = moduleRef.get(BlsService); + await blsService.onModuleInit(); + + // keys events service + signingKeysRegistryService = moduleRef.get(SigningKeysRegistryService); + + providerService = moduleRef.get(ProviderService); + + // keys api servies + keysApiService = moduleRef.get(KeysApiService); + + // rabbitmq message sending methods + guardianMessageService = moduleRef.get(GuardianMessageService); + + // main service that check keys and make decision + guardianService = moduleRef.get(GuardianService); + }; + + beforeEach(async () => { + await setupServer(); + await setupGuardians(); + const moduleRef = await setupTestingModule(); + await setupTestingServices(moduleRef); + setupMocks(); + }, 20000); + + afterEach(async () => { + await closeServer(server, levelDBService, signKeyLevelDBService); + }); + + test( + 'skip deposit if find duplicated key', + async () => { + const currentBlock = await providerService.provider.getBlock('latest'); + const walletAddress = await getWalletAddress(); + + await levelDBService.setCachedEvents({ + data: [], + headers: { + startBlock: currentBlock.number, + endBlock: currentBlock.number, + }, + }); + + await signingKeysRegistryService.setCachedEvents({ + data: [], + headers: { + startBlock: currentBlock.number, + endBlock: currentBlock.number, + stakingModulesAddresses: [NOP_REGISTRY, SIMPLE_DVT], + }, + }); + + // Keys api mock + const duplicates = [ + { ...mockKey, index: 0 }, + { ...mockKey, index: 1 }, + { ...mockKey, index: 2 }, + { ...mockKey2, moduleAddress: SIMPLE_DVT }, + ]; + + const meta = mockMeta(currentBlock, currentBlock.hash); + // setup /v1/modules + const stakingModules = [mockedModuleCurated, mockedModuleDvt]; + keysApiMockGetModules(keysApiService, stakingModules, meta); + // setup /v1/keys + keysApiMockGetAllKeys(keysApiService, duplicates, meta); + + // Check that module was not paused + const routerContract = StakingRouterAbi__factory.connect( + STAKING_ROUTER, + providerService.provider, + ); + const isOnPause = await routerContract.getStakingModuleIsDepositsPaused( + 1, + ); + expect(isOnPause).toBe(false); + + await guardianService.handleNewBlock(); + + await new Promise((res) => setTimeout(res, SLEEP_FOR_RESULT)); + + // just skip on this iteration deposit for Curated staking module + expect(sendDepositMessage).toBeCalledTimes(1); + expect(sendDepositMessage).toHaveBeenCalledWith( + expect.objectContaining({ + blockNumber: currentBlock.number, + guardianAddress: walletAddress, + guardianIndex: 7, + stakingModuleId: 2, + }), + ); + expect(sendPauseMessage).toBeCalledTimes(0); + + // after deleting duplicates in staking module, + // council will resume deposits to module + const unusedKeysWithoutDuplicates = [ + { ...mockKey, index: 0, moduleAddress: NOP_REGISTRY }, + { ...mockKey2, index: 0, moduleAddress: SIMPLE_DVT }, + ]; + + await providerService.provider.send('evm_mine', []); + const newBlock = await providerService.provider.getBlock('latest'); + const newMeta = mockMeta(newBlock, newBlock.hash); + // setup /v1/modules + keysApiMockGetModules(keysApiService, stakingModules, newMeta); + // setup /v1/keys + keysApiMockGetAllKeys( + keysApiService, + unusedKeysWithoutDuplicates, + newMeta, + ); + + sendDepositMessage.mockClear(); + + await guardianService.handleNewBlock(); + await new Promise((res) => setTimeout(res, SLEEP_FOR_RESULT)); + + expect(sendDepositMessage).toBeCalledTimes(2); + expect(sendDepositMessage).toHaveBeenCalledWith( + expect.objectContaining({ + blockNumber: newBlock.number, + guardianAddress: walletAddress, + guardianIndex: 7, + stakingModuleId: 1, + }), + ); + expect(sendDepositMessage).toHaveBeenCalledWith( + expect.objectContaining({ + blockNumber: newBlock.number, + guardianAddress: walletAddress, + guardianIndex: 7, + stakingModuleId: 2, + }), + ); + }, + TESTS_TIMEOUT, + ); + + test( + 'skip deposit if find duplicated key in another staking module', + async () => { + const currentBlock = await providerService.provider.getBlock('latest'); + const walletAddress = await getWalletAddress(); + + await levelDBService.setCachedEvents({ + data: [], + headers: { + startBlock: currentBlock.number, + endBlock: currentBlock.number, + }, + }); + + // Keys api mock + const duplicates = [ + { ...mockKey, index: 0, moduleAddress: NOP_REGISTRY }, + { ...mockKey, index: 0, moduleAddress: SIMPLE_DVT }, + ]; + + const meta = mockMeta(currentBlock, currentBlock.hash); + // setup /v1/modules + const stakingModules = [mockedModuleCurated, mockedModuleDvt]; + keysApiMockGetModules(keysApiService, stakingModules, meta); + // setup /v1/keys + keysApiMockGetAllKeys(keysApiService, duplicates, meta); + + await signingKeysRegistryService.setCachedEvents({ + data: [ + mockKeyEvent, + { + ...mockKeyEvent, + moduleAddress: SIMPLE_DVT, + blockNumber: mockKeyEvent.blockNumber + 1, + }, + ], + headers: { + startBlock: currentBlock.number - 2, + endBlock: currentBlock.number - 1, + stakingModulesAddresses: [NOP_REGISTRY, SIMPLE_DVT], + }, + }); + + // Check that module was not paused + const routerContract = StakingRouterAbi__factory.connect( + STAKING_ROUTER, + providerService.provider, + ); + const isOnPause = await routerContract.getStakingModuleIsDepositsPaused( + 1, + ); + expect(isOnPause).toBe(false); + + await guardianService.handleNewBlock(); + await new Promise((res) => setTimeout(res, SLEEP_FOR_RESULT)); + + expect(sendDepositMessage).toBeCalledTimes(1); + expect(sendDepositMessage).toHaveBeenCalledWith( + expect.objectContaining({ + blockNumber: currentBlock.number, + guardianAddress: walletAddress, + guardianIndex: 7, + stakingModuleId: 1, + }), + ); + expect(sendPauseMessage).toBeCalledTimes(0); + + await providerService.provider.send('evm_mine', []); + // after deleting duplicates in staking module, + // council will resume deposits to module + const unusedKeysWithoutDuplicates = [ + { ...mockKey, index: 0, moduleAddress: NOP_REGISTRY }, + { + ...mockKey2, + moduleAddress: SIMPLE_DVT, + }, + ]; + const newBlock = await providerService.provider.getBlock('latest'); + const newMeta = mockMeta(newBlock, newBlock.hash); + // setup /v1/modules + keysApiMockGetModules(keysApiService, stakingModules, newMeta); + // setup /v1/keys + keysApiMockGetAllKeys( + keysApiService, + unusedKeysWithoutDuplicates, + newMeta, + ); + + sendDepositMessage.mockClear(); + + await guardianService.handleNewBlock(); + await new Promise((res) => setTimeout(res, SLEEP_FOR_RESULT)); + + expect(sendDepositMessage).toBeCalledTimes(2); + expect(sendDepositMessage).toHaveBeenCalledWith( + expect.objectContaining({ + blockNumber: newBlock.number, + guardianAddress: walletAddress, + guardianIndex: 7, + stakingModuleId: 1, + }), + ); + + expect(sendDepositMessage).toHaveBeenCalledWith( + expect.objectContaining({ + blockNumber: newBlock.number, + guardianAddress: walletAddress, + guardianIndex: 7, + stakingModuleId: 2, + }), + ); + }, + TESTS_TIMEOUT, + ); + + test( + 'added unused keys for that deposit was already made', + async () => { + const currentBlock = await providerService.provider.getBlock('latest'); + const walletAddress = await getWalletAddress(); + + await levelDBService.setCachedEvents({ + data: [], + headers: { + startBlock: currentBlock.number, + endBlock: currentBlock.number, + }, + }); + + // Keys api mock + const duplicates = [ + { ...mockKey, used: true }, + { + ...mockKey, + moduleAddress: SIMPLE_DVT, + used: false, + }, + ]; + + const meta = mockMeta(currentBlock, currentBlock.hash); + // setup /v1/modules + const stakingModules = [mockedModuleCurated, mockedModuleDvt]; + keysApiMockGetModules(keysApiService, stakingModules, meta); + // setup /v1/keys + keysApiMockGetAllKeys(keysApiService, duplicates, meta); + + await signingKeysRegistryService.setCachedEvents({ + data: [], + headers: { + startBlock: currentBlock.number - 2, + endBlock: currentBlock.number - 1, + stakingModulesAddresses: [NOP_REGISTRY, SIMPLE_DVT], + }, + }); + + // Check that module was not paused + const routerContract = StakingRouterAbi__factory.connect( + STAKING_ROUTER, + providerService.provider, + ); + const isOnPause = await routerContract.getStakingModuleIsDepositsPaused( + 1, + ); + expect(isOnPause).toBe(false); + + await guardianService.handleNewBlock(); + await new Promise((res) => setTimeout(res, SLEEP_FOR_RESULT)); + + expect(sendDepositMessage).toBeCalledTimes(1); + expect(sendPauseMessage).toBeCalledTimes(0); + + // after deleting duplicates in staking module, + // council will resume deposits to module + await providerService.provider.send('evm_mine', []); + const newBlock = await providerService.provider.getBlock('latest'); + const keysWithoutDulicates = [{ ...mockKey, used: true }]; + const newMeta = mockMeta(newBlock, newBlock.hash); + // setup /v1/modules + keysApiMockGetModules(keysApiService, stakingModules, newMeta); + // setup /v1/keys + keysApiMockGetAllKeys(keysApiService, keysWithoutDulicates, newMeta); + + sendDepositMessage.mockClear(); + + await guardianService.handleNewBlock(); + await new Promise((res) => setTimeout(res, SLEEP_FOR_RESULT)); + + expect(sendDepositMessage).toBeCalledTimes(2); + expect(sendDepositMessage).toHaveBeenCalledWith( + expect.objectContaining({ + blockNumber: newBlock.number, + guardianAddress: walletAddress, + guardianIndex: 7, + stakingModuleId: 1, + }), + ); + expect(sendDepositMessage).toHaveBeenCalledWith( + expect.objectContaining({ + blockNumber: newBlock.number, + guardianAddress: walletAddress, + guardianIndex: 7, + stakingModuleId: 2, + }), + ); + }, + TESTS_TIMEOUT, + ); + + test('adding not vetted duplicate will not set on soft pause module', async () => { + const currentBlock = await providerService.provider.getBlock('latest'); + const walletAddress = await getWalletAddress(); + + await levelDBService.setCachedEvents({ + data: [], + headers: { + startBlock: currentBlock.number, + endBlock: currentBlock.number, + }, + }); + + // Keys api mock + const duplicates = [ + { ...mockKey, index: 0, operatorIndex: 0, used: false, vetted: true }, + { + ...mockKey, + index: 1, + operatorIndex: 0, + used: false, + vetted: false, + }, + ]; + + const meta = mockMeta(currentBlock, currentBlock.hash); + // setup /v1/modules + const stakingModules = [mockedModuleCurated, mockedModuleDvt]; + keysApiMockGetModules(keysApiService, stakingModules, meta); + // setup /v1/keys + keysApiMockGetAllKeys(keysApiService, duplicates, meta); + + await signingKeysRegistryService.setCachedEvents({ + data: [], + headers: { + startBlock: currentBlock.number - 2, + endBlock: currentBlock.number - 1, + stakingModulesAddresses: [NOP_REGISTRY, SIMPLE_DVT], + }, + }); + + await guardianService.handleNewBlock(); + await new Promise((res) => setTimeout(res, SLEEP_FOR_RESULT)); + + expect(sendDepositMessage).toBeCalledTimes(2); + expect(sendDepositMessage).toHaveBeenCalledWith( + expect.objectContaining({ + blockNumber: currentBlock.number, + guardianAddress: walletAddress, + guardianIndex: 7, + stakingModuleId: 1, + }), + ); + expect(sendDepositMessage).toHaveBeenCalledWith( + expect.objectContaining({ + blockNumber: currentBlock.number, + guardianAddress: walletAddress, + guardianIndex: 7, + stakingModuleId: 2, + }), + ); + }); +}); diff --git a/test/front-run-v3.e2e-spec.ts b/test/front-run-v3.e2e-spec.ts new file mode 100644 index 00000000..660857ef --- /dev/null +++ b/test/front-run-v3.e2e-spec.ts @@ -0,0 +1,816 @@ +// Global Helpers +import { toHexString } from '@chainsafe/ssz'; + +// Helpers +import { + keysApiMockGetAllKeys, + keysApiMockGetModules, + mockedKeysApiFind, + mockedModuleCurated, + mockedModuleDvt, + mockMeta, +} from './helpers'; + +// Constants +import { + TESTS_TIMEOUT, + SLEEP_FOR_RESULT, + LIDO_WC, + BAD_WC, + CHAIN_ID, + FORK_BLOCK, + GANACHE_PORT, + sk, + pk, + NOP_REGISTRY, + SIMPLE_DVT, + UNLOCKED_ACCOUNTS, + SECURITY_MODULE, + NO_PRIVKEY_MESSAGE, +} from './constants'; + +// Contract Factories +import { SecurityAbi__factory } from '../src/generated'; + +// BLS helpers + +// App modules and services +import { + setupTestingModule, + closeServer, + initLevelDB, +} from './helpers/test-setup'; +import { SecurityService } from 'contracts/security'; +import { GuardianService } from 'guardian'; +import { KeysApiService } from 'keys-api/keys-api.service'; +import { ProviderService } from 'provider'; +import { Server } from 'ganache'; +import { DepositsRegistryStoreService } from 'contracts/deposits-registry/store'; +import { SigningKeysStoreService as SignKeyLevelDBService } from 'contracts/signing-keys-registry/store'; +import { GuardianMessageService } from 'guardian/guardian-message'; +import { SigningKeysRegistryService } from 'contracts/signing-keys-registry'; +import { makeServer } from './server'; +import { addGuardians } from './helpers/dsm'; +import { DepositIntegrityCheckerService } from 'contracts/deposits-registry/sanity-checker'; +import { BlsService } from 'bls'; +import { makeDeposit, signDeposit } from './helpers/deposit'; +import { mockKey, mockKey2 } from './helpers/keys-fixtures'; +import { ethers } from 'ethers'; + +// Mock rabbit straight away +jest.mock('../src/transport/stomp/stomp.client.ts'); + +jest.setTimeout(10_000); + +describe('ganache e2e tests', () => { + let server: Server<'ethereum'>; + let providerService: ProviderService; + let keysApiService: KeysApiService; + let guardianService: GuardianService; + let securityService: SecurityService; + let levelDBService: DepositsRegistryStoreService; + let signKeyLevelDBService: SignKeyLevelDBService; + let guardianMessageService: GuardianMessageService; + let signingKeysRegistryService: SigningKeysRegistryService; + let depositIntegrityCheckerService: DepositIntegrityCheckerService; + + // method mocks + let sendDepositMessage: jest.SpyInstance; + let sendPauseMessage: jest.SpyInstance; + let sendUnvetMessage: jest.SpyInstance; + let unvetSigningKeys: jest.SpyInstance; + + const setupServer = async () => { + server = makeServer(FORK_BLOCK, CHAIN_ID, UNLOCKED_ACCOUNTS); + await server.listen(GANACHE_PORT); + }; + + const setupGuardians = async () => { + await addGuardians(); + }; + + const setupTestingServices = async (moduleRef) => { + // leveldb service + levelDBService = moduleRef.get(DepositsRegistryStoreService); + signKeyLevelDBService = moduleRef.get(SignKeyLevelDBService); + + await initLevelDB(levelDBService, signKeyLevelDBService); + + // deposit events related services + depositIntegrityCheckerService = moduleRef.get( + DepositIntegrityCheckerService, + ); + + const blsService = moduleRef.get(BlsService); + await blsService.onModuleInit(); + + // keys events service + signingKeysRegistryService = moduleRef.get(SigningKeysRegistryService); + + providerService = moduleRef.get(ProviderService); + + // dsm methods and council sign services + securityService = moduleRef.get(SecurityService); + + // keys api servies + keysApiService = moduleRef.get(KeysApiService); + + // rabbitmq message sending methods + guardianMessageService = moduleRef.get(GuardianMessageService); + + // main service that check keys and make decision + guardianService = moduleRef.get(GuardianService); + }; + + const setupMocks = () => { + // broker messages + sendDepositMessage = jest + .spyOn(guardianMessageService, 'sendDepositMessage') + .mockImplementation(() => Promise.resolve()); + jest + .spyOn(guardianMessageService, 'pingMessageBroker') + .mockImplementation(() => Promise.resolve()); + sendPauseMessage = jest + .spyOn(guardianMessageService, 'sendPauseMessageV3') + .mockImplementation(() => Promise.resolve()); + sendUnvetMessage = jest + .spyOn(guardianMessageService, 'sendUnvetMessage') + .mockImplementation(() => Promise.resolve()); + + // deposit cache mocks + jest + .spyOn(depositIntegrityCheckerService, 'putEventsToTree') + .mockImplementation(() => Promise.resolve()); + jest + .spyOn(depositIntegrityCheckerService, 'checkLatestRoot') + .mockImplementation(() => Promise.resolve(true)); + jest + .spyOn(depositIntegrityCheckerService, 'checkFinalizedRoot') + .mockImplementation(() => Promise.resolve(true)); + + // mock unvetting method of contract + // as we dont use real keys api and work with fixtures of operators and keys + // we cant make real unvetting + unvetSigningKeys = jest + .spyOn(securityService, 'unvetSigningKeys') + .mockImplementation(() => Promise.resolve(null as any)); + }; + + beforeEach(async () => { + await setupServer(); + await setupGuardians(); + const moduleRef = await setupTestingModule(); + await setupTestingServices(moduleRef); + setupMocks(); + }, 20000); + + afterEach(async () => { + await closeServer(server, levelDBService, signKeyLevelDBService); + }); + + test( + 'node operator deposit frontrun, 2 modules in staking router', + async () => { + const currentBlock = await providerService.provider.getBlock('latest'); + // create correct sign for deposit message for pk + const { signature } = signDeposit(pk, sk, LIDO_WC); + + // Keys api mock + // all keys in keys api on current block state + const keys = [ + { + key: toHexString(pk), + depositSignature: toHexString(signature), + operatorIndex: 0, + used: false, + index: 1, + moduleAddress: NOP_REGISTRY, + vetted: true, + }, + { + ...mockKey2, + index: 0, + moduleAddress: SIMPLE_DVT, + operatorIndex: 0, + vetted: true, + }, + ]; + + // add in deposit cache event of deposit on key with lido creds + await levelDBService.setCachedEvents({ + data: [], + headers: { + startBlock: currentBlock.number, + endBlock: currentBlock.number, + }, + }); + + // dont set events for keys as we check this cache only in case of duplicated keys + await signingKeysRegistryService.setCachedEvents({ + data: [], + headers: { + startBlock: currentBlock.number, + endBlock: currentBlock.number, + stakingModulesAddresses: [NOP_REGISTRY, SIMPLE_DVT], + }, + }); + + // Attempt to front run + const { depositData: theftDepositData } = signDeposit(pk, sk, BAD_WC); + const { wallet } = await makeDeposit(theftDepositData, providerService); + + // Mock Keys API again on new block + const newBlock = await providerService.provider.getBlock('latest'); + + // setup elBlockSnapshot + const meta = mockMeta(newBlock, newBlock.hash); + // setup /v1/modules + const stakingModules = [mockedModuleCurated, mockedModuleDvt]; + keysApiMockGetModules(keysApiService, stakingModules, meta); + // setup /v1/keys + keysApiMockGetAllKeys(keysApiService, keys, meta); + + // Run a cycle and wait for possible changes + await guardianService.handleNewBlock(); + await new Promise((res) => setTimeout(res, SLEEP_FOR_RESULT)); + + // soft pause for 1 module, sign deposit for 2 + expect(sendPauseMessage).toBeCalledTimes(0); + expect(sendUnvetMessage).toBeCalledTimes(1); + expect(sendUnvetMessage).toHaveBeenCalledWith( + expect.objectContaining({ + blockNumber: newBlock.number, + guardianAddress: wallet.address, + guardianIndex: 7, + stakingModuleId: 1, + operatorIds: '0x0000000000000000', + vettedKeysByOperator: '0x00000000000000000000000000000001', + }), + ); + expect(unvetSigningKeys).toBeCalledTimes(1); + expect(sendDepositMessage).toBeCalledTimes(1); + expect(sendDepositMessage).toHaveBeenCalledWith( + expect.objectContaining({ + blockNumber: newBlock.number, + guardianAddress: wallet.address, + guardianIndex: 7, + stakingModuleId: 2, + }), + ); + }, + TESTS_TIMEOUT, + ); + + test( + 'failed 1eth deposit attack to stop deposits', + async () => { + const currentBlock = await providerService.provider.getBlock('latest'); + + await levelDBService.setCachedEvents({ + data: [], + headers: { + startBlock: currentBlock.number, + endBlock: currentBlock.number, + }, + }); + + await signingKeysRegistryService.setCachedEvents({ + data: [], + headers: { + startBlock: currentBlock.number, + endBlock: currentBlock.number, + stakingModulesAddresses: [NOP_REGISTRY, SIMPLE_DVT], + }, + }); + + const { signature: goodSign } = signDeposit(pk, sk, LIDO_WC, 32000000000); + + const { depositData: depositData } = signDeposit( + pk, + sk, + LIDO_WC, + 1000000000, + ); + await makeDeposit(depositData, providerService, 1); + + // Mock Keys API + const keys = [ + { + key: toHexString(pk), + depositSignature: toHexString(goodSign), + operatorIndex: 0, + used: false, + index: 0, + moduleAddress: NOP_REGISTRY, + vetted: true, + }, + { + ...mockKey2, + index: 0, + moduleAddress: SIMPLE_DVT, + operatorIndex: 0, + vetted: true, + }, + ]; + + // Mock Keys API again on new block + const newBlock = await providerService.provider.getBlock('latest'); + // setup elBlockSnapshot + const meta = mockMeta(newBlock, newBlock.hash); + // setup /v1/modules + const stakingModules = [mockedModuleCurated, mockedModuleDvt]; + keysApiMockGetModules(keysApiService, stakingModules, meta); + // setup /v1/keys + keysApiMockGetAllKeys(keysApiService, keys, meta); + + // Run a cycle and wait for possible changes + await guardianService.handleNewBlock(); + await new Promise((res) => setTimeout(res, SLEEP_FOR_RESULT)); + + const securityContract = SecurityAbi__factory.connect( + SECURITY_MODULE, + providerService.provider, + ); + + const isOnPause = await securityContract.isDepositsPaused(); + + expect(isOnPause).toBe(false); + expect(sendPauseMessage).toBeCalledTimes(0); + expect(sendDepositMessage).toBeCalledTimes(2); + expect(sendUnvetMessage).toBeCalledTimes(0); + expect(unvetSigningKeys).toBeCalledTimes(0); + }, + TESTS_TIMEOUT, + ); + + test( + 'failed 1eth deposit attack to stop deposits with a wrong signature and wc', + async () => { + const currentBlock = await providerService.provider.getBlock('latest'); + + await levelDBService.setCachedEvents({ + data: [], + headers: { + startBlock: currentBlock.number, + endBlock: currentBlock.number, + }, + }); + + await signingKeysRegistryService.setCachedEvents({ + data: [], + headers: { + startBlock: currentBlock.number, + endBlock: currentBlock.number, + stakingModulesAddresses: [NOP_REGISTRY, SIMPLE_DVT], + }, + }); + + const { signature: goodSign } = signDeposit(pk, sk, LIDO_WC, 32000000000); + + // wrong deposit, fill not set on soft pause deposits + const { signature: weirdSign } = signDeposit(pk, sk, BAD_WC, 0); + const { depositData } = signDeposit(pk, sk, BAD_WC, 1000000000); + await makeDeposit( + { ...depositData, signature: weirdSign }, + providerService, + 1, + ); + + const keys = [ + { + key: toHexString(pk), + depositSignature: toHexString(goodSign), + operatorIndex: 0, + used: false, + index: 0, + moduleAddress: NOP_REGISTRY, + vetted: true, + }, + ]; + + // Mock Keys API again on new block + const newBlock = await providerService.provider.getBlock('latest'); + // setup elBlockSnapshot + const meta = mockMeta(newBlock, newBlock.hash); + // setup /v1/modules + const stakingModules = [mockedModuleCurated, mockedModuleDvt]; + keysApiMockGetModules(keysApiService, stakingModules, meta); + // setup /v1/keys + keysApiMockGetAllKeys(keysApiService, keys, meta); + + // Run a cycle and wait for possible changes + await guardianService.handleNewBlock(); + await new Promise((res) => setTimeout(res, SLEEP_FOR_RESULT)); + expect(sendPauseMessage).toBeCalledTimes(0); + expect(sendDepositMessage).toBeCalledTimes(2); + expect(sendUnvetMessage).toBeCalledTimes(0); + expect(unvetSigningKeys).toBeCalledTimes(0); + }, + TESTS_TIMEOUT, + ); + + test( + 'good scenario', + async () => { + const currentBlock = await providerService.provider.getBlock('latest'); + + await levelDBService.setCachedEvents({ + data: [], + headers: { + startBlock: currentBlock.number, + endBlock: currentBlock.number, + }, + }); + + await signingKeysRegistryService.setCachedEvents({ + data: [], + headers: { + startBlock: currentBlock.number, + endBlock: currentBlock.number, + stakingModulesAddresses: [NOP_REGISTRY, SIMPLE_DVT], + }, + }); + + const { signature: goodSign, depositData } = signDeposit( + pk, + sk, + LIDO_WC, + 32000000000, + ); + + const { wallet } = await makeDeposit(depositData, providerService); + + const keys = [ + { + key: toHexString(pk), + depositSignature: toHexString(goodSign), + operatorIndex: 0, + used: false, + index: 0, + moduleAddress: NOP_REGISTRY, + vetted: true, + }, + ]; + + // Mock Keys API again on new block + const newBlock = await providerService.provider.getBlock('latest'); + // setup elBlockSnapshot + const meta = mockMeta(newBlock, newBlock.hash); + // setup /v1/modules + const stakingModules = [mockedModuleCurated, mockedModuleDvt]; + keysApiMockGetModules(keysApiService, stakingModules, meta); + // setup /v1/keys + keysApiMockGetAllKeys(keysApiService, keys, meta); + + // Check if the service is ok and ready to go + await guardianService.handleNewBlock(); + await new Promise((res) => setTimeout(res, SLEEP_FOR_RESULT)); + + expect(sendDepositMessage).toBeCalledTimes(2); + expect(sendDepositMessage).toHaveBeenCalledWith( + expect.objectContaining({ + blockNumber: newBlock.number, + guardianAddress: wallet.address, + guardianIndex: 7, + stakingModuleId: 1, + }), + ); + expect(sendDepositMessage).toHaveBeenCalledWith( + expect.objectContaining({ + blockNumber: newBlock.number, + guardianAddress: wallet.address, + guardianIndex: 7, + stakingModuleId: 2, + }), + ); + expect(sendUnvetMessage).toBeCalledTimes(0); + expect(unvetSigningKeys).toBeCalledTimes(0); + }, + TESTS_TIMEOUT, + ); + + test( + 'inconsistent kapi requests data', + async () => { + const currentBlock = await providerService.provider.getBlock('latest'); + await levelDBService.setCachedEvents({ + data: [], + headers: { + startBlock: currentBlock.number, + endBlock: currentBlock.number, + }, + }); + + // Mock Keys API + const keys = [mockKey]; + // setup elBlockSnapshot + const meta = mockMeta(currentBlock, currentBlock.hash); + // setup /v1/modules + const stakingModules = [mockedModuleCurated, mockedModuleDvt]; + keysApiMockGetModules(keysApiService, stakingModules, meta); + // setup /v1/keys + await providerService.provider.send('evm_mine', []); + const newBlock = await providerService.provider.getBlock('latest'); + const newMeta = mockMeta(newBlock, newBlock.hash); + keysApiMockGetAllKeys(keysApiService, keys, newMeta); + + await guardianService.handleNewBlock(); + + expect(sendDepositMessage).toBeCalledTimes(0); + expect(sendPauseMessage).toBeCalledTimes(0); + expect(sendUnvetMessage).toBeCalledTimes(0); + expect(unvetSigningKeys).toBeCalledTimes(0); + }, + TESTS_TIMEOUT, + ); + + test( + 'historical front-run', + async () => { + const currentBlock = await providerService.provider.getBlock('latest'); + + const { signature: lidoSign } = signDeposit(pk, sk); + const { signature: theftDepositSign } = signDeposit(pk, sk, BAD_WC); + + const keys = [ + { + key: toHexString(pk), + depositSignature: toHexString(lidoSign), + operatorIndex: 0, + used: true, + index: 0, + moduleAddress: NOP_REGISTRY, + vetted: true, + }, + ]; + + // setup elBlockSnapshot + const meta = mockMeta(currentBlock, currentBlock.hash); + // setup /v1/modules + const stakingModules = [mockedModuleCurated, mockedModuleDvt]; + keysApiMockGetModules(keysApiService, stakingModules, meta); + // setup /v1/keys + keysApiMockGetAllKeys(keysApiService, keys, meta); + mockedKeysApiFind(keysApiService, keys, meta); + + await levelDBService.setCachedEvents({ + data: [ + { + valid: true, + pubkey: toHexString(pk), + amount: '32000000000', + wc: BAD_WC, + signature: toHexString(theftDepositSign), + tx: '0x122', + blockHash: '0x123456', + blockNumber: currentBlock.number - 1, + logIndex: 1, + depositCount: 1, + depositDataRoot: new Uint8Array(), + index: '', + }, + { + valid: true, + pubkey: toHexString(pk), + amount: '32000000000', + wc: LIDO_WC, + signature: toHexString(lidoSign), + tx: '0x123', + blockHash: currentBlock.hash, + blockNumber: currentBlock.number, + logIndex: 1, + depositCount: 2, + depositDataRoot: new Uint8Array(), + index: '', + }, + ], + headers: { + startBlock: currentBlock.number - 2, + endBlock: currentBlock.number, + }, + }); + + await guardianService.handleNewBlock(); + + await new Promise((res) => setTimeout(res, SLEEP_FOR_RESULT)); + + expect(sendPauseMessage).toBeCalledTimes(1); + expect(sendDepositMessage).toBeCalledTimes(0); + expect(sendUnvetMessage).toBeCalledTimes(0); + expect(unvetSigningKeys).toBeCalledTimes(0); + + const securityContract = SecurityAbi__factory.connect( + SECURITY_MODULE, + providerService.provider, + ); + + const isOnPause = await securityContract.isDepositsPaused(); + expect(isOnPause).toBe(true); + }, + TESTS_TIMEOUT, + ); + + test( + 'should not trigger pause for front-run attempt with non-Lido WC and Lido WC deposits when key is unused', + async () => { + const currentBlock = await providerService.provider.getBlock('latest'); + + await signingKeysRegistryService.setCachedEvents({ + data: [], + headers: { + startBlock: currentBlock.number, + endBlock: currentBlock.number, + stakingModulesAddresses: [NOP_REGISTRY, SIMPLE_DVT], + }, + }); + + const { signature: lidoSign } = signDeposit(pk, sk); + const { signature: theftDepositSign } = signDeposit(pk, sk, BAD_WC); + + if (!process.env.WALLET_PRIVATE_KEY) throw new Error(NO_PRIVKEY_MESSAGE); + const wallet = new ethers.Wallet(process.env.WALLET_PRIVATE_KEY); + + const keys = [ + { + key: toHexString(pk), + depositSignature: toHexString(lidoSign), + operatorIndex: 0, + used: false, + index: 0, + moduleAddress: NOP_REGISTRY, + vetted: true, + }, + ]; + + // setup elBlockSnapshot + const meta = mockMeta(currentBlock, currentBlock.hash); + // setup /v1/modules + const stakingModules = [mockedModuleCurated, mockedModuleDvt]; + keysApiMockGetModules(keysApiService, stakingModules, meta); + // setup /v1/keys + keysApiMockGetAllKeys(keysApiService, keys, meta); + mockedKeysApiFind(keysApiService, keys, meta); + + await levelDBService.setCachedEvents({ + data: [ + { + valid: true, + pubkey: toHexString(pk), + amount: '32000000000', + wc: BAD_WC, + signature: toHexString(theftDepositSign), + tx: '0x122', + blockHash: '0x123456', + blockNumber: currentBlock.number - 1, + logIndex: 1, + depositCount: 1, + depositDataRoot: new Uint8Array(), + index: '', + }, + { + valid: true, + pubkey: toHexString(pk), + amount: '32000000000', + wc: LIDO_WC, + signature: toHexString(lidoSign), + tx: '0x123', + blockHash: currentBlock.hash, + blockNumber: currentBlock.number, + logIndex: 1, + depositCount: 2, + depositDataRoot: new Uint8Array(), + index: '', + }, + ], + headers: { + startBlock: currentBlock.number - 2, + endBlock: currentBlock.number, + }, + }); + + await guardianService.handleNewBlock(); + + await new Promise((res) => setTimeout(res, SLEEP_FOR_RESULT)); + + expect(sendPauseMessage).toBeCalledTimes(0); + + const securityContract = SecurityAbi__factory.connect( + SECURITY_MODULE, + providerService.provider, + ); + + const isOnPause = await securityContract.isDepositsPaused(); + + expect(isOnPause).toBe(false); + + expect(sendPauseMessage).toBeCalledTimes(0); + expect(sendUnvetMessage).toBeCalledTimes(1); + expect(sendUnvetMessage).toHaveBeenCalledWith( + expect.objectContaining({ + blockNumber: currentBlock.number, + guardianAddress: wallet.address, + guardianIndex: 7, + stakingModuleId: 1, + operatorIds: '0x0000000000000000', + vettedKeysByOperator: '0x00000000000000000000000000000000', + }), + ); + expect(unvetSigningKeys).toBeCalledTimes(1); + expect(sendDepositMessage).toBeCalledTimes(1); + expect(sendDepositMessage).toHaveBeenCalledWith( + expect.objectContaining({ + blockNumber: currentBlock.number, + guardianAddress: wallet.address, + guardianIndex: 7, + stakingModuleId: 2, + }), + ); + }, + TESTS_TIMEOUT, + ); + + test( + 'frontrun of unvetted key will not set module on soft pause', + async () => { + const currentBlock = await providerService.provider.getBlock('latest'); + + await levelDBService.setCachedEvents({ + data: [], + headers: { + startBlock: currentBlock.number, + endBlock: currentBlock.number, + }, + }); + + await signingKeysRegistryService.setCachedEvents({ + data: [], + headers: { + startBlock: currentBlock.number, + endBlock: currentBlock.number, + stakingModulesAddresses: [NOP_REGISTRY, SIMPLE_DVT], + }, + }); + + const { signature: goodSign } = signDeposit(pk, sk, LIDO_WC, 32000000000); + + const { depositData: theftDepositData } = signDeposit(pk, sk, BAD_WC); + const { wallet } = await makeDeposit(theftDepositData, providerService); + + const unvettedKeys = [ + { + key: toHexString(pk), + depositSignature: toHexString(goodSign), + operatorIndex: 0, + used: false, + index: 0, + moduleAddress: NOP_REGISTRY, + vetted: false, + }, + ]; + + // Mock Keys API again on new block + const newBlock = await providerService.provider.getBlock('latest'); + // setup elBlockSnapshot + const meta = mockMeta(newBlock, newBlock.hash); + // setup /v1/modules + const stakingModules = [mockedModuleCurated, mockedModuleDvt]; + keysApiMockGetModules(keysApiService, stakingModules, meta); + // setup /v1/keys + keysApiMockGetAllKeys(keysApiService, unvettedKeys, meta); + + // Check if the service is ok and ready to go + // the same scenario as "failed 1eth deposit attack to stop deposits" + await guardianService.handleNewBlock(); + await new Promise((res) => setTimeout(res, SLEEP_FOR_RESULT)); + + expect(sendDepositMessage).toBeCalledTimes(2); + expect(sendDepositMessage).toHaveBeenCalledWith( + expect.objectContaining({ + blockNumber: newBlock.number, + guardianAddress: wallet.address, + guardianIndex: 7, + stakingModuleId: 1, + }), + ); + expect(sendDepositMessage).toHaveBeenCalledWith( + expect.objectContaining({ + blockNumber: newBlock.number, + guardianAddress: wallet.address, + guardianIndex: 7, + stakingModuleId: 2, + }), + ); + expect(unvetSigningKeys).toBeCalledTimes(0); + expect(sendPauseMessage).toBeCalledTimes(0); + + const securityContract = SecurityAbi__factory.connect( + SECURITY_MODULE, + providerService.provider, + ); + + const isOnPause = await securityContract.isDepositsPaused(); + expect(isOnPause).toBe(false); + }, + TESTS_TIMEOUT, + ); +}); diff --git a/test/front-run.e2e-spec.ts b/test/front-run.e2e-spec.ts new file mode 100644 index 00000000..b929fc27 --- /dev/null +++ b/test/front-run.e2e-spec.ts @@ -0,0 +1,722 @@ +// Global Helpers +import { toHexString } from '@chainsafe/ssz'; + +// Helpers +import { + mockedKeysApiFind, + keysApiMockGetAllKeys, + keysApiMockGetModules, + mockedModuleCurated, + mockedModuleDvt, + mockMeta, +} from './helpers'; + +// Constants +import { + TESTS_TIMEOUT, + SLEEP_FOR_RESULT, + STAKING_ROUTER, + LIDO_WC, + BAD_WC, + CHAIN_ID, + GANACHE_PORT, + sk, + pk, + NOP_REGISTRY, + SIMPLE_DVT, + SECURITY_MODULE_V2, + UNLOCKED_ACCOUNTS_V2, + FORK_BLOCK_V2, + SECURITY_MODULE_OWNER_V2, +} from './constants'; + +// Contract Factories +import { StakingRouterAbi__factory } from './../src/generated'; + +// BLS helpers + +// App modules and services +import { + setupTestingModule, + closeServer, + initLevelDB, +} from './helpers/test-setup'; +import { GuardianService } from 'guardian'; +import { KeysApiService } from 'keys-api/keys-api.service'; +import { ProviderService } from 'provider'; +import { Server } from 'ganache'; +import { DepositsRegistryStoreService } from 'contracts/deposits-registry/store'; +import { SigningKeysStoreService as SignKeyLevelDBService } from 'contracts/signing-keys-registry/store'; +import { GuardianMessageService } from 'guardian/guardian-message'; +import { SigningKeysRegistryService } from 'contracts/signing-keys-registry'; +import { makeServer } from './server'; +import { addGuardians } from './helpers/dsm'; +import { DepositIntegrityCheckerService } from 'contracts/deposits-registry/sanity-checker'; +import { BlsService } from 'bls'; +import { makeDeposit, signDeposit } from './helpers/deposit'; +import { mockKey, mockKey2 } from './helpers/keys-fixtures'; + +// Mock rabbit straight away +jest.mock('../src/transport/stomp/stomp.client.ts'); + +jest.setTimeout(10_000); + +describe('ganache e2e tests', () => { + let server: Server<'ethereum'>; + let providerService: ProviderService; + let keysApiService: KeysApiService; + let guardianService: GuardianService; + let sendDepositMessage: jest.SpyInstance; + let sendPauseMessage: jest.SpyInstance; + let levelDBService: DepositsRegistryStoreService; + let signKeyLevelDBService: SignKeyLevelDBService; + let guardianMessageService: GuardianMessageService; + let signingKeysRegistryService: SigningKeysRegistryService; + let depositIntegrityCheckerService: DepositIntegrityCheckerService; + + const setupServer = async () => { + server = makeServer(FORK_BLOCK_V2, CHAIN_ID, UNLOCKED_ACCOUNTS_V2); + await server.listen(GANACHE_PORT); + }; + + const setupGuardians = async () => { + await addGuardians({ + securityModule: SECURITY_MODULE_V2, + securityModuleOwner: SECURITY_MODULE_OWNER_V2, + }); + }; + + const setupTestingServices = async (moduleRef) => { + // leveldb service + levelDBService = moduleRef.get(DepositsRegistryStoreService); + signKeyLevelDBService = moduleRef.get(SignKeyLevelDBService); + + await initLevelDB(levelDBService, signKeyLevelDBService); + + // deposit events related services + depositIntegrityCheckerService = moduleRef.get( + DepositIntegrityCheckerService, + ); + + const blsService = moduleRef.get(BlsService); + await blsService.onModuleInit(); + + // keys events service + signingKeysRegistryService = moduleRef.get(SigningKeysRegistryService); + + providerService = moduleRef.get(ProviderService); + // keys api servies + keysApiService = moduleRef.get(KeysApiService); + + // rabbitmq message sending methods + guardianMessageService = moduleRef.get(GuardianMessageService); + + // main service that check keys and make decision + guardianService = moduleRef.get(GuardianService); + }; + + const setupMocks = () => { + // broker messages + sendDepositMessage = jest + .spyOn(guardianMessageService, 'sendDepositMessage') + .mockImplementation(() => Promise.resolve()); + jest + .spyOn(guardianMessageService, 'pingMessageBroker') + .mockImplementation(() => Promise.resolve()); + sendPauseMessage = jest + .spyOn(guardianMessageService, 'sendPauseMessageV2') + .mockImplementation(() => Promise.resolve()); + + // deposit cache mocks + jest + .spyOn(depositIntegrityCheckerService, 'putEventsToTree') + .mockImplementation(() => Promise.resolve()); + jest + .spyOn(depositIntegrityCheckerService, 'checkLatestRoot') + .mockImplementation(() => Promise.resolve(true)); + jest + .spyOn(depositIntegrityCheckerService, 'checkFinalizedRoot') + .mockImplementation(() => Promise.resolve(true)); + }; + + beforeEach(async () => { + await setupServer(); + await setupGuardians(); + const moduleRef = await setupTestingModule(); + await setupTestingServices(moduleRef); + setupMocks(); + }, 20000); + + afterEach(async () => { + await closeServer(server, levelDBService, signKeyLevelDBService); + }); + + test( + 'node operator deposit frontrun, 2 modules in staking router', + async () => { + const currentBlock = await providerService.provider.getBlock('latest'); + // create correct sign for deposit message for pk + const { signature } = signDeposit(pk, sk, LIDO_WC); + + // Keys api mock + const keys = [ + { + key: toHexString(pk), + depositSignature: toHexString(signature), + operatorIndex: 0, + used: false, + index: 0, + moduleAddress: NOP_REGISTRY, + vetted: true, + }, + { + ...mockKey2, + index: 0, + moduleAddress: SIMPLE_DVT, + operatorIndex: 0, + vetted: true, + }, + ]; + + // add in deposit cache event of deposit on key with lido creds + await levelDBService.setCachedEvents({ + data: [], + headers: { + startBlock: currentBlock.number, + endBlock: currentBlock.number, + }, + }); + + // dont set events for keys as we check this cache only in case of duplicated keys + await signingKeysRegistryService.setCachedEvents({ + data: [], + headers: { + startBlock: currentBlock.number, + endBlock: currentBlock.number, + stakingModulesAddresses: [NOP_REGISTRY, SIMPLE_DVT], + }, + }); + + // Attempt to front run + const { depositData: theftDepositData } = signDeposit(pk, sk, BAD_WC); + const { wallet } = await makeDeposit(theftDepositData, providerService); + + // Mock Keys API again on new block + const newBlock = await providerService.provider.getBlock('latest'); + // setup elBlockSnapshot + const meta = mockMeta(newBlock, newBlock.hash); + // setup /v1/modules + const stakingModules = [mockedModuleCurated, mockedModuleDvt]; + keysApiMockGetModules(keysApiService, stakingModules, meta); + // setup /v1/keys + keysApiMockGetAllKeys(keysApiService, keys, meta); + + // Run a cycle and wait for possible changes + await guardianService.handleNewBlock(); + await new Promise((res) => setTimeout(res, SLEEP_FOR_RESULT)); + + // soft pause for 1 module, sign deposit for 2 + expect(sendPauseMessage).toBeCalledTimes(0); + expect(sendDepositMessage).toBeCalledTimes(1); + expect(sendDepositMessage).toHaveBeenCalledWith( + expect.objectContaining({ + blockNumber: newBlock.number, + guardianAddress: wallet.address, + guardianIndex: 7, + stakingModuleId: 2, + }), + ); + }, + TESTS_TIMEOUT, + ); + + test( + 'failed 1eth deposit attack to stop deposits', + async () => { + const currentBlock = await providerService.provider.getBlock('latest'); + + await levelDBService.setCachedEvents({ + data: [], + headers: { + startBlock: currentBlock.number, + endBlock: currentBlock.number, + }, + }); + + await signingKeysRegistryService.setCachedEvents({ + data: [], + headers: { + startBlock: currentBlock.number, + endBlock: currentBlock.number, + stakingModulesAddresses: [NOP_REGISTRY, SIMPLE_DVT], + }, + }); + + const { signature: goodSign } = signDeposit(pk, sk, LIDO_WC, 32000000000); + + const { depositData: depositData } = signDeposit( + pk, + sk, + LIDO_WC, + 1000000000, + ); + await makeDeposit(depositData, providerService, 1); + + // Mock Keys API + const keys = [ + { + key: toHexString(pk), + depositSignature: toHexString(goodSign), + operatorIndex: 0, + used: false, + index: 0, + moduleAddress: NOP_REGISTRY, + vetted: true, + }, + { + ...mockKey2, + index: 0, + moduleAddress: SIMPLE_DVT, + operatorIndex: 0, + vetted: true, + }, + ]; + + // Mock Keys API again on new block + const newBlock = await providerService.provider.getBlock('latest'); + // setup elBlockSnapshot + const meta = mockMeta(newBlock, newBlock.hash); + // setup /v1/modules + const stakingModules = [mockedModuleCurated, mockedModuleDvt]; + keysApiMockGetModules(keysApiService, stakingModules, meta); + // setup /v1/keys + keysApiMockGetAllKeys(keysApiService, keys, meta); + + // Run a cycle and wait for possible changes + await guardianService.handleNewBlock(); + await new Promise((res) => setTimeout(res, SLEEP_FOR_RESULT)); + // Check if on pause now + const routerContract = StakingRouterAbi__factory.connect( + STAKING_ROUTER, + providerService.provider, + ); + + const isOnPause = await routerContract.getStakingModuleIsDepositsPaused( + 1, + ); + expect(isOnPause).toBe(false); + expect(sendPauseMessage).toBeCalledTimes(0); + expect(sendDepositMessage).toBeCalledTimes(2); + }, + TESTS_TIMEOUT, + ); + + test( + 'failed 1eth deposit attack to stop deposits with a wrong signature and wc', + async () => { + const currentBlock = await providerService.provider.getBlock('latest'); + + await levelDBService.setCachedEvents({ + data: [], + headers: { + startBlock: currentBlock.number, + endBlock: currentBlock.number, + }, + }); + + await signingKeysRegistryService.setCachedEvents({ + data: [], + headers: { + startBlock: currentBlock.number, + endBlock: currentBlock.number, + stakingModulesAddresses: [NOP_REGISTRY, SIMPLE_DVT], + }, + }); + + const { signature: goodSign } = signDeposit(pk, sk, LIDO_WC, 32000000000); + + // wrong deposit, fill not set on soft pause deposits + const { signature: weirdSign } = signDeposit(pk, sk, BAD_WC, 0); + const { depositData } = signDeposit(pk, sk, BAD_WC, 1000000000); + await makeDeposit( + { ...depositData, signature: weirdSign }, + providerService, + 1, + ); + + const keys = [ + { + key: toHexString(pk), + depositSignature: toHexString(goodSign), + operatorIndex: 0, + used: false, + index: 0, + moduleAddress: NOP_REGISTRY, + vetted: true, + }, + ]; + + // Mock Keys API again on new block + const newBlock = await providerService.provider.getBlock('latest'); + // setup elBlockSnapshot + const meta = mockMeta(newBlock, newBlock.hash); + // setup /v1/modules + const stakingModules = [mockedModuleCurated, mockedModuleDvt]; + keysApiMockGetModules(keysApiService, stakingModules, meta); + // setup /v1/keys + keysApiMockGetAllKeys(keysApiService, keys, meta); + + // Run a cycle and wait for possible changes + await guardianService.handleNewBlock(); + await new Promise((res) => setTimeout(res, SLEEP_FOR_RESULT)); + expect(sendPauseMessage).toBeCalledTimes(0); + expect(sendDepositMessage).toBeCalledTimes(2); + }, + TESTS_TIMEOUT, + ); + + test( + 'good scenario', + async () => { + const currentBlock = await providerService.provider.getBlock('latest'); + + await levelDBService.setCachedEvents({ + data: [], + headers: { + startBlock: currentBlock.number, + endBlock: currentBlock.number, + }, + }); + + await signingKeysRegistryService.setCachedEvents({ + data: [], + headers: { + startBlock: currentBlock.number, + endBlock: currentBlock.number, + stakingModulesAddresses: [NOP_REGISTRY, SIMPLE_DVT], + }, + }); + + const { signature: goodSign, depositData } = signDeposit( + pk, + sk, + LIDO_WC, + 32000000000, + ); + + const { wallet } = await makeDeposit(depositData, providerService); + + const keys = [ + { + key: toHexString(pk), + depositSignature: toHexString(goodSign), + operatorIndex: 0, + used: true, + index: 0, + moduleAddress: NOP_REGISTRY, + vetted: true, + }, + ]; + + // Mock Keys API again on new block + const newBlock = await providerService.provider.getBlock('latest'); + // setup elBlockSnapshot + const meta = mockMeta(newBlock, newBlock.hash); + // setup /v1/modules + const stakingModules = [mockedModuleCurated, mockedModuleDvt]; + keysApiMockGetModules(keysApiService, stakingModules, meta); + // setup /v1/keys + keysApiMockGetAllKeys(keysApiService, keys, meta); + + // Check if the service is ok and ready to go + // the same scenario as "failed 1eth deposit attack to stop deposits" + await guardianService.handleNewBlock(); + await new Promise((res) => setTimeout(res, SLEEP_FOR_RESULT)); + + expect(sendDepositMessage).toBeCalledTimes(2); + expect(sendDepositMessage).toHaveBeenCalledWith( + expect.objectContaining({ + blockNumber: newBlock.number, + guardianAddress: wallet.address, + guardianIndex: 7, + stakingModuleId: 1, + }), + ); + expect(sendDepositMessage).toHaveBeenCalledWith( + expect.objectContaining({ + blockNumber: newBlock.number, + + guardianAddress: wallet.address, + guardianIndex: 7, + stakingModuleId: 2, + }), + ); + + // Check if on pause now + const routerContract = StakingRouterAbi__factory.connect( + STAKING_ROUTER, + providerService.provider, + ); + const isOnPause = await routerContract.getStakingModuleIsDepositsPaused( + 1, + ); + expect(isOnPause).toBe(false); + const isOnPause2 = await routerContract.getStakingModuleIsDepositsPaused( + 2, + ); + expect(isOnPause2).toBe(false); + }, + TESTS_TIMEOUT, + ); + + test( + 'inconsistent kapi requests data', + async () => { + const currentBlock = await providerService.provider.getBlock('latest'); + await levelDBService.setCachedEvents({ + data: [], + headers: { + startBlock: currentBlock.number, + endBlock: currentBlock.number, + }, + }); + + const keys = [mockKey]; + + // Mock Keys API + // setup elBlockSnapshot + const meta = mockMeta(currentBlock, currentBlock.hash); + // setup /v1/modules + const stakingModules = [mockedModuleCurated, mockedModuleDvt]; + keysApiMockGetModules(keysApiService, stakingModules, meta); + // setup /v1/keys + await providerService.provider.send('evm_mine', []); + const newBlock = await providerService.provider.getBlock('latest'); + const newMeta = mockMeta(newBlock, newBlock.hash); + keysApiMockGetAllKeys(keysApiService, keys, newMeta); + + await guardianService.handleNewBlock(); + + expect(sendDepositMessage).toBeCalledTimes(0); + expect(sendPauseMessage).toBeCalledTimes(0); + }, + TESTS_TIMEOUT, + ); + + test( + 'frontrun of unvetted key will not set module on soft pause', + async () => { + const currentBlock = await providerService.provider.getBlock('latest'); + + await levelDBService.setCachedEvents({ + data: [], + headers: { + startBlock: currentBlock.number, + endBlock: currentBlock.number, + }, + }); + + await signingKeysRegistryService.setCachedEvents({ + data: [], + headers: { + startBlock: currentBlock.number, + endBlock: currentBlock.number, + stakingModulesAddresses: [NOP_REGISTRY, SIMPLE_DVT], + }, + }); + + const { signature: goodSign } = signDeposit(pk, sk, LIDO_WC, 32000000000); + + const { depositData: theftDepositData } = signDeposit(pk, sk, BAD_WC); + const { wallet } = await makeDeposit(theftDepositData, providerService); + + const unvettedKeys = [ + { + key: toHexString(pk), + depositSignature: toHexString(goodSign), + operatorIndex: 0, + used: false, + index: 0, + moduleAddress: NOP_REGISTRY, + vetted: false, + }, + ]; + + // Mock Keys API again on new block + const newBlock = await providerService.provider.getBlock('latest'); + // setup elBlockSnapshot + const meta = mockMeta(newBlock, newBlock.hash); + // setup /v1/modules + const stakingModules = [mockedModuleCurated, mockedModuleDvt]; + keysApiMockGetModules(keysApiService, stakingModules, meta); + // setup /v1/keys + keysApiMockGetAllKeys(keysApiService, unvettedKeys, meta); + + // Check if the service is ok and ready to go + // the same scenario as "failed 1eth deposit attack to stop deposits" + await guardianService.handleNewBlock(); + await new Promise((res) => setTimeout(res, SLEEP_FOR_RESULT)); + + expect(sendDepositMessage).toBeCalledTimes(2); + expect(sendDepositMessage).toHaveBeenCalledWith( + expect.objectContaining({ + blockNumber: newBlock.number, + guardianAddress: wallet.address, + guardianIndex: 7, + stakingModuleId: 1, + }), + ); + expect(sendDepositMessage).toHaveBeenCalledWith( + expect.objectContaining({ + blockNumber: newBlock.number, + guardianAddress: wallet.address, + guardianIndex: 7, + stakingModuleId: 2, + }), + ); + expect(sendPauseMessage).toBeCalledTimes(0); + + // Check if on pause now + const routerContract = StakingRouterAbi__factory.connect( + STAKING_ROUTER, + providerService.provider, + ); + const isOnPause = await routerContract.getStakingModuleIsDepositsPaused( + 1, + ); + expect(isOnPause).toBe(false); + }, + TESTS_TIMEOUT, + ); + + test( + 'historical front-run', + async () => { + const currentBlock = await providerService.provider.getBlock('latest'); + + await signingKeysRegistryService.setCachedEvents({ + data: [], + headers: { + startBlock: currentBlock.number, + endBlock: currentBlock.number, + stakingModulesAddresses: [NOP_REGISTRY, SIMPLE_DVT], + }, + }); + + const { signature: lidoSign } = signDeposit(pk, sk); + const { signature: theftDepositSign } = signDeposit(pk, sk, BAD_WC); + + const keys = [ + { + key: toHexString(pk), + depositSignature: toHexString(lidoSign), + operatorIndex: 0, + used: true, + index: 0, + moduleAddress: NOP_REGISTRY, + vetted: true, + }, + ]; + + await levelDBService.setCachedEvents({ + data: [ + { + valid: true, + pubkey: toHexString(pk), + amount: '32000000000', + wc: BAD_WC, + signature: toHexString(theftDepositSign), + tx: '0x122', + blockHash: '0x123456', + blockNumber: currentBlock.number - 1, + logIndex: 1, + depositCount: 1, + depositDataRoot: new Uint8Array(), + index: '', + }, + { + valid: true, + pubkey: toHexString(pk), + amount: '32000000000', + wc: LIDO_WC, + signature: toHexString(lidoSign), + tx: '0x123', + blockHash: currentBlock.hash, + blockNumber: currentBlock.number, + logIndex: 1, + depositCount: 2, + depositDataRoot: new Uint8Array(), + index: '', + }, + ], + headers: { + startBlock: currentBlock.number - 2, + endBlock: currentBlock.number, + }, + }); + + // setup elBlockSnapshot + const meta = mockMeta(currentBlock, currentBlock.hash); + // setup /v1/modules + const stakingModules = [mockedModuleCurated, mockedModuleDvt]; + keysApiMockGetModules(keysApiService, stakingModules, meta); + // setup /v1/keys + keysApiMockGetAllKeys(keysApiService, keys, meta); + mockedKeysApiFind(keysApiService, keys, meta); + + await guardianService.handleNewBlock(); + + await new Promise((res) => setTimeout(res, SLEEP_FOR_RESULT)); + + const routerContract = StakingRouterAbi__factory.connect( + STAKING_ROUTER, + providerService.provider, + ); + const isOnPause = await routerContract.getStakingModuleIsDepositsPaused( + 1, + ); + + expect(isOnPause).toBe(true); + + const isOnPause2Module = + await routerContract.getStakingModuleIsDepositsPaused(2); + + expect(isOnPause2Module).toBe(false); + expect(sendDepositMessage).toBeCalledTimes(0); + expect(sendPauseMessage).toBeCalledTimes(1); + + // Mine a new block + await providerService.provider.send('evm_mine', []); + + // // Your assertions after mining the block + const newBlock = await providerService.provider.getBlock('latest'); + + // setup elBlockSnapshot + const newMeta = mockMeta(newBlock, newBlock.hash); + keysApiMockGetModules(keysApiService, stakingModules, newMeta); + // setup /v1/keys + keysApiMockGetAllKeys(keysApiService, keys, newMeta); + mockedKeysApiFind(keysApiService, keys, newMeta); + + sendPauseMessage.mockClear(); + + await guardianService.handleNewBlock(); + + await new Promise((res) => setTimeout(res, SLEEP_FOR_RESULT)); + + const isOnPause1NextIter = + await routerContract.getStakingModuleIsDepositsPaused(1); + + expect(isOnPause1NextIter).toBe(true); + + const isOnPause2NextIter = + await routerContract.getStakingModuleIsDepositsPaused(2); + + expect(isOnPause2NextIter).toBe(true); + + expect(sendDepositMessage).toBeCalledTimes(0); + expect(sendPauseMessage).toBeCalledTimes(1); + }, + TESTS_TIMEOUT, + ); +}); diff --git a/test/guardian-balance-monitoring.e2e-spec.ts b/test/guardian-balance-monitoring.e2e-spec.ts new file mode 100644 index 00000000..75b9d47e --- /dev/null +++ b/test/guardian-balance-monitoring.e2e-spec.ts @@ -0,0 +1,266 @@ +// Mocking and Setup +jest.mock('../src/transport/stomp/stomp.client.ts'); +jest.setTimeout(10_000); + +// External Libraries +import { Server } from 'ganache'; + +// Helper Functions and Mocks +import { + keysApiMockGetAllKeys, + keysApiMockGetModules, + mockedModuleCurated, + mockedModuleDvt, + mockMeta, +} from './helpers'; + +import { + setupTestingModule, + closeServer, + initLevelDB, +} from './helpers/test-setup'; + +import { makeServer } from './server'; + +// Constants +import { + SLEEP_FOR_RESULT, + CHAIN_ID, + GANACHE_PORT, + NOP_REGISTRY, + SIMPLE_DVT, + UNLOCKED_ACCOUNTS, + FORK_BLOCK, +} from './constants'; + +// Contract and Service Imports +import { SecurityService } from 'contracts/security'; +import { GuardianService } from 'guardian'; +import { KeysApiService } from 'keys-api/keys-api.service'; +import { ProviderService } from 'provider'; +import { GuardianMessageService } from 'guardian/guardian-message'; +import { DepositsRegistryStoreService } from 'contracts/deposits-registry/store'; +import { SigningKeysStoreService as SignKeyLevelDBService } from 'contracts/signing-keys-registry/store'; +import { SigningKeysRegistryService } from 'contracts/signing-keys-registry'; +import { BlsService } from 'bls'; +import { DepositIntegrityCheckerService } from 'contracts/deposits-registry/sanity-checker'; + +// Test Data +import { mockKey, mockKey2 } from './helpers/keys-fixtures'; +import { addGuardians, setGuardianBalance } from './helpers/dsm'; +import { RegistryKey } from 'keys-api/interfaces/RegistryKey'; +import { ethers } from 'ethers'; + +describe('Guardian balance monitoring test', () => { + let server: Server<'ethereum'>; + let providerService: ProviderService; + let keysApiService: KeysApiService; + let guardianService: GuardianService; + let levelDBService: DepositsRegistryStoreService; + let signKeyLevelDBService: SignKeyLevelDBService; + let guardianMessageService: GuardianMessageService; + let signingKeysRegistryService: SigningKeysRegistryService; + let depositIntegrityCheckerService: DepositIntegrityCheckerService; + let securityService: SecurityService; + + // mocks + let sendDepositMessage: jest.SpyInstance; + let sendUnvetMessage: jest.SpyInstance; + let unvetSigningKeys: jest.SpyInstance; + + beforeEach(async () => { + await setupServer(); + await setupGuardians(); + const moduleRef = await setupTestingModule(); + await setupTestingServices(moduleRef); + setupMocks(); + }, 20000); + + afterEach(async () => { + await closeServer(server, levelDBService, signKeyLevelDBService); + }); + + test('should check unvetting will not happen if guardian balance lower critical threshold', async () => { + await setBalance('0.2'); + + const currentBlock = await providerService.getBlock(); + await setupDefaultCache(currentBlock.number); + setupKAPIWithInvalidSignProblem(currentBlock); + + await guardianService.handleNewBlock(); + await waitForProcessing(); + + expect(unvetSigningKeys).toBeCalledTimes(0); + expect(sendUnvetMessage).toBeCalledTimes(1); + expect(sendDepositMessage).toBeCalledTimes(0); + + // at next iteration it should unvet keys of second module + }); + + test('should check unvetting will happen if guardian balance is sufficient', async () => { + await setBalance('1'); + + const currentBlock = await providerService.getBlock(); + await setupDefaultCache(currentBlock.number); + setupKAPIWithInvalidSignProblem(currentBlock); + + await guardianService.handleNewBlock(); + await waitForProcessing(); + + expect(unvetSigningKeys).toBeCalledTimes(1); + expect(sendUnvetMessage).toBeCalledTimes(1); + expect(sendDepositMessage).toBeCalledTimes(0); + + // at next iteration it should unvet keys of second module + }); + + // Helper functions + + async function setBalance(eth: string) { + await setGuardianBalance(eth); + await waitForProcessing(); + } + + const setupDefaultCache = async (blockNumber) => { + await levelDBService.setCachedEvents({ + data: [], + headers: { + startBlock: blockNumber, + endBlock: blockNumber, + }, + }); + + await signingKeysRegistryService.setCachedEvents({ + data: [], + headers: { + startBlock: blockNumber, + endBlock: blockNumber, + stakingModulesAddresses: [NOP_REGISTRY, SIMPLE_DVT], + }, + }); + }; + + const setupKAPIWithInvalidSignProblem = (block: ethers.providers.Block) => { + // keys fixtures + const norKeyWithWrongSign: RegistryKey = { + ...mockKey, + depositSignature: + '0x8bf4401a354de243a3716ee2efc0bde1ded56a40e2943ac7c50290bec37e935d6170b21e7c0872f203199386143ef12612a1488a8e9f1cdf1229c382f29c326bcbf6ed6a87d8fbfe0df87dacec6632fc4709d9d338f4cf81e861d942c23bba1e', + vetted: true, + }; + const dvtKey: RegistryKey = { + ...mockKey2, + index: 1, + used: false, + operatorIndex: 0, + moduleAddress: SIMPLE_DVT, + vetted: true, + }; + const dvtKey2 = { ...dvtKey, index: 2 }; + + // setup elBlockSnapshot + const meta = mockMeta(block, block.hash); + + // setup /v1/modules + const stakingModules = [mockedModuleCurated, mockedModuleDvt]; + keysApiMockGetModules(keysApiService, stakingModules, meta); + + // setup /v1/keys + const keys = [norKeyWithWrongSign, dvtKey, dvtKey2]; + keysApiMockGetAllKeys(keysApiService, keys, meta); + }; + + async function waitForProcessing() { + await new Promise((res) => setTimeout(res, SLEEP_FOR_RESULT)); + } + + const setupServer = async () => { + server = makeServer(FORK_BLOCK, CHAIN_ID, UNLOCKED_ACCOUNTS); + await server.listen(GANACHE_PORT); + }; + + const setupGuardians = async () => { + await addGuardians(); + }; + + const setupTestingServices = async (moduleRef) => { + await initializeLevelDBServices(moduleRef); + initializeDepositServices(moduleRef); + await initializeBlsService(moduleRef); + initializeKeyEventServices(moduleRef); + initializeProviders(moduleRef); + initializeMessagingServices(moduleRef); + initializeGuardianService(moduleRef); + }; + + const initializeLevelDBServices = async (moduleRef) => { + levelDBService = moduleRef.get(DepositsRegistryStoreService); + signKeyLevelDBService = moduleRef.get(SignKeyLevelDBService); + await initLevelDB(levelDBService, signKeyLevelDBService); + }; + + const initializeDepositServices = (moduleRef) => { + depositIntegrityCheckerService = moduleRef.get( + DepositIntegrityCheckerService, + ); + }; + + const initializeKeyEventServices = (moduleRef) => { + signingKeysRegistryService = moduleRef.get(SigningKeysRegistryService); + }; + + const initializeProviders = (moduleRef) => { + providerService = moduleRef.get(ProviderService); + securityService = moduleRef.get(SecurityService); + keysApiService = moduleRef.get(KeysApiService); + }; + + const initializeMessagingServices = (moduleRef) => { + guardianMessageService = moduleRef.get(GuardianMessageService); + }; + + const initializeBlsService = async (moduleRef) => { + const blsService = moduleRef.get(BlsService); + await blsService.onModuleInit(); + }; + + const initializeGuardianService = (moduleRef) => { + guardianService = moduleRef.get(GuardianService); + }; + + const setupMocks = () => { + mockBrokerMessages(); + mockDepositCacheMethods(); + mockUnvettingMethod(); + }; + + const mockBrokerMessages = () => { + sendDepositMessage = jest + .spyOn(guardianMessageService, 'sendDepositMessage') + .mockImplementation(() => Promise.resolve()); + jest + .spyOn(guardianMessageService, 'pingMessageBroker') + .mockImplementation(() => Promise.resolve()); + sendUnvetMessage = jest + .spyOn(guardianMessageService, 'sendUnvetMessage') + .mockImplementation(() => Promise.resolve()); + }; + + const mockDepositCacheMethods = () => { + jest + .spyOn(depositIntegrityCheckerService, 'putEventsToTree') + .mockImplementation(() => Promise.resolve()); + jest + .spyOn(depositIntegrityCheckerService, 'checkLatestRoot') + .mockImplementation(() => Promise.resolve(true)); + jest + .spyOn(depositIntegrityCheckerService, 'checkFinalizedRoot') + .mockImplementation(() => Promise.resolve(true)); + }; + + const mockUnvettingMethod = () => { + unvetSigningKeys = jest + .spyOn(securityService, 'unvetSigningKeys') + .mockImplementation(() => Promise.resolve(null as any)); + }; +}); diff --git a/test/helpers/deposit.ts b/test/helpers/deposit.ts new file mode 100644 index 00000000..896ddee4 --- /dev/null +++ b/test/helpers/deposit.ts @@ -0,0 +1,61 @@ +import { fromHexString } from '@chainsafe/ssz'; +import { DEPOSIT_CONTRACT, LIDO_WC, NO_PRIVKEY_MESSAGE } from '../constants'; +import { computeRoot } from './computeDomain'; +import { DepositData } from 'bls/bls.containers'; +import { ethers } from 'ethers'; +import { ProviderService } from 'provider'; +import { DepositAbi__factory } from 'generated'; +import { SecretKey } from '@chainsafe/blst'; + +export function signDeposit( + pk: Uint8Array, + sk: SecretKey, + wc = LIDO_WC, + amountGwei = 32000000000, +): { depositData: any; signature: Uint8Array } { + const depositMessage = { + pubkey: pk, + withdrawalCredentials: fromHexString(wc), + amount: amountGwei, + }; + const signingRoot = computeRoot(depositMessage); + const sign = sk.sign(signingRoot).toBytes(); + + const depositData = { + ...depositMessage, + signature: sign, + }; + + return { depositData: depositData, signature: sign }; +} + +export async function makeDeposit( + depositData: any, + providerService: ProviderService, + amount = 32, +): Promise<{ wallet: ethers.Wallet; depositSign: Uint8Array }> { + const depositDataRoot = DepositData.hashTreeRoot(depositData); + + if (!process.env.WALLET_PRIVATE_KEY) throw new Error(NO_PRIVKEY_MESSAGE); + const wallet = new ethers.Wallet(process.env.WALLET_PRIVATE_KEY); + + // Make a deposit + const signer = wallet.connect(providerService.provider); + const depositContract = DepositAbi__factory.connect(DEPOSIT_CONTRACT, signer); + + await depositContract.deposit( + depositData.pubkey, + depositData.withdrawalCredentials, + depositData.signature, + depositDataRoot, + { value: ethers.constants.WeiPerEther.mul(amount) }, + ); + + return { wallet: signer, depositSign: depositData.signature }; +} + +export function getWalletAddress() { + if (!process.env.WALLET_PRIVATE_KEY) throw new Error(NO_PRIVKEY_MESSAGE); + const wallet = new ethers.Wallet(process.env.WALLET_PRIVATE_KEY); + return wallet.address; +} diff --git a/test/helpers/dsm.ts b/test/helpers/dsm.ts new file mode 100644 index 00000000..05e7dcf5 --- /dev/null +++ b/test/helpers/dsm.ts @@ -0,0 +1,61 @@ +import { ethers } from 'ethers'; +import { + SECURITY_MODULE, + SECURITY_MODULE_OWNER, + GANACHE_PORT, + NO_PRIVKEY_MESSAGE, +} from '../constants'; +import { SecurityAbi__factory } from 'generated'; + +function createProvider() { + return new ethers.providers.JsonRpcProvider( + `http://127.0.0.1:${GANACHE_PORT}`, + ); +} + +function createWallet(provider: ethers.providers.JsonRpcProvider) { + if (!process.env.WALLET_PRIVATE_KEY) throw new Error(NO_PRIVKEY_MESSAGE); + return new ethers.Wallet(process.env.WALLET_PRIVATE_KEY, provider); +} + +export async function addGuardians( + params = { + securityModule: SECURITY_MODULE, + securityModuleOwner: SECURITY_MODULE_OWNER, + }, +) { + const provider = createProvider(); + const wallet = createWallet(provider); + + // Convert the ETH amount to wei + const amountInWei = ethers.utils.parseEther('5'); + + const tx = await wallet.sendTransaction({ + to: params.securityModuleOwner, + value: amountInWei, + }); + + // Wait for the transaction to be mined + await tx.wait(); + + const signer = provider.getSigner(params.securityModuleOwner); + + const securityContract = SecurityAbi__factory.connect( + params.securityModule, + signer, + ); + await securityContract.functions.addGuardian(wallet.address, 1); +} + +export async function setGuardianBalance(eth: string) { + const provider = createProvider(); + const wallet = createWallet(provider); + + // Convert the ETH amount to wei + const amountInWei = ethers.utils.parseEther(eth); + + await provider.send('evm_setAccountBalance', [ + wallet.address, + ethers.utils.hexlify(amountInWei), + ]); +} diff --git a/test/helpers/keys-fixtures.ts b/test/helpers/keys-fixtures.ts new file mode 100644 index 00000000..cbaf0754 --- /dev/null +++ b/test/helpers/keys-fixtures.ts @@ -0,0 +1,32 @@ +import { FORK_BLOCK, NOP_REGISTRY } from '../constants'; + +export const mockKey = { + key: '0xa92daac72ad30458120e2a186400a673a4663768f118806c986ee045667c5599a608da5ea44354df124e6ac8d4ea9570', + depositSignature: + '0x93f492eed0fd6e86e7b50092027a06e186a5edf88250afb82c8c8ebf1febcf28e3a50669a302a4d2d451fab3d0d7d21b174ebf0061c685c2322b06dc6e714aa2a228218884e1fbe033287173c3162796acb4a526eaad031f19bd9dccb7f97a4d', + operatorIndex: 0, + used: false, + index: 0, + moduleAddress: NOP_REGISTRY, + vetted: true, +}; + +export const mockKeyEvent = { + operatorIndex: 0, + key: '0xa92daac72ad30458120e2a186400a673a4663768f118806c986ee045667c5599a608da5ea44354df124e6ac8d4ea9570', + moduleAddress: NOP_REGISTRY, + logIndex: 0, + blockNumber: FORK_BLOCK - 1, + blockHash: '0x1', +}; + +export const mockKey2 = { + key: '0x859eba194d2169faaedef29d7e3c28c954ec4790f050c9a53cb8a825700aa6cb388ffff041c69e8e4974ca716d4528fa', + depositSignature: + '0xb9699abf2672d54d3cab8f438e0d0cb45ad7de762ae1caff09d4bc571c4c16a91b33b11dbe567508d7db3c83a5f97adb11d53ab65ed8ffd1937da78b3942ba82bcc5b646a0c32df1ea61f0773ab5c976f0f6609bd6938f59eb2be0b20bfd14f7', + operatorIndex: 0, + used: true, + moduleAddress: NOP_REGISTRY, + index: 1, + vetted: true, +}; diff --git a/test/helpers/mockKeysApi.ts b/test/helpers/mockKeysApi.ts index 3c0fb3a8..7d2c0ec8 100644 --- a/test/helpers/mockKeysApi.ts +++ b/test/helpers/mockKeysApi.ts @@ -1,19 +1,13 @@ import ethers from 'ethers'; import { KeysApiService } from '../../src/keys-api/keys-api.service'; -import { FAKE_SIMPLE_DVT, NOP_REGISTRY } from './../constants'; -import { RegistryOperator } from 'keys-api/interfaces/RegistryOperator'; +import { SIMPLE_DVT, NOP_REGISTRY } from './../constants'; import { SRModule } from 'keys-api/interfaces'; import { ELBlockSnapshot } from 'keys-api/interfaces/ELBlockSnapshot'; import { RegistryKey } from 'keys-api/interfaces/RegistryKey'; -export const mockedModule = ( - block: ethers.providers.Block, - lastChangedBlockHash: string, - nonce = 6046, -): SRModule => ({ - nonce, - type: 'grouped-onchain-v1', +export const mockedModuleCurated: SRModule = { + type: 'curated-onchain-v1', id: 1, stakingModuleAddress: NOP_REGISTRY, moduleFee: 10, @@ -21,35 +15,32 @@ export const mockedModule = ( targetShare: 10, status: 1, name: 'NodeOperatorRegistry', - lastDepositAt: block.timestamp, - lastDepositBlock: block.number, - lastChangedBlockHash, + lastDepositAt: 1234345657, + lastDepositBlock: 12345, + lastChangedBlockHash: '', + nonce: 6046, exitedValidatorsCount: 0, active: true, -}); +}; -export const mockedModuleDvt = ( - block: ethers.providers.Block, - lastChangedBlockHash: string, - nonce = 6046, -): SRModule => ({ - nonce, - type: 'grouped-onchain-v1', +export const mockedModuleDvt: SRModule = { + type: 'curated-onchain-v1', id: 2, - stakingModuleAddress: FAKE_SIMPLE_DVT, + stakingModuleAddress: SIMPLE_DVT, moduleFee: 10, treasuryFee: 10, targetShare: 10, status: 1, name: 'NodeOperatorRegistrySimpleDvt', - lastDepositAt: block.timestamp, - lastDepositBlock: block.number, - lastChangedBlockHash, + lastDepositAt: 1234345657, + lastDepositBlock: 12345, + lastChangedBlockHash: '', + nonce: 6046, exitedValidatorsCount: 0, active: true, -}); +}; -export const mockedMeta = ( +export const mockMeta = ( block: ethers.providers.Block, lastChangedBlockHash: string, ) => ({ @@ -59,89 +50,41 @@ export const mockedMeta = ( lastChangedBlockHash, }); -export const mockedOperators: RegistryOperator[] = [ - { - name: 'Dev team', - rewardAddress: '0x6D725DAe055287f913661ee0b79dE6B21F12A459', - stakingLimit: 12, - stoppedValidators: 0, - totalSigningKeys: 12, - usedSigningKeys: 9, - index: 0, - active: true, - moduleAddress: NOP_REGISTRY, - }, -]; - -export const mockedDvtOperators: RegistryOperator[] = [ - { - name: 'Dev DVT team', - rewardAddress: '0x6D725DAe055287f913661ee0b79dE6B21F12A459', - stakingLimit: 12, - stoppedValidators: 0, - totalSigningKeys: 12, - usedSigningKeys: 10, - index: 0, - active: true, - moduleAddress: FAKE_SIMPLE_DVT, - }, -]; - -export const mockedKeysApiOperators = ( +export const keysApiMockGetModules = ( keysApiService: KeysApiService, - mockedOperators: RegistryOperator[], - mockedModule: SRModule, - mockedMeta: ELBlockSnapshot, + modules: SRModule[], + meta: ELBlockSnapshot, ) => { - jest - .spyOn(keysApiService, 'getOperatorListWithModule') - .mockImplementation(async () => ({ - data: [{ operators: mockedOperators, module: mockedModule }], - meta: { - elBlockSnapshot: mockedMeta, - }, - })); -}; - -export const mockedKeysApiOperatorsMany = ( - keysApiService: KeysApiService, - data: { operators: RegistryOperator[]; module: SRModule }[], - mockedMeta: ELBlockSnapshot, -) => { - jest - .spyOn(keysApiService, 'getOperatorListWithModule') - .mockImplementation(async () => ({ - data: data, - meta: { - elBlockSnapshot: mockedMeta, - }, - })); + jest.spyOn(keysApiService, 'getModules').mockImplementation(async () => ({ + data: modules, + elBlockSnapshot: meta, + })); }; -export const mockedKeysApiUnusedKeys = ( +export const keysApiMockGetAllKeys = ( keysApiService: KeysApiService, - mockedKeys: RegistryKey[], - mockedMeta: ELBlockSnapshot, + keys: RegistryKey[], + meta: ELBlockSnapshot, ) => { - jest.spyOn(keysApiService, 'getUnusedKeys').mockImplementation(async () => ({ - data: mockedKeys, + jest.spyOn(keysApiService, 'getKeys').mockImplementation(async () => ({ + data: keys, meta: { - elBlockSnapshot: mockedMeta, + elBlockSnapshot: meta, }, })); }; -export const mockedKeysWithDuplicates = ( +export const mockedKeysApiFind = ( keysApiService: KeysApiService, - mockedKeys: RegistryKey[], - mockedMeta: ELBlockSnapshot, + keys: RegistryKey[], + meta: ELBlockSnapshot, ) => { jest .spyOn(keysApiService, 'getKeysByPubkeys') .mockImplementation(async () => ({ - data: mockedKeys, + data: keys, meta: { - elBlockSnapshot: mockedMeta, + elBlockSnapshot: meta, }, })); }; diff --git a/test/helpers/test-setup.ts b/test/helpers/test-setup.ts new file mode 100644 index 00000000..3dee69ba --- /dev/null +++ b/test/helpers/test-setup.ts @@ -0,0 +1,70 @@ +import { Test } from '@nestjs/testing'; +import { ConfigModule } from 'common/config'; +import { LoggerModule } from 'common/logger'; +import { PrometheusModule } from 'common/prometheus'; +import { DepositsRegistryModule } from 'contracts/deposits-registry'; +import { RepositoryModule } from 'contracts/repository'; +import { SecurityModule } from 'contracts/security'; +import { GuardianModule } from 'guardian'; +import { KeysApiModule } from 'keys-api/keys-api.module'; +import { GanacheProviderModule } from 'provider'; +import { WalletModule } from 'wallet'; +import { DepositsRegistryStoreService } from 'contracts/deposits-registry/store'; +import { SigningKeysStoreService as SignKeyLevelDBService } from 'contracts/signing-keys-registry/store'; +import { WINSTON_MODULE_NEST_PROVIDER } from 'nest-winston'; + +export const setupTestingModule = async () => { + const moduleRef = await Test.createTestingModule({ + imports: [ + GanacheProviderModule.forRoot(), + ConfigModule.forRoot(), + PrometheusModule, + LoggerModule, + GuardianModule, + RepositoryModule, + WalletModule, + KeysApiModule, + DepositsRegistryModule.register('latest'), + SecurityModule, + ], + }).compile(); + + const loggerService = moduleRef.get(WINSTON_MODULE_NEST_PROVIDER); + + jest.spyOn(loggerService, 'log').mockImplementation(() => undefined); + jest.spyOn(loggerService, 'warn').mockImplementation(() => undefined); + jest.spyOn(loggerService, 'debug').mockImplementation(() => undefined); + jest.spyOn(loggerService, 'error').mockImplementation(() => undefined); + + return moduleRef; +}; + +export const initLevelDB = async ( + levelDBService: DepositsRegistryStoreService, + signKeyLevelDBService: SignKeyLevelDBService, +) => { + await levelDBService.initialize(); + await signKeyLevelDBService.initialize(); +}; + +export const closeServer = async ( + server, + levelDBService: DepositsRegistryStoreService, + signKeyLevelDBService: SignKeyLevelDBService, +) => { + await server.close(); + await levelDBService.deleteCache(); + await signKeyLevelDBService.deleteCache(); + await levelDBService.close(); + await signKeyLevelDBService.close(); +}; + +export const closeLevelDB = async ( + levelDBService: DepositsRegistryStoreService, + signKeyLevelDBService: SignKeyLevelDBService, +) => { + await levelDBService.deleteCache(); + await signKeyLevelDBService.deleteCache(); + await levelDBService.close(); + await signKeyLevelDBService.close(); +}; diff --git a/test/invalid-keys-v3.e2e-spec.ts b/test/invalid-keys-v3.e2e-spec.ts new file mode 100644 index 00000000..37ae4251 --- /dev/null +++ b/test/invalid-keys-v3.e2e-spec.ts @@ -0,0 +1,542 @@ +// Global Helpers +import { toHexString } from '@chainsafe/ssz'; + +// Helpers +import { + keysApiMockGetAllKeys, + keysApiMockGetModules, + mockedModuleCurated, + mockedModuleDvt, + mockMeta, +} from './helpers'; + +// Constants +import { + TESTS_TIMEOUT, + SLEEP_FOR_RESULT, + CHAIN_ID, + GANACHE_PORT, + sk, + pk, + NOP_REGISTRY, + SIMPLE_DVT, + LIDO_WC, + UNLOCKED_ACCOUNTS, + FORK_BLOCK, +} from './constants'; + +// Mock rabbit straight away +jest.mock('../src/transport/stomp/stomp.client.ts'); + +jest.setTimeout(10_000); + +import { + setupTestingModule, + closeServer, + initLevelDB, +} from './helpers/test-setup'; +import { SecurityService } from 'contracts/security'; +import { GuardianService } from 'guardian'; +import { KeysApiService } from 'keys-api/keys-api.service'; +import { ProviderService } from 'provider'; +import { Server } from 'ganache'; +import { GuardianMessageService } from 'guardian/guardian-message'; +import { DepositsRegistryStoreService } from 'contracts/deposits-registry/store'; +import { SigningKeysStoreService as SignKeyLevelDBService } from 'contracts/signing-keys-registry/store'; +import { KeyValidatorInterface } from '@lido-nestjs/key-validation'; + +import { getWalletAddress, signDeposit } from './helpers/deposit'; +import { SigningKeysRegistryService } from 'contracts/signing-keys-registry'; +import { addGuardians } from './helpers/dsm'; +import { BlsService } from 'bls'; +import { DepositIntegrityCheckerService } from 'contracts/deposits-registry/sanity-checker'; +import { makeServer } from './server'; +import { mockKey } from './helpers/keys-fixtures'; + +describe('ganache e2e tests', () => { + let server: Server<'ethereum'>; + let providerService: ProviderService; + let keysApiService: KeysApiService; + let guardianService: GuardianService; + let keyValidator: KeyValidatorInterface; + let levelDBService: DepositsRegistryStoreService; + let signKeyLevelDBService: SignKeyLevelDBService; + let guardianMessageService: GuardianMessageService; + let signingKeysRegistryService: SigningKeysRegistryService; + let depositIntegrityCheckerService: DepositIntegrityCheckerService; + let securityService: SecurityService; + + // mocks + let sendDepositMessage: jest.SpyInstance; + let sendPauseMessage: jest.SpyInstance; + let validateKeys: jest.SpyInstance; + let sendUnvetMessage: jest.SpyInstance; + let unvetSigningKeys: jest.SpyInstance; + + const setupServer = async () => { + server = makeServer(FORK_BLOCK, CHAIN_ID, UNLOCKED_ACCOUNTS); + await server.listen(GANACHE_PORT); + }; + + const setupGuardians = async () => { + await addGuardians(); + }; + + const setupTestingServices = async (moduleRef) => { + // leveldb service + levelDBService = moduleRef.get(DepositsRegistryStoreService); + signKeyLevelDBService = moduleRef.get(SignKeyLevelDBService); + + await initLevelDB(levelDBService, signKeyLevelDBService); + + // deposit events related services + depositIntegrityCheckerService = moduleRef.get( + DepositIntegrityCheckerService, + ); + + const blsService = moduleRef.get(BlsService); + await blsService.onModuleInit(); + + // keys events service + signingKeysRegistryService = moduleRef.get(SigningKeysRegistryService); + + providerService = moduleRef.get(ProviderService); + + // dsm methods and council sign services + securityService = moduleRef.get(SecurityService); + + // keys api servies + keysApiService = moduleRef.get(KeysApiService); + + // rabbitmq message sending methods + guardianMessageService = moduleRef.get(GuardianMessageService); + + // main service that check keys and make decision + guardianService = moduleRef.get(GuardianService); + + // sign validation + keyValidator = moduleRef.get(KeyValidatorInterface); + }; + + const setupMocks = () => { + // broker messages + sendDepositMessage = jest + .spyOn(guardianMessageService, 'sendDepositMessage') + .mockImplementation(() => Promise.resolve()); + jest + .spyOn(guardianMessageService, 'pingMessageBroker') + .mockImplementation(() => Promise.resolve()); + sendPauseMessage = jest + .spyOn(guardianMessageService, 'sendPauseMessageV2') + .mockImplementation(() => Promise.resolve()); + sendUnvetMessage = jest + .spyOn(guardianMessageService, 'sendUnvetMessage') + .mockImplementation(() => Promise.resolve()); + + // deposit cache mocks + jest + .spyOn(depositIntegrityCheckerService, 'putEventsToTree') + .mockImplementation(() => Promise.resolve()); + jest + .spyOn(depositIntegrityCheckerService, 'checkLatestRoot') + .mockImplementation(() => Promise.resolve(true)); + jest + .spyOn(depositIntegrityCheckerService, 'checkFinalizedRoot') + .mockImplementation(() => Promise.resolve(true)); + + // sign validation + validateKeys = jest.spyOn(keyValidator, 'validateKeys'); + + // mock unvetting method of contract + // as we dont use real keys api and work with fixtures of operators and keys + // we cant make real unvetting + unvetSigningKeys = jest + .spyOn(securityService, 'unvetSigningKeys') + .mockImplementation(() => Promise.resolve(null as any)); + }; + + beforeEach(async () => { + await setupServer(); + await setupGuardians(); + const moduleRef = await setupTestingModule(); + await setupTestingServices(moduleRef); + setupMocks(); + }, 20000); + + afterEach(async () => { + await closeServer(server, levelDBService, signKeyLevelDBService); + }); + + test( + 'should not validate again if depositData was not changed', + async () => { + const currentBlock = await providerService.provider.getBlock('latest'); + + await levelDBService.setCachedEvents({ + data: [], + headers: { + startBlock: currentBlock.number, + endBlock: currentBlock.number, + }, + }); + + await signingKeysRegistryService.setCachedEvents({ + data: [], + headers: { + startBlock: currentBlock.number, + endBlock: currentBlock.number, + stakingModulesAddresses: [NOP_REGISTRY, SIMPLE_DVT], + }, + }); + + const keyWithWrongSign = { + key: toHexString(pk), + // just some random sign + depositSignature: + '0x8bf4401a354de243a3716ee2efc0bde1ded56a40e2943ac7c50290bec37e935d6170b21e7c0872f203199386143ef12612a1488a8e9f1cdf1229c382f29c326bcbf6ed6a87d8fbfe0df87dacec6632fc4709d9d338f4cf81e861d942c23bba1e', + operatorIndex: 0, + used: false, + index: 0, + moduleAddress: NOP_REGISTRY, + vetted: true, + }; + + const invalidKeys = [keyWithWrongSign]; + + const meta = mockMeta(currentBlock, currentBlock.hash); + // setup /v1/modules + const stakingModules = [mockedModuleCurated, mockedModuleDvt]; + keysApiMockGetModules(keysApiService, stakingModules, meta); + // setup /v1/keys + keysApiMockGetAllKeys(keysApiService, invalidKeys, meta); + const walletAddress = await getWalletAddress(); + + await guardianService.handleNewBlock(); + await new Promise((res) => setTimeout(res, SLEEP_FOR_RESULT)); + + expect(validateKeys).toBeCalledTimes(2); + expect(validateKeys).toHaveBeenNthCalledWith( + 1, + expect.arrayContaining([ + expect.objectContaining({ + key: toHexString(pk), + // just some random sign + depositSignature: + '0x8bf4401a354de243a3716ee2efc0bde1ded56a40e2943ac7c50290bec37e935d6170b21e7c0872f203199386143ef12612a1488a8e9f1cdf1229c382f29c326bcbf6ed6a87d8fbfe0df87dacec6632fc4709d9d338f4cf81e861d942c23bba1e', + }), + ]), + ); + expect(validateKeys).toHaveBeenNthCalledWith(2, []); + expect(sendUnvetMessage).toBeCalledTimes(1); + expect(sendUnvetMessage).toHaveBeenCalledWith( + expect.objectContaining({ + blockNumber: currentBlock.number, + guardianAddress: walletAddress, + guardianIndex: 7, + stakingModuleId: 1, + operatorIds: '0x0000000000000000', + vettedKeysByOperator: '0x00000000000000000000000000000000', + }), + ); + expect(unvetSigningKeys).toBeCalledTimes(1); + + expect(sendDepositMessage).toBeCalledTimes(1); + expect(sendDepositMessage).toBeCalledWith( + expect.objectContaining({ + blockNumber: currentBlock.number, + guardianAddress: walletAddress, + guardianIndex: 7, + stakingModuleId: 2, + }), + ); + expect(sendPauseMessage).toBeCalledTimes(0); + + await providerService.provider.send('evm_mine', []); + + // if depositData was not changed it will not validate again + await providerService.provider.send('evm_mine', []); + const newBlock = await providerService.provider.getBlock('latest'); + const newMeta = mockMeta(newBlock, newBlock.hash); + // setup /v1/modules + keysApiMockGetModules(keysApiService, stakingModules, newMeta); + // setup /v1/keys + keysApiMockGetAllKeys(keysApiService, invalidKeys, newMeta); + + validateKeys.mockClear(); + sendDepositMessage.mockClear(); + sendUnvetMessage.mockClear(); + unvetSigningKeys.mockClear(); + + await guardianService.handleNewBlock(); + await new Promise((res) => setTimeout(res, SLEEP_FOR_RESULT)); + + expect(validateKeys).toBeCalledTimes(2); + // don't validate again + expect(validateKeys).toHaveBeenNthCalledWith(1, []); + expect(validateKeys).toHaveBeenNthCalledWith(2, []); + expect(sendUnvetMessage).toBeCalledTimes(1); + expect(sendUnvetMessage).toHaveBeenCalledWith( + expect.objectContaining({ + blockNumber: newBlock.number, + guardianAddress: walletAddress, + guardianIndex: 7, + stakingModuleId: 1, + operatorIds: '0x0000000000000000', + vettedKeysByOperator: '0x00000000000000000000000000000000', + }), + ); + expect(unvetSigningKeys).toBeCalledTimes(1); + + expect(sendDepositMessage).toBeCalledTimes(1); + expect(sendDepositMessage).toBeCalledWith( + expect.objectContaining({ + blockNumber: newBlock.number, + guardianAddress: walletAddress, + guardianIndex: 7, + stakingModuleId: 2, + }), + ); + expect(sendPauseMessage).toBeCalledTimes(0); + }, + TESTS_TIMEOUT, + ); + + test('should validate again if deposit data was changed', async () => { + const currentBlock = await providerService.provider.getBlock('latest'); + + await levelDBService.setCachedEvents({ + data: [], + headers: { + startBlock: currentBlock.number, + endBlock: currentBlock.number, + }, + }); + + await signingKeysRegistryService.setCachedEvents({ + data: [], + headers: { + startBlock: currentBlock.number, + endBlock: currentBlock.number, + stakingModulesAddresses: [NOP_REGISTRY, SIMPLE_DVT], + }, + }); + + const keyWithWrongSign = { + key: toHexString(pk), + // just some random sign + depositSignature: + '0x8bf4401a354de243a3716ee2efc0bde1ded56a40e2943ac7c50290bec37e935d6170b21e7c0872f203199386143ef12612a1488a8e9f1cdf1229c382f29c326bcbf6ed6a87d8fbfe0df87dacec6632fc4709d9d338f4cf81e861d942c23bba1e', + operatorIndex: 0, + used: false, + index: 0, + moduleAddress: NOP_REGISTRY, + vetted: true, + }; + + const dvtKey = { + ...mockKey, + moduleAddress: SIMPLE_DVT, + }; + + const invalidKeys = [keyWithWrongSign, dvtKey]; + + const meta = mockMeta(currentBlock, currentBlock.hash); + // setup /v1/modules + const stakingModules = [mockedModuleCurated, mockedModuleDvt]; + keysApiMockGetModules(keysApiService, stakingModules, meta); + // setup /v1/keys + keysApiMockGetAllKeys(keysApiService, invalidKeys, meta); + + const walletAddress = await getWalletAddress(); + + await guardianService.handleNewBlock(); + + await new Promise((res) => setTimeout(res, SLEEP_FOR_RESULT)); + + const { signature: lidoSign } = signDeposit(pk, sk, LIDO_WC); + + expect(validateKeys).toBeCalledTimes(2); + expect(validateKeys).toHaveBeenNthCalledWith( + 1, + expect.arrayContaining([ + expect.objectContaining({ + key: toHexString(pk), + // just some random sign + depositSignature: + '0x8bf4401a354de243a3716ee2efc0bde1ded56a40e2943ac7c50290bec37e935d6170b21e7c0872f203199386143ef12612a1488a8e9f1cdf1229c382f29c326bcbf6ed6a87d8fbfe0df87dacec6632fc4709d9d338f4cf81e861d942c23bba1e', + }), + ]), + ); + expect(validateKeys).toHaveBeenNthCalledWith( + 2, + expect.arrayContaining([ + expect.objectContaining({ + key: mockKey.key, + depositSignature: mockKey.depositSignature, + }), + ]), + ); + expect(sendUnvetMessage).toBeCalledTimes(1); + expect(sendUnvetMessage).toHaveBeenCalledWith( + expect.objectContaining({ + blockNumber: currentBlock.number, + guardianAddress: walletAddress, + guardianIndex: 7, + stakingModuleId: 1, + operatorIds: '0x0000000000000000', + vettedKeysByOperator: '0x00000000000000000000000000000000', + }), + ); + expect(unvetSigningKeys).toBeCalledTimes(1); + expect(sendDepositMessage).toBeCalledTimes(1); + expect(sendDepositMessage).toBeCalledWith( + expect.objectContaining({ + blockNumber: currentBlock.number, + guardianAddress: walletAddress, + guardianIndex: 7, + stakingModuleId: 2, + }), + ); + expect(sendPauseMessage).toBeCalledTimes(0); + + const fixedKey = { + ...keyWithWrongSign, + depositSignature: toHexString(lidoSign), + }; + + const fixedKeys = [fixedKey, dvtKey]; + + await providerService.provider.send('evm_mine', []); + const newBlock = await providerService.provider.getBlock('latest'); + const newMeta = mockMeta(newBlock, newBlock.hash); + // setup /v1/modules + keysApiMockGetModules(keysApiService, stakingModules, newMeta); + // setup /v1/keys + keysApiMockGetAllKeys(keysApiService, fixedKeys, newMeta); + + validateKeys.mockClear(); + sendDepositMessage.mockClear(); + sendUnvetMessage.mockClear(); + unvetSigningKeys.mockClear(); + + await guardianService.handleNewBlock(); + await new Promise((res) => setTimeout(res, SLEEP_FOR_RESULT)); + + expect(validateKeys).toBeCalledTimes(2); + expect(validateKeys).toHaveBeenNthCalledWith( + 1, + expect.arrayContaining([ + expect.objectContaining({ + key: toHexString(pk), + depositSignature: toHexString(lidoSign), + }), + ]), + ); + expect(validateKeys).toHaveBeenNthCalledWith(2, []); + expect(sendDepositMessage).toBeCalledTimes(2); + expect(sendDepositMessage).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + blockNumber: newBlock.number, + guardianAddress: walletAddress, + guardianIndex: 7, + stakingModuleId: 1, + }), + ); + expect(sendDepositMessage).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + blockNumber: newBlock.number, + guardianAddress: walletAddress, + guardianIndex: 7, + stakingModuleId: 2, + }), + ); + + expect(sendPauseMessage).toBeCalledTimes(0); + }); + + test('adding not vetted invalid key will not set on soft pause module', async () => { + const currentBlock = await providerService.provider.getBlock('latest'); + + await levelDBService.setCachedEvents({ + data: [], + headers: { + startBlock: currentBlock.number, + endBlock: currentBlock.number, + }, + }); + + await signingKeysRegistryService.setCachedEvents({ + data: [], + headers: { + startBlock: currentBlock.number, + endBlock: currentBlock.number, + stakingModulesAddresses: [NOP_REGISTRY, SIMPLE_DVT], + }, + }); + + const keyWithWrongSign = { + key: toHexString(pk), + // just some random sign + depositSignature: + '0x8bf4401a354de243a3716ee2efc0bde1ded56a40e2943ac7c50290bec37e935d6170b21e7c0872f203199386143ef12612a1488a8e9f1cdf1229c382f29c326bcbf6ed6a87d8fbfe0df87dacec6632fc4709d9d338f4cf81e861d942c23bba1e', + operatorIndex: 0, + used: false, + index: 0, + moduleAddress: NOP_REGISTRY, + vetted: false, + }; + + const dvtKey = { + ...mockKey, + moduleAddress: SIMPLE_DVT, + }; + + const keys = [keyWithWrongSign, dvtKey]; + + const meta = mockMeta(currentBlock, currentBlock.hash); + // setup /v1/modules + const stakingModules = [mockedModuleCurated, mockedModuleDvt]; + keysApiMockGetModules(keysApiService, stakingModules, meta); + // setup /v1/keys + keysApiMockGetAllKeys(keysApiService, keys, meta); + + const walletAddress = await getWalletAddress(); + + await guardianService.handleNewBlock(); + + await new Promise((res) => setTimeout(res, SLEEP_FOR_RESULT)); + + expect(validateKeys).toBeCalledTimes(2); + expect(validateKeys).toHaveBeenNthCalledWith(1, []); + expect(validateKeys).toHaveBeenNthCalledWith( + 2, + expect.arrayContaining([ + expect.objectContaining({ + key: mockKey.key, + depositSignature: mockKey.depositSignature, + }), + ]), + ); + expect(sendUnvetMessage).toBeCalledTimes(0); + expect(sendDepositMessage).toBeCalledTimes(2); + expect(sendDepositMessage).toBeCalledWith( + expect.objectContaining({ + blockNumber: currentBlock.number, + guardianAddress: walletAddress, + guardianIndex: 7, + stakingModuleId: 1, + }), + ); + expect(sendDepositMessage).toBeCalledWith( + expect.objectContaining({ + blockNumber: currentBlock.number, + guardianAddress: walletAddress, + guardianIndex: 7, + stakingModuleId: 2, + }), + ); + expect(sendPauseMessage).toBeCalledTimes(0); + }); +}); diff --git a/test/invalid-keys.e2e-spec.ts b/test/invalid-keys.e2e-spec.ts new file mode 100644 index 00000000..791c2b1f --- /dev/null +++ b/test/invalid-keys.e2e-spec.ts @@ -0,0 +1,402 @@ +// Global Helpers +import { toHexString } from '@chainsafe/ssz'; + +// Helpers +import { + keysApiMockGetAllKeys, + keysApiMockGetModules, + mockedModuleCurated, + mockedModuleDvt, + mockMeta, +} from './helpers'; + +// Constants +import { + TESTS_TIMEOUT, + SLEEP_FOR_RESULT, + CHAIN_ID, + GANACHE_PORT, + sk, + pk, + NOP_REGISTRY, + SIMPLE_DVT, + LIDO_WC, + FORK_BLOCK_V2, + UNLOCKED_ACCOUNTS_V2, + SECURITY_MODULE_V2, + SECURITY_MODULE_OWNER_V2, +} from './constants'; + +// Mock rabbit straight away +jest.mock('../src/transport/stomp/stomp.client.ts'); + +jest.setTimeout(10_000); + +import { + setupTestingModule, + closeServer, + initLevelDB, +} from './helpers/test-setup'; +import { GuardianService } from 'guardian'; +import { KeysApiService } from 'keys-api/keys-api.service'; +import { ProviderService } from 'provider'; +import { Server } from 'ganache'; +import { GuardianMessageService } from 'guardian/guardian-message'; +import { DepositsRegistryStoreService } from 'contracts/deposits-registry/store'; +import { SigningKeysStoreService as SignKeyLevelDBService } from 'contracts/signing-keys-registry/store'; +import { KeyValidatorInterface } from '@lido-nestjs/key-validation'; +import { getWalletAddress, signDeposit } from './helpers/deposit'; +import { SigningKeysRegistryService } from 'contracts/signing-keys-registry'; +import { addGuardians } from './helpers/dsm'; +import { BlsService } from 'bls'; +import { DepositIntegrityCheckerService } from 'contracts/deposits-registry/sanity-checker'; +import { makeServer } from './server'; +import { mockKey } from './helpers/keys-fixtures'; + +describe('ganache e2e tests', () => { + let server: Server<'ethereum'>; + let providerService: ProviderService; + let keysApiService: KeysApiService; + let guardianService: GuardianService; + let keyValidator: KeyValidatorInterface; + let sendDepositMessage: jest.SpyInstance; + let sendPauseMessage: jest.SpyInstance; + let validateKeys: jest.SpyInstance; + let levelDBService: DepositsRegistryStoreService; + let signKeyLevelDBService: SignKeyLevelDBService; + let guardianMessageService: GuardianMessageService; + let signingKeysRegistryService: SigningKeysRegistryService; + let depositIntegrityCheckerService: DepositIntegrityCheckerService; + + const setupServer = async () => { + server = makeServer(FORK_BLOCK_V2, CHAIN_ID, UNLOCKED_ACCOUNTS_V2); + await server.listen(GANACHE_PORT); + }; + + const setupGuardians = async () => { + await addGuardians({ + securityModule: SECURITY_MODULE_V2, + securityModuleOwner: SECURITY_MODULE_OWNER_V2, + }); + }; + + const setupTestingServices = async (moduleRef) => { + // leveldb service + levelDBService = moduleRef.get(DepositsRegistryStoreService); + signKeyLevelDBService = moduleRef.get(SignKeyLevelDBService); + + await initLevelDB(levelDBService, signKeyLevelDBService); + + // deposit events related services + depositIntegrityCheckerService = moduleRef.get( + DepositIntegrityCheckerService, + ); + + const blsService = moduleRef.get(BlsService); + await blsService.onModuleInit(); + + // keys events service + signingKeysRegistryService = moduleRef.get(SigningKeysRegistryService); + + providerService = moduleRef.get(ProviderService); + + // keys api servies + keysApiService = moduleRef.get(KeysApiService); + + // rabbitmq message sending methods + guardianMessageService = moduleRef.get(GuardianMessageService); + + // main service that check keys and make decision + guardianService = moduleRef.get(GuardianService); + + // sign validation + keyValidator = moduleRef.get(KeyValidatorInterface); + }; + + const setupMocks = () => { + // broker messages + sendDepositMessage = jest + .spyOn(guardianMessageService, 'sendDepositMessage') + .mockImplementation(() => Promise.resolve()); + jest + .spyOn(guardianMessageService, 'pingMessageBroker') + .mockImplementation(() => Promise.resolve()); + sendPauseMessage = jest + .spyOn(guardianMessageService, 'sendPauseMessageV2') + .mockImplementation(() => Promise.resolve()); + + // deposit cache mocks + jest + .spyOn(depositIntegrityCheckerService, 'putEventsToTree') + .mockImplementation(() => Promise.resolve()); + jest + .spyOn(depositIntegrityCheckerService, 'checkLatestRoot') + .mockImplementation(() => Promise.resolve(true)); + jest + .spyOn(depositIntegrityCheckerService, 'checkFinalizedRoot') + .mockImplementation(() => Promise.resolve(true)); + + // sign validation + validateKeys = jest.spyOn(keyValidator, 'validateKeys'); + }; + + beforeEach(async () => { + await setupServer(); + await setupGuardians(); + const moduleRef = await setupTestingModule(); + await setupTestingServices(moduleRef); + setupMocks(); + }, 20000); + + afterEach(async () => { + await closeServer(server, levelDBService, signKeyLevelDBService); + }); + + test( + 'should not validate again if depositData was not changed', + async () => { + const currentBlock = await providerService.provider.getBlock('latest'); + + await levelDBService.setCachedEvents({ + data: [], + headers: { + startBlock: currentBlock.number, + endBlock: currentBlock.number, + }, + }); + + await signingKeysRegistryService.setCachedEvents({ + data: [], + headers: { + startBlock: currentBlock.number, + endBlock: currentBlock.number, + stakingModulesAddresses: [NOP_REGISTRY, SIMPLE_DVT], + }, + }); + + const walletAddress = await getWalletAddress(); + + const keyWithWrongSign = { + key: toHexString(pk), + // just some random sign + depositSignature: + '0x8bf4401a354de243a3716ee2efc0bde1ded56a40e2943ac7c50290bec37e935d6170b21e7c0872f203199386143ef12612a1488a8e9f1cdf1229c382f29c326bcbf6ed6a87d8fbfe0df87dacec6632fc4709d9d338f4cf81e861d942c23bba1e', + operatorIndex: 0, + used: false, + index: 0, + moduleAddress: NOP_REGISTRY, + vetted: true, + }; + + const keys = [keyWithWrongSign]; + const meta = mockMeta(currentBlock, currentBlock.hash); + // setup /v1/modules + const stakingModules = [mockedModuleCurated, mockedModuleDvt]; + keysApiMockGetModules(keysApiService, stakingModules, meta); + // setup /v1/keys + keysApiMockGetAllKeys(keysApiService, keys, meta); + + await guardianService.handleNewBlock(); + await new Promise((res) => setTimeout(res, SLEEP_FOR_RESULT)); + + expect(validateKeys).toBeCalledTimes(2); + expect(validateKeys).toHaveBeenNthCalledWith( + 1, + expect.arrayContaining([ + expect.objectContaining({ + key: toHexString(pk), + // just some random sign + depositSignature: + '0x8bf4401a354de243a3716ee2efc0bde1ded56a40e2943ac7c50290bec37e935d6170b21e7c0872f203199386143ef12612a1488a8e9f1cdf1229c382f29c326bcbf6ed6a87d8fbfe0df87dacec6632fc4709d9d338f4cf81e861d942c23bba1e', + }), + ]), + ); + expect(validateKeys).toHaveBeenNthCalledWith(2, []); + + expect(sendDepositMessage).toBeCalledTimes(1); + expect(sendDepositMessage).toBeCalledWith( + expect.objectContaining({ + blockNumber: currentBlock.number, + guardianAddress: walletAddress, + guardianIndex: 7, + stakingModuleId: 2, + }), + ); + expect(sendPauseMessage).toBeCalledTimes(0); + + // if depositData was not changed it will not validate again + await providerService.provider.send('evm_mine', []); + const newBlock = await providerService.provider.getBlock('latest'); + const newMeta = mockMeta(newBlock, newBlock.hash); + // setup /v1/modules + keysApiMockGetModules(keysApiService, stakingModules, newMeta); + // setup /v1/keys + keysApiMockGetAllKeys(keysApiService, keys, newMeta); + + validateKeys.mockClear(); + sendDepositMessage.mockClear(); + sendPauseMessage.mockClear(); + + await guardianService.handleNewBlock(); + + await new Promise((res) => setTimeout(res, SLEEP_FOR_RESULT)); + + expect(validateKeys).toBeCalledTimes(2); + // dont validate again + expect(validateKeys).toHaveBeenNthCalledWith(1, []); + expect(validateKeys).toHaveBeenNthCalledWith(2, []); + expect(sendDepositMessage).toBeCalledTimes(1); + expect(sendDepositMessage).toBeCalledWith( + expect.objectContaining({ + blockNumber: newBlock.number, + guardianAddress: walletAddress, + guardianIndex: 7, + stakingModuleId: 2, + }), + ); + expect(sendPauseMessage).toBeCalledTimes(0); + }, + TESTS_TIMEOUT, + ); + + test('should validate again if deposit data was changed', async () => { + const currentBlock = await providerService.provider.getBlock('latest'); + + await levelDBService.setCachedEvents({ + data: [], + headers: { + startBlock: currentBlock.number, + endBlock: currentBlock.number, + }, + }); + + await signingKeysRegistryService.setCachedEvents({ + data: [], + headers: { + startBlock: currentBlock.number, + endBlock: currentBlock.number, + stakingModulesAddresses: [NOP_REGISTRY, SIMPLE_DVT], + }, + }); + + const keyWithWrongSign = { + key: toHexString(pk), + // just some random sign + depositSignature: + '0x8bf4401a354de243a3716ee2efc0bde1ded56a40e2943ac7c50290bec37e935d6170b21e7c0872f203199386143ef12612a1488a8e9f1cdf1229c382f29c326bcbf6ed6a87d8fbfe0df87dacec6632fc4709d9d338f4cf81e861d942c23bba1e', + operatorIndex: 0, + used: false, + index: 0, + moduleAddress: NOP_REGISTRY, + vetted: true, + }; + + const dvtKey = { + ...mockKey, + moduleAddress: SIMPLE_DVT, + vetted: true, + }; + + const keys = [keyWithWrongSign, dvtKey]; + const meta = mockMeta(currentBlock, currentBlock.hash); + // setup /v1/modules + const stakingModules = [mockedModuleCurated, mockedModuleDvt]; + keysApiMockGetModules(keysApiService, stakingModules, meta); + // setup /v1/keys + keysApiMockGetAllKeys(keysApiService, keys, meta); + + await guardianService.handleNewBlock(); + + await new Promise((res) => setTimeout(res, SLEEP_FOR_RESULT)); + + const { signature: lidoSign } = signDeposit(pk, sk, LIDO_WC); + const walletAddress = await getWalletAddress(); + + expect(validateKeys).toBeCalledTimes(2); + expect(validateKeys).toHaveBeenNthCalledWith( + 1, + expect.arrayContaining([ + expect.objectContaining({ + key: toHexString(pk), + // just some random sign + depositSignature: + '0x8bf4401a354de243a3716ee2efc0bde1ded56a40e2943ac7c50290bec37e935d6170b21e7c0872f203199386143ef12612a1488a8e9f1cdf1229c382f29c326bcbf6ed6a87d8fbfe0df87dacec6632fc4709d9d338f4cf81e861d942c23bba1e', + }), + ]), + ); + expect(validateKeys).toHaveBeenNthCalledWith( + 2, + expect.arrayContaining([ + expect.objectContaining({ + key: dvtKey.key, + depositSignature: dvtKey.depositSignature, + }), + ]), + ); + expect(sendDepositMessage).toBeCalledTimes(1); + expect(sendDepositMessage).toBeCalledWith( + expect.objectContaining({ + blockNumber: currentBlock.number, + guardianAddress: walletAddress, + guardianIndex: 7, + stakingModuleId: 2, + }), + ); + expect(sendPauseMessage).toBeCalledTimes(0); + + const fixedKey = { + ...keyWithWrongSign, + depositSignature: toHexString(lidoSign), + }; + + const fixedKeys = [fixedKey, dvtKey]; + await providerService.provider.send('evm_mine', []); + const newBlock = await providerService.provider.getBlock('latest'); + + const newMeta = mockMeta(newBlock, newBlock.hash); + // setup /v1/modules + keysApiMockGetModules(keysApiService, stakingModules, newMeta); + // setup /v1/keys + keysApiMockGetAllKeys(keysApiService, fixedKeys, newMeta); + + validateKeys.mockClear(); + sendDepositMessage.mockClear(); + sendPauseMessage.mockClear(); + + await guardianService.handleNewBlock(); + await new Promise((res) => setTimeout(res, SLEEP_FOR_RESULT)); + + expect(validateKeys).toBeCalledTimes(2); + expect(validateKeys).toHaveBeenNthCalledWith( + 1, + expect.arrayContaining([ + expect.objectContaining({ + key: toHexString(pk), + depositSignature: toHexString(lidoSign), + }), + ]), + ); + expect(validateKeys).toHaveBeenNthCalledWith(2, []); + expect(sendDepositMessage).toBeCalledTimes(2); + expect(sendDepositMessage).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + blockNumber: newBlock.number, + guardianAddress: walletAddress, + guardianIndex: 7, + stakingModuleId: 1, + }), + ); + expect(sendDepositMessage).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + blockNumber: newBlock.number, + guardianAddress: walletAddress, + guardianIndex: 7, + stakingModuleId: 2, + }), + ); + + expect(sendPauseMessage).toBeCalledTimes(0); + }); +}); diff --git a/test/manifest.e2e-spec.ts b/test/manifest.e2e-spec.ts deleted file mode 100644 index dbf77805..00000000 --- a/test/manifest.e2e-spec.ts +++ /dev/null @@ -1,2221 +0,0 @@ -import { Test } from '@nestjs/testing'; - -// Global Helpers -import { ethers } from 'ethers'; -import { fromHexString, toHexString } from '@chainsafe/ssz'; - -// Helpers -import { - computeRoot, - mockedDvtOperators, - mockedKeysApiOperators, - mockedKeysApiOperatorsMany, - mockedKeysApiUnusedKeys, - mockedKeysWithDuplicates, - mockedMeta, - mockedModule, - mockedModuleDvt, - mockedOperators, -} from './helpers'; - -// Constants -import { WeiPerEther } from '@ethersproject/constants'; -import { - TESTS_TIMEOUT, - SLEEP_FOR_RESULT, - SECURITY_MODULE, - SECURITY_MODULE_OWNER, - STAKING_ROUTER, - DEPOSIT_CONTRACT, - GOOD_WC, - BAD_WC, - CHAIN_ID, - FORK_BLOCK, - UNLOCKED_ACCOUNTS, - GANACHE_PORT, - NO_PRIVKEY_MESSAGE, - sk, - pk, - NOP_REGISTRY, - FAKE_SIMPLE_DVT, -} from './constants'; - -// Ganache -import { makeServer } from './server'; - -// Contract Factories -import { - DepositAbi__factory, - SecurityAbi__factory, - StakingRouterAbi__factory, -} from './../src/generated'; - -// BLS helpers - -import { DepositData } from './../src/bls/bls.containers'; - -// App modules and services - -import { PrometheusModule } from '../src/common/prometheus'; -import { LoggerModule } from '../src/common/logger'; -import { ConfigModule } from '../src/common/config'; - -import { GuardianService } from '../src/guardian'; -import { GuardianModule } from '../src/guardian'; - -import { WalletService } from '../src/wallet'; -import { WalletModule } from '../src/wallet'; - -import { RepositoryModule } from '../src/contracts/repository'; - -import { DepositService } from '../src/contracts/deposit'; -import { DepositModule } from '../src/contracts/deposit'; - -import { SecurityModule, SecurityService } from '../src/contracts/security'; - -import { LidoService } from '../src/contracts/lido'; -import { LidoModule } from '../src/contracts/lido'; - -import { KeysApiService } from '../src/keys-api/keys-api.service'; -import { KeysApiModule } from '../src/keys-api/keys-api.module'; - -import { ProviderService } from '../src/provider'; -import { GanacheProviderModule } from '../src/provider'; - -import { BlsService } from '../src/bls'; -import { GuardianMessageService } from '../src/guardian/guardian-message'; -import { KeyValidatorInterface } from '@lido-nestjs/key-validation'; -import { StakingModuleGuardService } from 'guardian/staking-module-guard'; - -// Mock rabbit straight away -jest.mock('../src/transport/stomp/stomp.client.ts'); - -jest.setTimeout(10_000); - -describe('ganache e2e tests', () => { - let server: ReturnType; - - let providerService: ProviderService; - let walletService: WalletService; - let keysApiService: KeysApiService; - let guardianService: GuardianService; - let lidoService: LidoService; - let depositService: DepositService; - let blsService: BlsService; - let guardianMessageService: GuardianMessageService; - - let sendDepositMessage: jest.SpyInstance; - let sendPauseMessage: jest.SpyInstance; - - let keyValidator: KeyValidatorInterface; - let validateKeys: jest.SpyInstance; - - let securityService: SecurityService; - - let stakingModuleGuardService: StakingModuleGuardService; - - beforeEach(async () => { - server = makeServer(FORK_BLOCK, CHAIN_ID, UNLOCKED_ACCOUNTS); - await server.listen(GANACHE_PORT); - }); - - afterEach(async () => { - await server.close(); - }); - - beforeEach(async () => { - // Prepare a signer for the unlocked Ganache account - if (!process.env.WALLET_PRIVATE_KEY) throw new Error(NO_PRIVKEY_MESSAGE); - const wallet = new ethers.Wallet(process.env.WALLET_PRIVATE_KEY); - const tempProvider = new ethers.providers.JsonRpcProvider( - `http://127.0.0.1:${GANACHE_PORT}`, - ); - const tempSigner = tempProvider.getSigner(SECURITY_MODULE_OWNER); - - // Add our address to guardians and set consensus to 1 - const securityContract = SecurityAbi__factory.connect( - SECURITY_MODULE, - tempSigner, - ); - await securityContract.functions.addGuardian(wallet.address, 1); - - const moduleRef = await Test.createTestingModule({ - imports: [ - GanacheProviderModule.forRoot(), - ConfigModule.forRoot(), - PrometheusModule, - LoggerModule, - GuardianModule, - RepositoryModule, - WalletModule, - KeysApiModule, - LidoModule, - DepositModule, - SecurityModule, - ], - }).compile(); - - providerService = moduleRef.get(ProviderService); - walletService = moduleRef.get(WalletService); - keysApiService = moduleRef.get(KeysApiService); - guardianService = moduleRef.get(GuardianService); - lidoService = moduleRef.get(LidoService); - depositService = moduleRef.get(DepositService); - guardianMessageService = moduleRef.get(GuardianMessageService); - keyValidator = moduleRef.get(KeyValidatorInterface); - securityService = moduleRef.get(SecurityService); - stakingModuleGuardService = moduleRef.get(StakingModuleGuardService); - - // Initializing needed service instead of the whole app - blsService = moduleRef.get(BlsService); - await blsService.onModuleInit(); - - jest - .spyOn(lidoService, 'getWithdrawalCredentials') - .mockImplementation(async () => GOOD_WC); - - jest - .spyOn(guardianMessageService, 'pingMessageBroker') - .mockImplementation(() => Promise.resolve()); - sendDepositMessage = jest - .spyOn(guardianMessageService, 'sendDepositMessage') - .mockImplementation(() => Promise.resolve()); - sendPauseMessage = jest - .spyOn(guardianMessageService, 'sendPauseMessage') - .mockImplementation(() => Promise.resolve()); - - validateKeys = jest.spyOn(keyValidator, 'validateKeys'); - }); - - describe('node checks', () => { - it('should be on correct network', async () => { - const chainId = await providerService.getChainId(); - expect(chainId).toBe(CHAIN_ID); - }); - - it('should be able to create new blocks', async () => { - const isMining = await providerService.provider.send('eth_mining', []); - expect(isMining).toBe(true); - }); - - it('should be on correct block number', async () => { - const provider = providerService.provider; - const block = await provider.getBlock('latest'); - expect(block.number).toBe(FORK_BLOCK + 2); - }); - - it('testing address should have some eth', async () => { - const provider = providerService.provider; - const balance = await provider.getBalance(walletService.address); - expect(balance.gte(WeiPerEther.mul(34))).toBe(true); - }); - - it('needed contract should not be already on pause', async () => { - const routerContract = StakingRouterAbi__factory.connect( - STAKING_ROUTER, - providerService.provider, - ); - const isOnPause = await routerContract.getStakingModuleIsDepositsPaused( - 1, - ); - expect(isOnPause).toBe(false); - }); - }); - - test( - 'node operator deposit frontrun', - async () => { - const tempProvider = new ethers.providers.JsonRpcProvider( - `http://127.0.0.1:${GANACHE_PORT}`, - ); - const forkBlock = await tempProvider.getBlock(FORK_BLOCK); - const currentBlock = await tempProvider.getBlock('latest'); - - // create correct sign for deposit message for pk - const goodDepositMessage = { - pubkey: pk, - withdrawalCredentials: fromHexString(GOOD_WC), - amount: 32000000000, // gwei! - }; - const goodSigningRoot = computeRoot(goodDepositMessage); - const goodSig = sk.sign(goodSigningRoot).toBytes(); - - const unusedKeys = [ - { - key: toHexString(pk), - depositSignature: toHexString(goodSig), - operatorIndex: 0, - used: false, - index: 0, - moduleAddress: NOP_REGISTRY, - }, - ]; - - const meta = mockedMeta(currentBlock, currentBlock.hash); - const stakingModule = mockedModule(currentBlock, currentBlock.hash); - - mockedKeysApiOperators( - keysApiService, - mockedOperators, - stakingModule, - meta, - ); - - mockedKeysApiUnusedKeys(keysApiService, unusedKeys, meta); - mockedKeysWithDuplicates(keysApiService, unusedKeys, meta); - - await depositService.setCachedEvents({ - data: [ - { - valid: true, - pubkey: toHexString(pk), - amount: '32000000000', - wc: GOOD_WC, - signature: toHexString(goodSig), - tx: '0x123', - blockHash: forkBlock.hash, - blockNumber: forkBlock.number, - logIndex: 1, - }, - ], - headers: { - startBlock: currentBlock.number, - endBlock: currentBlock.number, - version: '1', - }, - }); - - // Check if the service is ok and ready to go - await guardianService.handleNewBlock(); - - const badDepositMessage = { - pubkey: pk, - withdrawalCredentials: fromHexString(BAD_WC), - amount: 1000000000, // gwei! - }; - const badSigningRoot = computeRoot(badDepositMessage); - const badSig = sk.sign(badSigningRoot).toBytes(); - - const badDepositData = { - ...badDepositMessage, - signature: badSig, - }; - const badDepositDataRoot = DepositData.hashTreeRoot(badDepositData); - - if (!process.env.WALLET_PRIVATE_KEY) throw new Error(NO_PRIVKEY_MESSAGE); - const wallet = new ethers.Wallet(process.env.WALLET_PRIVATE_KEY); - - // Make a bad deposit - const signer = wallet.connect(providerService.provider); - const depositContract = DepositAbi__factory.connect( - DEPOSIT_CONTRACT, - signer, - ); - await depositContract.deposit( - badDepositData.pubkey, - badDepositData.withdrawalCredentials, - badDepositData.signature, - badDepositDataRoot, - { value: ethers.constants.WeiPerEther.mul(1) }, - ); - - // Mock Keys API again on new block - const newBlock = await providerService.provider.getBlock('latest'); - const newMeta = mockedMeta(newBlock, newBlock.hash); - const updatedStakingModule = mockedModule(currentBlock, newBlock.hash); - - mockedKeysApiOperators( - keysApiService, - mockedOperators, - updatedStakingModule, - newMeta, - ); - - mockedKeysApiUnusedKeys(keysApiService, unusedKeys, newMeta); - - // Run a cycle and wait for possible changes - await guardianService.handleNewBlock(); - - expect(sendPauseMessage).toHaveBeenCalledWith( - expect.objectContaining({ - blockNumber: newBlock.number, - guardianAddress: wallet.address, - guardianIndex: 9, - stakingModuleId: 1, - }), - ); - await new Promise((res) => setTimeout(res, SLEEP_FOR_RESULT)); - - // Check if on pause now - const routerContract = StakingRouterAbi__factory.connect( - STAKING_ROUTER, - providerService.provider, - ); - const isOnPause = await routerContract.getStakingModuleIsDepositsPaused( - 1, - ); - expect(isOnPause).toBe(true); - }, - TESTS_TIMEOUT, - ); - - test( - 'node operator deposit frontrun many modules (1 with error, 2 normal)', - async () => { - const tempProvider = new ethers.providers.JsonRpcProvider( - `http://127.0.0.1:${GANACHE_PORT}`, - ); - const forkBlock = await tempProvider.getBlock(FORK_BLOCK); - const currentBlock = await tempProvider.getBlock('latest'); - - // create correct sign for deposit message for pk - const goodDepositMessage = { - pubkey: pk, - withdrawalCredentials: fromHexString(GOOD_WC), - amount: 32000000000, // gwei! - }; - const goodSigningRoot = computeRoot(goodDepositMessage); - const goodSig = sk.sign(goodSigningRoot).toBytes(); - - const unusedKeys = [ - { - key: toHexString(pk), - depositSignature: toHexString(goodSig), - operatorIndex: 0, - used: false, - index: 0, - moduleAddress: NOP_REGISTRY, - }, - // simple dvt - { - key: '0xb3c90525010a5710d43acbea46047fc37ed55306d032527fa15dd7e8cd8a9a5fa490347cc5fce59936fb8300683cd9f3', - depositSignature: - '0x8a77d9411781360cc107344a99f6660b206d2c708ae7fa35565b76ec661a0b86b6c78f5b5691d2cf469c27d0655dfc6311451a9e0501f3c19c6f7e35a770d1a908bfec7cba2e07339dc633b8b6626216ce76ec0fa48ee56aaaf2f9dc7ccb2fe2', - operatorIndex: 0, - used: false, - moduleAddress: FAKE_SIMPLE_DVT, - index: 0, - }, - ]; - - // mocked curated module - const stakingModule = mockedModule(currentBlock, currentBlock.hash); - const stakingDvtModule = mockedModuleDvt(currentBlock, currentBlock.hash); - const meta = mockedMeta(currentBlock, currentBlock.hash); - - mockedKeysApiOperatorsMany( - keysApiService, - [ - { operators: mockedOperators, module: stakingModule }, - { operators: mockedDvtOperators, module: stakingDvtModule }, - ], - meta, - ); - - mockedKeysApiUnusedKeys(keysApiService, unusedKeys, meta); - mockedKeysWithDuplicates(keysApiService, unusedKeys, meta); - - await depositService.setCachedEvents({ - data: [ - { - valid: true, - pubkey: toHexString(pk), - amount: '32000000000', - wc: GOOD_WC, - signature: toHexString(goodSig), - tx: '0x123', - blockHash: forkBlock.hash, - blockNumber: forkBlock.number, - logIndex: 1, - }, - ], - headers: { - startBlock: currentBlock.number, - endBlock: currentBlock.number, - version: '1', - }, - }); - const originalIsDepositsPaused = securityService.isDepositsPaused; - // as we have faked simple dvt - jest - .spyOn(securityService, 'isDepositsPaused') - .mockImplementation((stakingModuleId, blockTag) => { - if (stakingModuleId === stakingDvtModule.id) { - return Promise.resolve(false); - } - return originalIsDepositsPaused.call( - securityService, - stakingModuleId, - blockTag, - ); - }); - - // Check if the service is ok and ready to go - await guardianService.handleNewBlock(); - - const badDepositMessage = { - pubkey: pk, - withdrawalCredentials: fromHexString(BAD_WC), - amount: 1000000000, // gwei! - }; - const badSigningRoot = computeRoot(badDepositMessage); - const badSig = sk.sign(badSigningRoot).toBytes(); - - const badDepositData = { - ...badDepositMessage, - signature: badSig, - }; - const badDepositDataRoot = DepositData.hashTreeRoot(badDepositData); - - if (!process.env.WALLET_PRIVATE_KEY) throw new Error(NO_PRIVKEY_MESSAGE); - const wallet = new ethers.Wallet(process.env.WALLET_PRIVATE_KEY); - - // Make a bad deposit - const signer = wallet.connect(providerService.provider); - const depositContract = DepositAbi__factory.connect( - DEPOSIT_CONTRACT, - signer, - ); - await depositContract.deposit( - badDepositData.pubkey, - badDepositData.withdrawalCredentials, - badDepositData.signature, - badDepositDataRoot, - { value: ethers.constants.WeiPerEther.mul(1) }, - ); - - // Mock Keys API again on new block - const newBlock = await providerService.provider.getBlock('latest'); - const newMeta = mockedMeta(newBlock, newBlock.hash); - const updatedStakingModule = mockedModule(currentBlock, newBlock.hash); - - mockedKeysApiOperatorsMany( - keysApiService, - [ - { operators: mockedOperators, module: updatedStakingModule }, - { operators: mockedDvtOperators, module: stakingDvtModule }, - ], - newMeta, - ); - - mockedKeysApiUnusedKeys(keysApiService, unusedKeys, newMeta); - - sendDepositMessage.mockReset(); - // Run a cycle and wait for possible changes - await guardianService.handleNewBlock(); - - await new Promise((res) => setTimeout(res, SLEEP_FOR_RESULT)); - - // Check if on pause now - const routerContract = StakingRouterAbi__factory.connect( - STAKING_ROUTER, - providerService.provider, - ); - const isOnPause = await routerContract.getStakingModuleIsDepositsPaused( - 1, - ); - - expect(isOnPause).toBe(true); - - expect(sendPauseMessage).toBeCalledTimes(1); - expect(sendPauseMessage).toHaveBeenCalledWith( - expect.objectContaining({ - blockNumber: newBlock.number, - guardianAddress: wallet.address, - guardianIndex: 9, - stakingModuleId: 1, - }), - ); - - expect(sendDepositMessage).toBeCalledTimes(1); - expect(sendDepositMessage).toHaveBeenCalledWith( - expect.objectContaining({ - blockNumber: newBlock.number, - guardianAddress: wallet.address, - guardianIndex: 9, - stakingModuleId: 2, - }), - ); - }, - TESTS_TIMEOUT, - ); - - test( - 'failed 1eth deposit attack to stop deposits (free money)', - async () => { - const tempProvider = new ethers.providers.JsonRpcProvider( - `http://127.0.0.1:${GANACHE_PORT}`, - ); - const currentBlock = await tempProvider.getBlock('latest'); - - // mock kapi response - const goodDepositMessage = { - pubkey: pk, - withdrawalCredentials: fromHexString(GOOD_WC), - amount: 32000000000, // gwei! - }; - const goodSigningRoot = computeRoot(goodDepositMessage); - const goodSig = sk.sign(goodSigningRoot).toBytes(); - - const unusedKeys = [ - { - key: toHexString(pk), - depositSignature: toHexString(goodSig), - operatorIndex: 0, - used: false, - index: 0, - moduleAddress: NOP_REGISTRY, - }, - ]; - - const meta = mockedMeta(currentBlock, currentBlock.hash); - const stakingModule = mockedModule(currentBlock, currentBlock.hash); - - mockedKeysApiOperators( - keysApiService, - mockedOperators, - stakingModule, - meta, - ); - - mockedKeysApiUnusedKeys(keysApiService, unusedKeys, meta); - - await depositService.setCachedEvents({ - data: [], - headers: { - startBlock: currentBlock.number, - endBlock: currentBlock.number, - version: '1', - }, - }); - - // Check if the service is ok and ready to go - await guardianService.handleNewBlock(); - - const badDepositMessage = { - pubkey: pk, - withdrawalCredentials: fromHexString(GOOD_WC), - amount: 1000000000, // gwei! - }; - const badSigningRoot = computeRoot(badDepositMessage); - const badSig = sk.sign(badSigningRoot).toBytes(); - - const badDepositData = { - ...badDepositMessage, - signature: badSig, - }; - const badDepositDataRoot = DepositData.hashTreeRoot(badDepositData); - - if (!process.env.WALLET_PRIVATE_KEY) throw new Error(NO_PRIVKEY_MESSAGE); - const wallet = new ethers.Wallet(process.env.WALLET_PRIVATE_KEY); - - // Make a bad deposit - const signer = wallet.connect(providerService.provider); - const depositContract = DepositAbi__factory.connect( - DEPOSIT_CONTRACT, - signer, - ); - await depositContract.deposit( - badDepositData.pubkey, - badDepositData.withdrawalCredentials, - badDepositData.signature, - badDepositDataRoot, - { value: ethers.constants.WeiPerEther.mul(1) }, - ); - - // Mock Keys API again on new block - const newBlock = await providerService.provider.getBlock('latest'); - const newMeta = mockedMeta(newBlock, newBlock.hash); - const newStakingModule = mockedModule(currentBlock, currentBlock.hash); - - mockedKeysApiOperators( - keysApiService, - mockedOperators, - newStakingModule, - newMeta, - ); - mockedKeysApiUnusedKeys(keysApiService, unusedKeys, newMeta); - // we make check that there are no duplicated used keys - // this request return keys along with their duplicates - mockedKeysWithDuplicates(keysApiService, unusedKeys, newMeta); - - // Run a cycle and wait for possible changes - await guardianService.handleNewBlock(); - await new Promise((res) => setTimeout(res, SLEEP_FOR_RESULT)); - - // Check if on pause now - const routerContract = StakingRouterAbi__factory.connect( - STAKING_ROUTER, - providerService.provider, - ); - const isOnPause = await routerContract.getStakingModuleIsDepositsPaused( - 1, - ); - expect(isOnPause).toBe(false); - }, - TESTS_TIMEOUT, - ); - - test( - 'failed 1eth deposit attack to stop deposits with a wrong signature and wc', - async () => { - const tempProvider = new ethers.providers.JsonRpcProvider( - `http://127.0.0.1:${GANACHE_PORT}`, - ); - const currentBlock = await tempProvider.getBlock('latest'); - const goodDepositMessage = { - pubkey: pk, - withdrawalCredentials: fromHexString(GOOD_WC), - amount: 32000000000, // gwei! - }; - const goodSigningRoot = computeRoot(goodDepositMessage); - const goodSig = sk.sign(goodSigningRoot).toBytes(); - - const unusedKeys = [ - { - key: toHexString(pk), - depositSignature: toHexString(goodSig), - operatorIndex: 0, - used: false, - index: 0, - moduleAddress: NOP_REGISTRY, - }, - ]; - - const meta = mockedMeta(currentBlock, currentBlock.hash); - const stakingModule = mockedModule(currentBlock, currentBlock.hash); - - mockedKeysApiOperators( - keysApiService, - mockedOperators, - stakingModule, - meta, - ); - - mockedKeysApiUnusedKeys(keysApiService, unusedKeys, meta); - - await depositService.setCachedEvents({ - data: [], - headers: { - startBlock: currentBlock.number, - endBlock: currentBlock.number, - version: '1', - }, - }); - - // Check if the service is ok and ready to go - await guardianService.handleNewBlock(); - - const badDepositMessage = { - pubkey: pk, - withdrawalCredentials: fromHexString(BAD_WC), - amount: 1000000000, // gwei! - }; - - // Weird sig - const weirdDepositMessage = { - pubkey: pk, - withdrawalCredentials: fromHexString(BAD_WC), - amount: 0, // gwei! - }; - const weirdSigningRoot = computeRoot(weirdDepositMessage); - const weirdSig = sk.sign(weirdSigningRoot).toBytes(); - const badDepositData = { - ...badDepositMessage, - signature: weirdSig, - }; - const badDepositDataRoot = DepositData.hashTreeRoot(badDepositData); - if (!process.env.WALLET_PRIVATE_KEY) throw new Error(NO_PRIVKEY_MESSAGE); - const wallet = new ethers.Wallet(process.env.WALLET_PRIVATE_KEY); - // Make a bad deposit - const signer = wallet.connect(providerService.provider); - const depositContract = DepositAbi__factory.connect( - DEPOSIT_CONTRACT, - signer, - ); - await depositContract.deposit( - badDepositData.pubkey, - badDepositData.withdrawalCredentials, - badDepositData.signature, - badDepositDataRoot, - { value: ethers.constants.WeiPerEther.mul(1) }, - ); - // Mock Keys API again on new block - const newBlock = await providerService.provider.getBlock('latest'); - const newMeta = mockedMeta(newBlock, newBlock.hash); - const newStakingModule = mockedModule(currentBlock, newBlock.hash); - - mockedKeysApiOperators( - keysApiService, - mockedOperators, - newStakingModule, - newMeta, - ); - - mockedKeysApiUnusedKeys(keysApiService, unusedKeys, newMeta); - // Run a cycle and wait for possible changes - await guardianService.handleNewBlock(); - await new Promise((res) => setTimeout(res, SLEEP_FOR_RESULT)); - // Check if on pause now - const routerContract = StakingRouterAbi__factory.connect( - STAKING_ROUTER, - providerService.provider, - ); - const isOnPause = await routerContract.getStakingModuleIsDepositsPaused( - 1, - ); - expect(isOnPause).toBe(false); - }, - TESTS_TIMEOUT, - ); - - test( - 'good scenario', - async () => { - const tempProvider = new ethers.providers.JsonRpcProvider( - `http://127.0.0.1:${GANACHE_PORT}`, - ); - const currentBlock = await tempProvider.getBlock('latest'); - - // no diff - const goodDepositMessage = { - pubkey: pk, - withdrawalCredentials: fromHexString(GOOD_WC), - amount: 32000000000, // gwei! - }; - const goodSigningRoot = computeRoot(goodDepositMessage); - const goodSig = sk.sign(goodSigningRoot).toBytes(); - - const unusedKeys = [ - { - key: toHexString(pk), - depositSignature: toHexString(goodSig), - operatorIndex: 0, - used: false, - index: 0, - moduleAddress: NOP_REGISTRY, - }, - ]; - - const meta = mockedMeta(currentBlock, currentBlock.hash); - const stakingModule = mockedModule(currentBlock, currentBlock.hash); - - mockedKeysApiOperators( - keysApiService, - mockedOperators, - stakingModule, - meta, - ); - - mockedKeysApiUnusedKeys(keysApiService, unusedKeys, meta); - - const goodDepositData = { - ...goodDepositMessage, - signature: goodSig, - }; - const goodDepositDataRoot = DepositData.hashTreeRoot(goodDepositData); - - await depositService.setCachedEvents({ - data: [], - headers: { - startBlock: currentBlock.number, - endBlock: currentBlock.number, - version: '1', - }, - }); - - // Check if the service is ok and ready to go - await guardianService.handleNewBlock(); - - if (!process.env.WALLET_PRIVATE_KEY) throw new Error(NO_PRIVKEY_MESSAGE); - const wallet = new ethers.Wallet(process.env.WALLET_PRIVATE_KEY); - - // Make a deposit - const signer = wallet.connect(providerService.provider); - const depositContract = DepositAbi__factory.connect( - DEPOSIT_CONTRACT, - signer, - ); - await depositContract.deposit( - goodDepositData.pubkey, - goodDepositData.withdrawalCredentials, - goodDepositData.signature, - goodDepositDataRoot, - { value: ethers.constants.WeiPerEther.mul(32) }, - ); - - // Mock Keys API again on new block - const newBlock = await providerService.provider.getBlock('latest'); - const newMeta = mockedMeta(newBlock, newBlock.hash); - const newStakingModule = mockedModule(currentBlock, newBlock.hash); - - mockedKeysApiOperators( - keysApiService, - mockedOperators, - newStakingModule, - newMeta, - ); - - mockedKeysApiUnusedKeys(keysApiService, unusedKeys, newMeta); - mockedKeysWithDuplicates(keysApiService, unusedKeys, newMeta); - - // Run a cycle and wait for possible changes - await guardianService.handleNewBlock(); - - expect(sendDepositMessage).toHaveBeenLastCalledWith( - expect.objectContaining({ - blockNumber: newBlock.number, - guardianAddress: wallet.address, - guardianIndex: 9, - stakingModuleId: 1, - }), - ); - - await new Promise((res) => setTimeout(res, SLEEP_FOR_RESULT)); - - // Check if on pause now - const routerContract = StakingRouterAbi__factory.connect( - STAKING_ROUTER, - providerService.provider, - ); - const isOnPause = await routerContract.getStakingModuleIsDepositsPaused( - 1, - ); - expect(isOnPause).toBe(false); - }, - TESTS_TIMEOUT, - ); - - test( - 'reorganization', - async () => { - // TODO: need attention to this test - const tempProvider = new ethers.providers.JsonRpcProvider( - `http://127.0.0.1:${GANACHE_PORT}`, - ); - const currentBlock = await tempProvider.getBlock('latest'); - - const goodDepositMessage = { - pubkey: pk, - withdrawalCredentials: fromHexString(GOOD_WC), - amount: 32000000000, // gwei! - }; - const goodSigningRoot = computeRoot(goodDepositMessage); - const goodSig = sk.sign(goodSigningRoot).toBytes(); - - const unusedKeys = [ - { - key: toHexString(pk), - depositSignature: toHexString(goodSig), - operatorIndex: 0, - used: false, - index: 0, - moduleAddress: NOP_REGISTRY, - }, - ]; - - const meta = mockedMeta(currentBlock, currentBlock.hash); - const stakingModule = mockedModule(currentBlock, currentBlock.hash); - - mockedKeysApiOperators( - keysApiService, - mockedOperators, - stakingModule, - meta, - ); - - mockedKeysApiUnusedKeys(keysApiService, unusedKeys, meta); - - const goodDepositData = { - ...goodDepositMessage, - signature: goodSig, - }; - const goodDepositDataRoot = DepositData.hashTreeRoot(goodDepositData); - - await depositService.setCachedEvents({ - data: [], - headers: { - startBlock: currentBlock.number, - endBlock: currentBlock.number, - version: '1', - }, - }); - - // Check if the service is ok and ready to go - await guardianService.handleNewBlock(); - - // Wait for possible changes - await new Promise((res) => setTimeout(res, SLEEP_FOR_RESULT)); - - const routerContract = StakingRouterAbi__factory.connect( - STAKING_ROUTER, - providerService.provider, - ); - const isOnPauseBefore = - await routerContract.getStakingModuleIsDepositsPaused(1); - expect(isOnPauseBefore).toBe(false); - - if (!process.env.WALLET_PRIVATE_KEY) throw new Error(NO_PRIVKEY_MESSAGE); - const wallet = new ethers.Wallet(process.env.WALLET_PRIVATE_KEY); - - // Make a deposit - const signer = wallet.connect(providerService.provider); - const depositContract = DepositAbi__factory.connect( - DEPOSIT_CONTRACT, - signer, - ); - await depositContract.deposit( - goodDepositData.pubkey, - goodDepositData.withdrawalCredentials, - goodDepositData.signature, - goodDepositDataRoot, - { value: ethers.constants.WeiPerEther.mul(32) }, - ); - - // Mock Keys API again on new block, but now mark as used - const newBlock = await providerService.provider.getBlock('latest'); - const newMeta = mockedMeta(newBlock, newBlock.hash); - const newStakingModule = mockedModule(currentBlock, newBlock.hash); - - mockedKeysApiOperators( - keysApiService, - mockedOperators, - newStakingModule, - newMeta, - ); - - mockedKeysApiUnusedKeys(keysApiService, [], newMeta); - - // Run a cycle and wait for possible changes - await guardianService.handleNewBlock(); - await new Promise((res) => setTimeout(res, SLEEP_FOR_RESULT)); - - const isOnPauseMiddle = - await routerContract.getStakingModuleIsDepositsPaused(1); - expect(isOnPauseMiddle).toBe(false); - - // Simulating a reorg - await server.close(); - server = makeServer(FORK_BLOCK, CHAIN_ID, UNLOCKED_ACCOUNTS); - await server.listen(GANACHE_PORT); - - mockedKeysApiUnusedKeys(keysApiService, unusedKeys, newMeta); - mockedKeysWithDuplicates(keysApiService, unusedKeys, newMeta); - - // Check if on pause now - const isOnPauseAfter = - await routerContract.getStakingModuleIsDepositsPaused(1); - expect(isOnPauseAfter).toBe(false); - }, - TESTS_TIMEOUT, - ); - - test( - 'skip deposit if find duplicated key', - async () => { - const tempProvider = new ethers.providers.JsonRpcProvider( - `http://127.0.0.1:${GANACHE_PORT}`, - ); - const currentBlock = await tempProvider.getBlock('latest'); - - // this key should be used in kapi - const goodDepositMessage = { - pubkey: pk, - withdrawalCredentials: fromHexString(GOOD_WC), - amount: 32000000000, // gwei! - }; - const goodSigningRoot = computeRoot(goodDepositMessage); - const goodSig = sk.sign(goodSigningRoot).toBytes(); - - const goodDepositData = { - ...goodDepositMessage, - signature: goodSig, - }; - const goodDepositDataRoot = DepositData.hashTreeRoot(goodDepositData); - - if (!process.env.WALLET_PRIVATE_KEY) throw new Error(NO_PRIVKEY_MESSAGE); - const wallet = new ethers.Wallet(process.env.WALLET_PRIVATE_KEY); - - // Make a deposit - const signer = wallet.connect(providerService.provider); - const depositContract = DepositAbi__factory.connect( - DEPOSIT_CONTRACT, - signer, - ); - await depositContract.deposit( - goodDepositData.pubkey, - goodDepositData.withdrawalCredentials, - goodDepositData.signature, - goodDepositDataRoot, - { value: ethers.constants.WeiPerEther.mul(32) }, - ); - - await depositService.setCachedEvents({ - data: [], - headers: { - startBlock: currentBlock.number, - endBlock: currentBlock.number, - version: '1', - }, - }); - - // mocked curated module - const stakingModule = mockedModule(currentBlock, currentBlock.hash); - const stakingDvtModule = mockedModuleDvt(currentBlock, currentBlock.hash); - const meta = mockedMeta(currentBlock, currentBlock.hash); - - mockedKeysApiOperatorsMany( - keysApiService, - [ - { operators: mockedOperators, module: stakingModule }, - { operators: mockedDvtOperators, module: stakingDvtModule }, - ], - meta, - ); - - // list of keys for /keys?used=false mock - const unusedKeys = [ - { - key: '0xa9bfaa8207ee6c78644c079ffc91b6e5abcc5eede1b7a06abb8fb40e490a75ea269c178dd524b65185299d2bbd2eb7b2', - depositSignature: - '0xaa5f2a1053ba7d197495df44d4a32b7ae10265cf9e38560a16b782978c0a24271a113c9538453b7e45f35cb64c7adb460d7a9fe8c8ce6b8c80ca42fd5c48e180c73fc08f7d35ba32e39f32c902fd333faf47611827f0b7813f11c4c518dd2e59', - operatorIndex: 0, - used: false, - index: 0, - moduleAddress: NOP_REGISTRY, - }, - { - key: '0xa9bfaa8207ee6c78644c079ffc91b6e5abcc5eede1b7a06abb8fb40e490a75ea269c178dd524b65185299d2bbd2eb7b2', - depositSignature: - '0xaa5f2a1053ba7d197495df44d4a32b7ae10265cf9e38560a16b782978c0a24271a113c9538453b7e45f35cb64c7adb460d7a9fe8c8ce6b8c80ca42fd5c48e180c73fc08f7d35ba32e39f32c902fd333faf47611827f0b7813f11c4c518dd2e59', - operatorIndex: 0, - used: false, - index: 1, - moduleAddress: NOP_REGISTRY, - }, - { - key: '0xa9bfaa8207ee6c78644c079ffc91b6e5abcc5eede1b7a06abb8fb40e490a75ea269c178dd524b65185299d2bbd2eb7b2', - depositSignature: - '0xaa5f2a1053ba7d197495df44d4a32b7ae10265cf9e38560a16b782978c0a24271a113c9538453b7e45f35cb64c7adb460d7a9fe8c8ce6b8c80ca42fd5c48e180c73fc08f7d35ba32e39f32c902fd333faf47611827f0b7813f11c4c518dd2e59', - operatorIndex: 0, - used: false, - index: 12, - moduleAddress: NOP_REGISTRY, - }, - { - key: '0xb3c90525010a5710d43acbea46047fc37ed55306d032527fa15dd7e8cd8a9a5fa490347cc5fce59936fb8300683cd9f3', - depositSignature: - '0x8a77d9411781360cc107344a99f6660b206d2c708ae7fa35565b76ec661a0b86b6c78f5b5691d2cf469c27d0655dfc6311451a9e0501f3c19c6f7e35a770d1a908bfec7cba2e07339dc633b8b6626216ce76ec0fa48ee56aaaf2f9dc7ccb2fe2', - operatorIndex: 0, - used: false, - moduleAddress: FAKE_SIMPLE_DVT, - index: 0, - }, - ]; - - mockedKeysApiUnusedKeys(keysApiService, unusedKeys, meta); - - // Check that module was not paused - const routerContract = StakingRouterAbi__factory.connect( - STAKING_ROUTER, - providerService.provider, - ); - const isOnPause = await routerContract.getStakingModuleIsDepositsPaused( - 1, - ); - expect(isOnPause).toBe(false); - - const originalIsDepositsPaused = securityService.isDepositsPaused; - - // as we have faked simple dvt - jest - .spyOn(securityService, 'isDepositsPaused') - .mockImplementation((stakingModuleId, blockTag) => { - if (stakingModuleId === stakingDvtModule.id) { - return Promise.resolve(false); - } - return originalIsDepositsPaused.call( - securityService, - stakingModuleId, - blockTag, - ); - }); - - await guardianService.handleNewBlock(); - - // just skip on this iteration deposit for staking module - expect(sendDepositMessage).toBeCalledTimes(1); - expect(sendDepositMessage).toHaveBeenCalledWith( - expect.objectContaining({ - blockNumber: currentBlock.number, - guardianAddress: wallet.address, - guardianIndex: 9, - stakingModuleId: 2, - }), - ); - expect(sendPauseMessage).toBeCalledTimes(0); - - // after deleting duplicates in staking module, - // council will resume deposits to module - const unusedKeysWithoutDuplicates = [ - { - key: '0xa9bfaa8207ee6c78644c079ffc91b6e5abcc5eede1b7a06abb8fb40e490a75ea269c178dd524b65185299d2bbd2eb7b2', - depositSignature: - '0xaa5f2a1053ba7d197495df44d4a32b7ae10265cf9e38560a16b782978c0a24271a113c9538453b7e45f35cb64c7adb460d7a9fe8c8ce6b8c80ca42fd5c48e180c73fc08f7d35ba32e39f32c902fd333faf47611827f0b7813f11c4c518dd2e59', - operatorIndex: 0, - used: false, - index: 0, - moduleAddress: NOP_REGISTRY, - }, - { - key: '0xb3c90525010a5710d43acbea46047fc37ed55306d032527fa15dd7e8cd8a9a5fa490347cc5fce59936fb8300683cd9f3', - depositSignature: - '0x8a77d9411781360cc107344a99f6660b206d2c708ae7fa35565b76ec661a0b86b6c78f5b5691d2cf469c27d0655dfc6311451a9e0501f3c19c6f7e35a770d1a908bfec7cba2e07339dc633b8b6626216ce76ec0fa48ee56aaaf2f9dc7ccb2fe2', - operatorIndex: 0, - used: false, - moduleAddress: FAKE_SIMPLE_DVT, - index: 0, - }, - ]; - - const newBlock = await tempProvider.getBlock('latest'); - const newMeta = mockedMeta(newBlock, newBlock.hash); - const newStakingModule = mockedModule(newBlock, newBlock.hash); - const newStakingDvtModule = mockedModuleDvt(newBlock, newBlock.hash); - - mockedKeysApiOperatorsMany( - keysApiService, - [ - { operators: mockedOperators, module: newStakingModule }, - { operators: mockedDvtOperators, module: newStakingDvtModule }, - ], - newMeta, - ); - - mockedKeysApiUnusedKeys( - keysApiService, - unusedKeysWithoutDuplicates, - newMeta, - ); - - sendDepositMessage.mockReset(); - - await guardianService.handleNewBlock(); - - expect(sendDepositMessage).toBeCalledTimes(2); - - expect(sendDepositMessage).toHaveBeenLastCalledWith( - expect.objectContaining({ - blockNumber: newBlock.number, - guardianAddress: wallet.address, - guardianIndex: 9, - stakingModuleId: 1, - }), - ); - - expect(sendDepositMessage).toHaveBeenCalledWith( - expect.objectContaining({ - blockNumber: newBlock.number, - guardianAddress: wallet.address, - guardianIndex: 9, - stakingModuleId: 2, - }), - ); - - jest.spyOn(securityService, 'isDepositsPaused').mockRestore(); - }, - TESTS_TIMEOUT, - ); - - test( - 'skip deposit if find duplicated key in another staking module', - async () => { - const tempProvider = new ethers.providers.JsonRpcProvider( - `http://127.0.0.1:${GANACHE_PORT}`, - ); - const currentBlock = await tempProvider.getBlock('latest'); - - // this key should be used in kapi - const goodDepositMessage = { - pubkey: pk, - withdrawalCredentials: fromHexString(GOOD_WC), - amount: 32000000000, // gwei! - }; - const goodSigningRoot = computeRoot(goodDepositMessage); - const goodSig = sk.sign(goodSigningRoot).toBytes(); - - const goodDepositData = { - ...goodDepositMessage, - signature: goodSig, - }; - const goodDepositDataRoot = DepositData.hashTreeRoot(goodDepositData); - - if (!process.env.WALLET_PRIVATE_KEY) throw new Error(NO_PRIVKEY_MESSAGE); - const wallet = new ethers.Wallet(process.env.WALLET_PRIVATE_KEY); - - // Make a deposit - const signer = wallet.connect(providerService.provider); - const depositContract = DepositAbi__factory.connect( - DEPOSIT_CONTRACT, - signer, - ); - await depositContract.deposit( - goodDepositData.pubkey, - goodDepositData.withdrawalCredentials, - goodDepositData.signature, - goodDepositDataRoot, - { value: ethers.constants.WeiPerEther.mul(32) }, - ); - - await depositService.setCachedEvents({ - data: [], - headers: { - startBlock: currentBlock.number, - endBlock: currentBlock.number, - version: '1', - }, - }); - - // mocked curated module - const stakingModule = mockedModule(currentBlock, currentBlock.hash); - const stakingDvtModule = mockedModuleDvt(currentBlock, currentBlock.hash); - const meta = mockedMeta(currentBlock, currentBlock.hash); - - mockedKeysApiOperatorsMany( - keysApiService, - [ - { operators: mockedOperators, module: stakingModule }, - { operators: mockedDvtOperators, module: stakingDvtModule }, - ], - meta, - ); - - // list of keys for /keys?used=false mock - const unusedKeys = [ - { - key: '0xa9bfaa8207ee6c78644c079ffc91b6e5abcc5eede1b7a06abb8fb40e490a75ea269c178dd524b65185299d2bbd2eb7b2', - depositSignature: - '0xaa5f2a1053ba7d197495df44d4a32b7ae10265cf9e38560a16b782978c0a24271a113c9538453b7e45f35cb64c7adb460d7a9fe8c8ce6b8c80ca42fd5c48e180c73fc08f7d35ba32e39f32c902fd333faf47611827f0b7813f11c4c518dd2e59', - operatorIndex: 0, - used: false, - index: 0, - moduleAddress: NOP_REGISTRY, - }, - { - key: '0xa9bfaa8207ee6c78644c079ffc91b6e5abcc5eede1b7a06abb8fb40e490a75ea269c178dd524b65185299d2bbd2eb7b2', - depositSignature: - '0xaa5f2a1053ba7d197495df44d4a32b7ae10265cf9e38560a16b782978c0a24271a113c9538453b7e45f35cb64c7adb460d7a9fe8c8ce6b8c80ca42fd5c48e180c73fc08f7d35ba32e39f32c902fd333faf47611827f0b7813f11c4c518dd2e59', - operatorIndex: 0, - used: false, - index: 0, - moduleAddress: FAKE_SIMPLE_DVT, - }, - ]; - - mockedKeysApiUnusedKeys(keysApiService, unusedKeys, meta); - - // Check that module was not paused - const routerContract = StakingRouterAbi__factory.connect( - STAKING_ROUTER, - providerService.provider, - ); - const isOnPause = await routerContract.getStakingModuleIsDepositsPaused( - 1, - ); - expect(isOnPause).toBe(false); - - await guardianService.handleNewBlock(); - - // just skip on this iteration deposit for staking module - expect(sendDepositMessage).toBeCalledTimes(0); - expect(sendPauseMessage).toBeCalledTimes(0); - - // after deleting duplicates in staking module, - // council will resume deposits to module - const unusedKeysWithoutDuplicates = [ - { - key: '0xa9bfaa8207ee6c78644c079ffc91b6e5abcc5eede1b7a06abb8fb40e490a75ea269c178dd524b65185299d2bbd2eb7b2', - depositSignature: - '0xaa5f2a1053ba7d197495df44d4a32b7ae10265cf9e38560a16b782978c0a24271a113c9538453b7e45f35cb64c7adb460d7a9fe8c8ce6b8c80ca42fd5c48e180c73fc08f7d35ba32e39f32c902fd333faf47611827f0b7813f11c4c518dd2e59', - operatorIndex: 0, - used: false, - index: 0, - moduleAddress: NOP_REGISTRY, - }, - { - key: '0xb3c90525010a5710d43acbea46047fc37ed55306d032527fa15dd7e8cd8a9a5fa490347cc5fce59936fb8300683cd9f3', - depositSignature: - '0x8a77d9411781360cc107344a99f6660b206d2c708ae7fa35565b76ec661a0b86b6c78f5b5691d2cf469c27d0655dfc6311451a9e0501f3c19c6f7e35a770d1a908bfec7cba2e07339dc633b8b6626216ce76ec0fa48ee56aaaf2f9dc7ccb2fe2', - operatorIndex: 0, - used: false, - moduleAddress: FAKE_SIMPLE_DVT, - index: 0, - }, - ]; - - const newBlock = await tempProvider.getBlock('latest'); - const newMeta = mockedMeta(newBlock, newBlock.hash); - const newStakingModule = mockedModule(newBlock, newBlock.hash); - const newStakingDvtModule = mockedModuleDvt(newBlock, newBlock.hash); - - mockedKeysApiOperatorsMany( - keysApiService, - [ - { operators: mockedOperators, module: newStakingModule }, - { operators: mockedDvtOperators, module: newStakingDvtModule }, - ], - newMeta, - ); - - mockedKeysApiUnusedKeys( - keysApiService, - unusedKeysWithoutDuplicates, - newMeta, - ); - - const originalIsDepositsPaused = securityService.isDepositsPaused; - - // as we have faked simple dvt - jest - .spyOn(securityService, 'isDepositsPaused') - .mockImplementation((stakingModuleId, blockTag) => { - if (stakingModuleId === newStakingDvtModule.id) { - return Promise.resolve(false); - } - return originalIsDepositsPaused.call( - securityService, - stakingModuleId, - blockTag, - ); - }); - - sendDepositMessage.mockReset(); - - await guardianService.handleNewBlock(); - - expect(sendDepositMessage).toBeCalledTimes(2); - - expect(sendDepositMessage).toHaveBeenCalledWith( - expect.objectContaining({ - blockNumber: newBlock.number, - guardianAddress: wallet.address, - guardianIndex: 9, - stakingModuleId: 1, - }), - ); - - expect(sendDepositMessage).toHaveBeenCalledWith( - expect.objectContaining({ - blockNumber: newBlock.number, - guardianAddress: wallet.address, - guardianIndex: 9, - stakingModuleId: 2, - }), - ); - - jest.spyOn(securityService, 'isDepositsPaused').mockRestore(); - }, - TESTS_TIMEOUT, - ); - - test( - 'inconsistent kapi requests data', - async () => { - const tempProvider = new ethers.providers.JsonRpcProvider( - `http://127.0.0.1:${GANACHE_PORT}`, - ); - const currentBlock = await tempProvider.getBlock('latest'); - - const goodDepositMessage = { - pubkey: pk, - withdrawalCredentials: fromHexString(GOOD_WC), - amount: 32000000000, // gwei! - }; - const goodSigningRoot = computeRoot(goodDepositMessage); - const goodSig = sk.sign(goodSigningRoot).toBytes(); - - const goodDepositData = { - ...goodDepositMessage, - signature: goodSig, - }; - const goodDepositDataRoot = DepositData.hashTreeRoot(goodDepositData); - - if (!process.env.WALLET_PRIVATE_KEY) throw new Error(NO_PRIVKEY_MESSAGE); - const wallet = new ethers.Wallet(process.env.WALLET_PRIVATE_KEY); - - // Make a deposit - const signer = wallet.connect(providerService.provider); - const depositContract = DepositAbi__factory.connect( - DEPOSIT_CONTRACT, - signer, - ); - await depositContract.deposit( - goodDepositData.pubkey, - goodDepositData.withdrawalCredentials, - goodDepositData.signature, - goodDepositDataRoot, - { value: ethers.constants.WeiPerEther.mul(32) }, - ); - - await depositService.setCachedEvents({ - data: [], - headers: { - startBlock: currentBlock.number, - endBlock: currentBlock.number, - version: '1', - }, - }); - - // mocked curated module - const stakingModule = mockedModule(currentBlock, currentBlock.hash); - const meta = mockedMeta(currentBlock, currentBlock.hash); - - mockedKeysApiOperators( - keysApiService, - mockedOperators, - stakingModule, - meta, - ); - - // list of keys for /keys?used=false mock - const unusedKeys = [ - { - key: '0xa9bfaa8207ee6c78644c079ffc91b6e5abcc5eede1b7a06abb8fb40e490a75ea269c178dd524b65185299d2bbd2eb7b2', - depositSignature: - '0xaa5f2a1053ba7d197495df44d4a32b7ae10265cf9e38560a16b782978c0a24271a113c9538453b7e45f35cb64c7adb460d7a9fe8c8ce6b8c80ca42fd5c48e180c73fc08f7d35ba32e39f32c902fd333faf47611827f0b7813f11c4c518dd2e59', - operatorIndex: 0, - used: false, - index: 0, - moduleAddress: NOP_REGISTRY, - }, - ]; - - const hashWasChanged = - '0xd921055dbb407e09f64afe5182a64c1bd309fe28f26909a96425cdb6bfc48959'; - const newMeta = mockedMeta(currentBlock, hashWasChanged); - mockedKeysApiUnusedKeys(keysApiService, unusedKeys, newMeta); - - await guardianService.handleNewBlock(); - - expect(sendDepositMessage).toBeCalledTimes(0); - expect(sendPauseMessage).toBeCalledTimes(0); - }, - TESTS_TIMEOUT, - ); - - test( - 'added unused keys for that deposit was already made', - async () => { - const tempProvider = new ethers.providers.JsonRpcProvider( - `http://127.0.0.1:${GANACHE_PORT}`, - ); - const currentBlock = await tempProvider.getBlock('latest'); - - // this key should be used in kapi - const goodDepositMessage = { - pubkey: pk, - withdrawalCredentials: fromHexString(GOOD_WC), - amount: 32000000000, // gwei! - }; - const goodSigningRoot = computeRoot(goodDepositMessage); - const goodSig = sk.sign(goodSigningRoot).toBytes(); - - const goodDepositData = { - ...goodDepositMessage, - signature: goodSig, - }; - const goodDepositDataRoot = DepositData.hashTreeRoot(goodDepositData); - - if (!process.env.WALLET_PRIVATE_KEY) throw new Error(NO_PRIVKEY_MESSAGE); - const wallet = new ethers.Wallet(process.env.WALLET_PRIVATE_KEY); - - // Make a deposit - const signer = wallet.connect(providerService.provider); - const depositContract = DepositAbi__factory.connect( - DEPOSIT_CONTRACT, - signer, - ); - await depositContract.deposit( - goodDepositData.pubkey, - goodDepositData.withdrawalCredentials, - goodDepositData.signature, - goodDepositDataRoot, - { value: ethers.constants.WeiPerEther.mul(32) }, - ); - - await depositService.setCachedEvents({ - data: [], - headers: { - startBlock: currentBlock.number, - endBlock: currentBlock.number, - version: '1', - }, - }); - - // mocked curated module - const stakingModule = mockedModule(currentBlock, currentBlock.hash); - const meta = mockedMeta(currentBlock, currentBlock.hash); - - mockedKeysApiOperators( - keysApiService, - mockedOperators, - stakingModule, - meta, - ); - - // list of keys for /keys?used=false mock - const unusedKeys = [ - { - key: toHexString(pk), - depositSignature: toHexString(goodSig), - operatorIndex: 0, - used: false, - index: 0, - moduleAddress: NOP_REGISTRY, - }, - ]; - - const keys = [...unusedKeys, { ...unusedKeys[0], used: true }]; - mockedKeysApiUnusedKeys(keysApiService, unusedKeys, meta); - mockedKeysWithDuplicates(keysApiService, keys, meta); - - // Check that module was not paused - const routerContract = StakingRouterAbi__factory.connect( - STAKING_ROUTER, - providerService.provider, - ); - const isOnPause = await routerContract.getStakingModuleIsDepositsPaused( - 1, - ); - expect(isOnPause).toBe(false); - - await guardianService.handleNewBlock(); - - // just skip on this iteration deposit for staking module - expect(sendDepositMessage).toBeCalledTimes(0); - expect(sendPauseMessage).toBeCalledTimes(0); - - // after deleting duplicates in staking module, - // council will resume deposits to module - - const newBlock = await tempProvider.getBlock('latest'); - const newMeta = mockedMeta(newBlock, newBlock.hash); - const newStakingModule = mockedModule(currentBlock, newBlock.hash); - - mockedKeysApiOperators( - keysApiService, - mockedOperators, - newStakingModule, - newMeta, - ); - - mockedKeysApiUnusedKeys(keysApiService, [], newMeta); - mockedKeysWithDuplicates(keysApiService, [], meta); - - await guardianService.handleNewBlock(); - - expect(sendDepositMessage).toBeCalledTimes(1); - - expect(sendDepositMessage).toHaveBeenLastCalledWith( - expect.objectContaining({ - blockNumber: newBlock.number, - guardianAddress: wallet.address, - guardianIndex: 9, - stakingModuleId: 1, - }), - ); - }, - TESTS_TIMEOUT, - ); - - test( - 'should not validate keys if lastChangedBlock was not changed', - async () => { - const tempProvider = new ethers.providers.JsonRpcProvider( - `http://127.0.0.1:${GANACHE_PORT}`, - ); - const block0 = await tempProvider.getBlock('latest'); - - const goodDepositMessage = { - pubkey: pk, - withdrawalCredentials: fromHexString(GOOD_WC), - amount: 32000000000, // gwei! - }; - const goodSigningRoot = computeRoot(goodDepositMessage); - const goodSig = sk.sign(goodSigningRoot).toBytes(); - - const goodDepositData = { - ...goodDepositMessage, - signature: goodSig, - }; - const goodDepositDataRoot = DepositData.hashTreeRoot(goodDepositData); - - if (!process.env.WALLET_PRIVATE_KEY) throw new Error(NO_PRIVKEY_MESSAGE); - const wallet = new ethers.Wallet(process.env.WALLET_PRIVATE_KEY); - - // Make a deposit - const signer = wallet.connect(providerService.provider); - const depositContract = DepositAbi__factory.connect( - DEPOSIT_CONTRACT, - signer, - ); - await depositContract.deposit( - goodDepositData.pubkey, - goodDepositData.withdrawalCredentials, - goodDepositData.signature, - goodDepositDataRoot, - { value: ethers.constants.WeiPerEther.mul(32) }, - ); - - await depositService.setCachedEvents({ - data: [], - headers: { - startBlock: block0.number, - endBlock: block0.number, - version: '1', - }, - }); - - // mocked curated module - const stakingModule = mockedModule(block0, block0.hash); - const meta = mockedMeta(block0, block0.hash); - - mockedKeysApiOperators( - keysApiService, - mockedOperators, - stakingModule, - meta, - ); - - const keyWithWrongSign = { - key: toHexString(pk), - // just some random sign - depositSignature: - '0x8bf4401a354de243a3716ee2efc0bde1ded56a40e2943ac7c50290bec37e935d6170b21e7c0872f203199386143ef12612a1488a8e9f1cdf1229c382f29c326bcbf6ed6a87d8fbfe0df87dacec6632fc4709d9d338f4cf81e861d942c23bba1e', - operatorIndex: 0, - used: false, - index: 0, - moduleAddress: NOP_REGISTRY, - }; - // list of keys for /keys?used=false mock - mockedKeysApiUnusedKeys(keysApiService, [keyWithWrongSign], meta); - mockedKeysWithDuplicates(keysApiService, [], meta); - - expect( - stakingModuleGuardService['lastContractsStateByModuleId'][ - stakingModule.id - ], - ).not.toBeDefined(); - - await guardianService.handleNewBlock(); - - expect(validateKeys).toBeCalledTimes(1); - expect(validateKeys).toBeCalledWith( - expect.arrayContaining([ - expect.objectContaining({ - key: toHexString(pk), - // just some random sign - depositSignature: - '0x8bf4401a354de243a3716ee2efc0bde1ded56a40e2943ac7c50290bec37e935d6170b21e7c0872f203199386143ef12612a1488a8e9f1cdf1229c382f29c326bcbf6ed6a87d8fbfe0df87dacec6632fc4709d9d338f4cf81e861d942c23bba1e', - }), - ]), - ); - - expect(sendDepositMessage).toBeCalledTimes(0); - expect(sendPauseMessage).toBeCalledTimes(0); - - await new Promise((res) => setTimeout(res, SLEEP_FOR_RESULT)); - - // TEST: If lastChangeBlockHash was not changed, validation will not be called - const block1 = await tempProvider.getBlock('latest'); - - await depositService.setCachedEvents({ - data: [], - headers: { - startBlock: block1.number, - endBlock: block1.number, - version: '1', - }, - }); - - // mocked curated module - // lastChangeBlockHash will not change - const meta1 = mockedMeta(block1, block0.hash); - const stakingModule1 = mockedModule(block1, block0.hash, 6047); - - mockedKeysApiOperators( - keysApiService, - mockedOperators, - stakingModule1, - meta1, - ); - - // list of keys for /keys?used=false mock - mockedKeysApiUnusedKeys(keysApiService, [keyWithWrongSign], meta1); - - validateKeys.mockClear(); - - // put in state that we found invalid keys - expect( - stakingModuleGuardService['lastContractsStateByModuleId'][ - stakingModule1.id - ]?.invalidKeysFound, - ).toBeTruthy(); - - await guardianService.handleNewBlock(); - - expect(validateKeys).toBeCalledTimes(0); - expect(sendDepositMessage).toBeCalledTimes(0); - expect(sendPauseMessage).toBeCalledTimes(0); - }, - TESTS_TIMEOUT, - ); - - test('should validate keys if lastChangedBlock was changed', async () => { - const tempProvider = new ethers.providers.JsonRpcProvider( - `http://127.0.0.1:${GANACHE_PORT}`, - ); - const block0 = await tempProvider.getBlock('latest'); - - const goodDepositMessage = { - pubkey: pk, - withdrawalCredentials: fromHexString(GOOD_WC), - amount: 32000000000, // gwei! - }; - const goodSigningRoot = computeRoot(goodDepositMessage); - const goodSig = sk.sign(goodSigningRoot).toBytes(); - - const goodDepositData = { - ...goodDepositMessage, - signature: goodSig, - }; - const goodDepositDataRoot = DepositData.hashTreeRoot(goodDepositData); - - if (!process.env.WALLET_PRIVATE_KEY) throw new Error(NO_PRIVKEY_MESSAGE); - const wallet = new ethers.Wallet(process.env.WALLET_PRIVATE_KEY); - - // Make a deposit - const signer = wallet.connect(providerService.provider); - const depositContract = DepositAbi__factory.connect( - DEPOSIT_CONTRACT, - signer, - ); - await depositContract.deposit( - goodDepositData.pubkey, - goodDepositData.withdrawalCredentials, - goodDepositData.signature, - goodDepositDataRoot, - { value: ethers.constants.WeiPerEther.mul(32) }, - ); - - await depositService.setCachedEvents({ - data: [], - headers: { - startBlock: block0.number, - endBlock: block0.number, - version: '1', - }, - }); - - // mocked curated module - const stakingModule = mockedModule(block0, block0.hash); - const meta = mockedMeta(block0, block0.hash); - - mockedKeysApiOperators( - keysApiService, - mockedOperators, - stakingModule, - meta, - ); - - const keyWithWrongSign = { - key: toHexString(pk), - // just some random sign - depositSignature: - '0x8bf4401a354de243a3716ee2efc0bde1ded56a40e2943ac7c50290bec37e935d6170b21e7c0872f203199386143ef12612a1488a8e9f1cdf1229c382f29c326bcbf6ed6a87d8fbfe0df87dacec6632fc4709d9d338f4cf81e861d942c23bba1e', - operatorIndex: 0, - used: false, - index: 0, - moduleAddress: NOP_REGISTRY, - }; - // list of keys for /keys?used=false mock - mockedKeysApiUnusedKeys(keysApiService, [keyWithWrongSign], meta); - mockedKeysWithDuplicates(keysApiService, [], meta); - - expect( - stakingModuleGuardService['lastContractsStateByModuleId'][ - stakingModule.id - ], - ).not.toBeDefined(); - - await guardianService.handleNewBlock(); - - expect(validateKeys).toBeCalledTimes(1); - expect(validateKeys).toBeCalledWith( - expect.arrayContaining([ - expect.objectContaining({ - key: toHexString(pk), - // just some random sign - depositSignature: - '0x8bf4401a354de243a3716ee2efc0bde1ded56a40e2943ac7c50290bec37e935d6170b21e7c0872f203199386143ef12612a1488a8e9f1cdf1229c382f29c326bcbf6ed6a87d8fbfe0df87dacec6632fc4709d9d338f4cf81e861d942c23bba1e', - }), - ]), - ); - - expect(sendDepositMessage).toBeCalledTimes(0); - expect(sendPauseMessage).toBeCalledTimes(0); - - await new Promise((res) => setTimeout(res, SLEEP_FOR_RESULT)); - - // TEST: If lastChangeBlockHash was changed, validation will be called - const block1 = await tempProvider.getBlock('latest'); - - await depositService.setCachedEvents({ - data: [], - headers: { - startBlock: block1.number, - endBlock: block1.number, - version: '1', - }, - }); - - // mocked curated module - // lastChangeBlockHash will not change - const meta1 = mockedMeta(block1, block1.hash); - const stakingModule1 = mockedModule(block1, block1.hash, 6047); - - const fixedKey = { - ...keyWithWrongSign, - depositSignature: toHexString(goodSig), - }; - - // list of keys for /keys?used=false mock - mockedKeysApiUnusedKeys(keysApiService, [fixedKey], meta1); - - mockedKeysApiOperators( - keysApiService, - mockedOperators, - stakingModule1, - meta1, - ); - - validateKeys.mockClear(); - - expect( - stakingModuleGuardService['lastContractsStateByModuleId'][ - stakingModule1.id - ]?.invalidKeysFound, - ).toBeTruthy(); - - await guardianService.handleNewBlock(); - - expect(validateKeys).toBeCalledTimes(1); - expect(validateKeys).toBeCalledWith( - expect.arrayContaining([ - expect.objectContaining({ - key: toHexString(pk), - // just some random sign - depositSignature: toHexString(goodSig), - }), - ]), - ); - - expect(sendDepositMessage).toBeCalledTimes(1); - expect(sendDepositMessage).toHaveBeenLastCalledWith( - expect.objectContaining({ - blockNumber: block1.number, - guardianAddress: wallet.address, - guardianIndex: 9, - stakingModuleId: 1, - }), - ); - - expect(sendPauseMessage).toBeCalledTimes(0); - }); - - test('should not skip deposits if invalid keys where found in another module', async () => { - const tempProvider = new ethers.providers.JsonRpcProvider( - `http://127.0.0.1:${GANACHE_PORT}`, - ); - const block0 = await tempProvider.getBlock('latest'); - - const goodDepositMessage = { - pubkey: pk, - withdrawalCredentials: fromHexString(GOOD_WC), - amount: 32000000000, // gwei! - }; - const goodSigningRoot = computeRoot(goodDepositMessage); - const goodSig = sk.sign(goodSigningRoot).toBytes(); - - const goodDepositData = { - ...goodDepositMessage, - signature: goodSig, - }; - const goodDepositDataRoot = DepositData.hashTreeRoot(goodDepositData); - - if (!process.env.WALLET_PRIVATE_KEY) throw new Error(NO_PRIVKEY_MESSAGE); - const wallet = new ethers.Wallet(process.env.WALLET_PRIVATE_KEY); - - // Make a deposit - const signer = wallet.connect(providerService.provider); - const depositContract = DepositAbi__factory.connect( - DEPOSIT_CONTRACT, - signer, - ); - await depositContract.deposit( - goodDepositData.pubkey, - goodDepositData.withdrawalCredentials, - goodDepositData.signature, - goodDepositDataRoot, - { value: ethers.constants.WeiPerEther.mul(32) }, - ); - - await depositService.setCachedEvents({ - data: [], - headers: { - startBlock: block0.number, - endBlock: block0.number, - version: '1', - }, - }); - - // mocked curated module - const stakingModule = mockedModule(block0, block0.hash); - const stakingDvtModule = mockedModuleDvt(block0, block0.hash); - const meta = mockedMeta(block0, block0.hash); - - mockedKeysApiOperatorsMany( - keysApiService, - [ - { operators: mockedOperators, module: stakingModule }, - { operators: mockedDvtOperators, module: stakingDvtModule }, - ], - meta, - ); - - const keyWithWrongSign = { - key: toHexString(pk), - // just some random sign - depositSignature: - '0x8bf4401a354de243a3716ee2efc0bde1ded56a40e2943ac7c50290bec37e935d6170b21e7c0872f203199386143ef12612a1488a8e9f1cdf1229c382f29c326bcbf6ed6a87d8fbfe0df87dacec6632fc4709d9d338f4cf81e861d942c23bba1e', - operatorIndex: 0, - used: false, - index: 0, - moduleAddress: NOP_REGISTRY, - }; - const dvtKey = { - key: '0xa9bfaa8207ee6c78644c079ffc91b6e5abcc5eede1b7a06abb8fb40e490a75ea269c178dd524b65185299d2bbd2eb7b2', - depositSignature: - '0xaa5f2a1053ba7d197495df44d4a32b7ae10265cf9e38560a16b782978c0a24271a113c9538453b7e45f35cb64c7adb460d7a9fe8c8ce6b8c80ca42fd5c48e180c73fc08f7d35ba32e39f32c902fd333faf47611827f0b7813f11c4c518dd2e59', - operatorIndex: 0, - used: false, - index: 0, - moduleAddress: FAKE_SIMPLE_DVT, - }; - // list of keys for /keys?used=false mock - mockedKeysApiUnusedKeys(keysApiService, [keyWithWrongSign, dvtKey], meta); - mockedKeysWithDuplicates(keysApiService, [], meta); - - expect( - stakingModuleGuardService['lastContractsStateByModuleId'][ - stakingModule.id - ], - ).not.toBeDefined(); - - const originalIsDepositsPaused = securityService.isDepositsPaused; - - // as we have faked simple dvt - jest - .spyOn(securityService, 'isDepositsPaused') - .mockImplementation((stakingModuleId, blockTag) => { - if (stakingModuleId === stakingDvtModule.id) { - return Promise.resolve(false); - } - return originalIsDepositsPaused.call( - securityService, - stakingModuleId, - blockTag, - ); - }); - - sendDepositMessage.mockReset(); - - await guardianService.handleNewBlock(); - - expect(validateKeys).toBeCalledTimes(2); - expect(validateKeys).toBeCalledWith( - expect.arrayContaining([ - expect.objectContaining({ - key: toHexString(pk), - // just some random sign - depositSignature: - '0x8bf4401a354de243a3716ee2efc0bde1ded56a40e2943ac7c50290bec37e935d6170b21e7c0872f203199386143ef12612a1488a8e9f1cdf1229c382f29c326bcbf6ed6a87d8fbfe0df87dacec6632fc4709d9d338f4cf81e861d942c23bba1e', - }), - ]), - ); - expect(validateKeys).toBeCalledWith( - expect.arrayContaining([ - expect.objectContaining({ - key: dvtKey.key, - depositSignature: dvtKey.depositSignature, - }), - ]), - ); - - expect(sendDepositMessage).toBeCalledTimes(1); - expect(sendPauseMessage).toBeCalledTimes(0); - }); - - test( - 'duplicates will not block front-run', - async () => { - const tempProvider = new ethers.providers.JsonRpcProvider( - `http://127.0.0.1:${GANACHE_PORT}`, - ); - const forkBlock = await tempProvider.getBlock(FORK_BLOCK); - const currentBlock = await tempProvider.getBlock('latest'); - - // create correct sign for deposit message for pk - const goodDepositMessage = { - pubkey: pk, - withdrawalCredentials: fromHexString(GOOD_WC), - amount: 32000000000, // gwei! - }; - const goodSigningRoot = computeRoot(goodDepositMessage); - const goodSig = sk.sign(goodSigningRoot).toBytes(); - - const unusedKeys = [ - { - key: toHexString(pk), - depositSignature: toHexString(goodSig), - operatorIndex: 0, - used: false, - index: 0, - moduleAddress: NOP_REGISTRY, - }, - ]; - - const meta = mockedMeta(currentBlock, currentBlock.hash); - const stakingModule = mockedModule(currentBlock, currentBlock.hash); - - mockedKeysApiOperators( - keysApiService, - mockedOperators, - stakingModule, - meta, - ); - - mockedKeysApiUnusedKeys(keysApiService, unusedKeys, meta); - // TODO: rename - mockedKeysWithDuplicates(keysApiService, unusedKeys, meta); - - // just to start checks set event in cache - await depositService.setCachedEvents({ - data: [ - { - valid: true, - pubkey: toHexString(pk), - amount: '32000000000', - wc: GOOD_WC, - signature: toHexString(goodSig), - tx: '0x123', - blockHash: forkBlock.hash, - blockNumber: forkBlock.number, - logIndex: 1, - }, - ], - headers: { - startBlock: currentBlock.number, - endBlock: currentBlock.number, - version: '1', - }, - }); - - // Check if the service is ok and ready to go - await guardianService.handleNewBlock(); - - const badDepositMessage = { - pubkey: pk, - withdrawalCredentials: fromHexString(BAD_WC), - amount: 1000000000, // gwei! - }; - const badSigningRoot = computeRoot(badDepositMessage); - const badSig = sk.sign(badSigningRoot).toBytes(); - - const badDepositData = { - ...badDepositMessage, - signature: badSig, - }; - const badDepositDataRoot = DepositData.hashTreeRoot(badDepositData); - - if (!process.env.WALLET_PRIVATE_KEY) throw new Error(NO_PRIVKEY_MESSAGE); - const wallet = new ethers.Wallet(process.env.WALLET_PRIVATE_KEY); - - // Make a bad deposit - const signer = wallet.connect(providerService.provider); - const depositContract = DepositAbi__factory.connect( - DEPOSIT_CONTRACT, - signer, - ); - // front-run - await depositContract.deposit( - badDepositData.pubkey, - badDepositData.withdrawalCredentials, - badDepositData.signature, - badDepositDataRoot, - { value: ethers.constants.WeiPerEther.mul(1) }, - ); - - // Mock Keys API again on new block - const newBlock = await providerService.provider.getBlock('latest'); - const newMeta = mockedMeta(newBlock, newBlock.hash); - const updatedStakingModule = mockedModule(currentBlock, newBlock.hash); - - mockedKeysApiOperators( - keysApiService, - mockedOperators, - updatedStakingModule, - newMeta, - ); - - const duplicate = { - key: toHexString(pk), - depositSignature: toHexString(goodSig), - operatorIndex: 0, - used: false, - index: 1, - moduleAddress: NOP_REGISTRY, - }; - - mockedKeysApiUnusedKeys( - keysApiService, - [...unusedKeys, duplicate], - newMeta, - ); - - // Run a cycle and wait for possible changes - await guardianService.handleNewBlock(); - - expect(sendPauseMessage).toHaveBeenCalledWith( - expect.objectContaining({ - blockNumber: newBlock.number, - guardianAddress: wallet.address, - guardianIndex: 9, - stakingModuleId: 1, - }), - ); - await new Promise((res) => setTimeout(res, SLEEP_FOR_RESULT)); - - // Check if on pause now - const routerContract = StakingRouterAbi__factory.connect( - STAKING_ROUTER, - providerService.provider, - ); - const isOnPause = await routerContract.getStakingModuleIsDepositsPaused( - 1, - ); - expect(isOnPause).toBe(true); - }, - TESTS_TIMEOUT, - ); -}); diff --git a/test/node-checks-v2.e2e-spec.ts b/test/node-checks-v2.e2e-spec.ts new file mode 100644 index 00000000..20df09f5 --- /dev/null +++ b/test/node-checks-v2.e2e-spec.ts @@ -0,0 +1,81 @@ +// Constants +import { WeiPerEther } from '@ethersproject/constants'; +import { + STAKING_ROUTER, + CHAIN_ID, + GANACHE_PORT, + UNLOCKED_ACCOUNTS_V2, + FORK_BLOCK_V2, +} from './constants'; + +// Contract Factories +import { StakingRouterAbi__factory } from '../src/generated'; + +// App modules and services +import { setupTestingModule } from './helpers/test-setup'; +import { WalletService } from 'wallet'; +import { ProviderService } from 'provider'; +import { Server } from 'ganache'; +import { makeServer } from './server'; + +// Mock rabbit straight away +jest.mock('../src/transport/stomp/stomp.client.ts'); + +jest.setTimeout(10_000); + +describe('ganache e2e tests', () => { + let server: Server<'ethereum'>; + let providerService: ProviderService; + let walletService: WalletService; + + const setupServer = async () => { + server = makeServer(FORK_BLOCK_V2, CHAIN_ID, UNLOCKED_ACCOUNTS_V2); + await server.listen(GANACHE_PORT); + }; + + beforeEach(async () => { + await setupServer(); + const moduleRef = await setupTestingModule(); + providerService = moduleRef.get(ProviderService); + walletService = moduleRef.get(WalletService); + }, 20000); + + afterEach(async () => { + await server.close(); + }); + + describe('node checks', () => { + test('correctness network', async () => { + const chainId = await providerService.getChainId(); + expect(chainId).toBe(CHAIN_ID); + }); + + test('ability to create new blocks', async () => { + const isMining = await providerService.provider.send('eth_mining', []); + expect(isMining).toBe(true); + }); + + test('correctness block number', async () => { + const provider = providerService.provider; + const block = await provider.getBlock('latest'); + expect(block.number).toBe(FORK_BLOCK_V2 + 1); + }); + + test('testing address has some eth', async () => { + const provider = providerService.provider; + const balance = await provider.getBalance(walletService.address); + expect(balance.gte(WeiPerEther.mul(34))).toBe(true); + }); + + test('curated module is not on pause', async () => { + const routerContract = StakingRouterAbi__factory.connect( + STAKING_ROUTER, + providerService.provider, + ); + const isOnPause = await routerContract.getStakingModuleIsDepositsPaused( + 1, + ); + expect(isOnPause).toBe(false); + }); + }); +}); diff --git a/test/server.ts b/test/server.ts index 9b25fbd8..cad5db71 100644 --- a/test/server.ts +++ b/test/server.ts @@ -14,11 +14,13 @@ export const makeServer = ( debug: false, quiet: true, }, - chainId, fork: { url: rpcUrl, blockNumber: startBlock }, - accounts: [{ secretKey, balance: BigInt(1e18) * BigInt(100) }], + chain: { + chainId, + }, wallet: { unlockedAccounts, + accounts: [{ secretKey, balance: BigInt(1e18) * BigInt(100) }], }, }); }; diff --git a/tsconfig.json b/tsconfig.json index 042e8f28..4d726a82 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,7 +6,7 @@ "emitDecoratorMetadata": true, "experimentalDecorators": true, "allowSyntheticDefaultImports": true, - "target": "es2017", + "target": "es2020", "sourceMap": true, "outDir": "./dist", "baseUrl": "./src", diff --git a/yarn.lock b/yarn.lock index 09c2387a..948aea51 100644 --- a/yarn.lock +++ b/yarn.lock @@ -461,22 +461,7 @@ minimatch "^3.0.4" strip-json-comments "^3.1.1" -"@ethersproject/abi@5.5.0", "@ethersproject/abi@^5.5.0": - version "5.5.0" - resolved "https://registry.yarnpkg.com/@ethersproject/abi/-/abi-5.5.0.tgz#fb52820e22e50b854ff15ce1647cc508d6660613" - integrity sha512-loW7I4AohP5KycATvc0MgujU6JyCHPqHdeoo9z3Nr9xEiNioxa65ccdm1+fsoJhkuhdRtfcL8cfyGamz2AxZ5w== - dependencies: - "@ethersproject/address" "^5.5.0" - "@ethersproject/bignumber" "^5.5.0" - "@ethersproject/bytes" "^5.5.0" - "@ethersproject/constants" "^5.5.0" - "@ethersproject/hash" "^5.5.0" - "@ethersproject/keccak256" "^5.5.0" - "@ethersproject/logger" "^5.5.0" - "@ethersproject/properties" "^5.5.0" - "@ethersproject/strings" "^5.5.0" - -"@ethersproject/abi@5.7.0", "@ethersproject/abi@^5.7.0": +"@ethersproject/abi@5.7.0", "@ethersproject/abi@^5.5.0", "@ethersproject/abi@^5.7.0": version "5.7.0" resolved "https://registry.yarnpkg.com/@ethersproject/abi/-/abi-5.7.0.tgz#b3f3e045bbbeed1af3947335c247ad625a44e449" integrity sha512-351ktp42TiRcYB3H1OP8yajPeAQstMW/yCFokj/AthP9bLHzQFPlOrxOcwYEDkUAICmOHljvN4K39OMTMUa9RA== @@ -491,19 +476,6 @@ "@ethersproject/properties" "^5.7.0" "@ethersproject/strings" "^5.7.0" -"@ethersproject/abstract-provider@5.5.1", "@ethersproject/abstract-provider@^5.5.0": - version "5.5.1" - resolved "https://registry.yarnpkg.com/@ethersproject/abstract-provider/-/abstract-provider-5.5.1.tgz#2f1f6e8a3ab7d378d8ad0b5718460f85649710c5" - integrity sha512-m+MA/ful6eKbxpr99xUYeRvLkfnlqzrF8SZ46d/xFB1A7ZVknYc/sXJG0RcufF52Qn2jeFj1hhcoQ7IXjNKUqg== - dependencies: - "@ethersproject/bignumber" "^5.5.0" - "@ethersproject/bytes" "^5.5.0" - "@ethersproject/logger" "^5.5.0" - "@ethersproject/networks" "^5.5.0" - "@ethersproject/properties" "^5.5.0" - "@ethersproject/transactions" "^5.5.0" - "@ethersproject/web" "^5.5.0" - "@ethersproject/abstract-provider@5.7.0", "@ethersproject/abstract-provider@^5.7.0": version "5.7.0" resolved "https://registry.yarnpkg.com/@ethersproject/abstract-provider/-/abstract-provider-5.7.0.tgz#b0a8550f88b6bf9d51f90e4795d48294630cb9ef" @@ -517,17 +489,6 @@ "@ethersproject/transactions" "^5.7.0" "@ethersproject/web" "^5.7.0" -"@ethersproject/abstract-signer@5.5.0", "@ethersproject/abstract-signer@^5.5.0": - version "5.5.0" - resolved "https://registry.yarnpkg.com/@ethersproject/abstract-signer/-/abstract-signer-5.5.0.tgz#590ff6693370c60ae376bf1c7ada59eb2a8dd08d" - integrity sha512-lj//7r250MXVLKI7sVarXAbZXbv9P50lgmJQGr2/is82EwEb8r7HrxsmMqAjTsztMYy7ohrIhGMIml+Gx4D3mA== - dependencies: - "@ethersproject/abstract-provider" "^5.5.0" - "@ethersproject/bignumber" "^5.5.0" - "@ethersproject/bytes" "^5.5.0" - "@ethersproject/logger" "^5.5.0" - "@ethersproject/properties" "^5.5.0" - "@ethersproject/abstract-signer@5.7.0", "@ethersproject/abstract-signer@^5.7.0": version "5.7.0" resolved "https://registry.yarnpkg.com/@ethersproject/abstract-signer/-/abstract-signer-5.7.0.tgz#13f4f32117868452191a4649723cb086d2b596b2" @@ -539,17 +500,6 @@ "@ethersproject/logger" "^5.7.0" "@ethersproject/properties" "^5.7.0" -"@ethersproject/address@5.5.0", "@ethersproject/address@^5.5.0": - version "5.5.0" - resolved "https://registry.yarnpkg.com/@ethersproject/address/-/address-5.5.0.tgz#bcc6f576a553f21f3dd7ba17248f81b473c9c78f" - integrity sha512-l4Nj0eWlTUh6ro5IbPTgbpT4wRbdH5l8CQf7icF7sb/SI3Nhd9Y9HzhonTSTi6CefI0necIw7LJqQPopPLZyWw== - dependencies: - "@ethersproject/bignumber" "^5.5.0" - "@ethersproject/bytes" "^5.5.0" - "@ethersproject/keccak256" "^5.5.0" - "@ethersproject/logger" "^5.5.0" - "@ethersproject/rlp" "^5.5.0" - "@ethersproject/address@5.7.0", "@ethersproject/address@^5.7.0": version "5.7.0" resolved "https://registry.yarnpkg.com/@ethersproject/address/-/address-5.7.0.tgz#19b56c4d74a3b0a46bfdbb6cfcc0a153fc697f37" @@ -561,13 +511,6 @@ "@ethersproject/logger" "^5.7.0" "@ethersproject/rlp" "^5.7.0" -"@ethersproject/base64@5.5.0", "@ethersproject/base64@^5.5.0": - version "5.5.0" - resolved "https://registry.yarnpkg.com/@ethersproject/base64/-/base64-5.5.0.tgz#881e8544e47ed976930836986e5eb8fab259c090" - integrity sha512-tdayUKhU1ljrlHzEWbStXazDpsx4eg1dBXUSI6+mHlYklOXoXF6lZvw8tnD6oVaWfnMxAgRSKROg3cVKtCcppA== - dependencies: - "@ethersproject/bytes" "^5.5.0" - "@ethersproject/base64@5.7.0", "@ethersproject/base64@^5.7.0": version "5.7.0" resolved "https://registry.yarnpkg.com/@ethersproject/base64/-/base64-5.7.0.tgz#ac4ee92aa36c1628173e221d0d01f53692059e1c" @@ -575,14 +518,6 @@ dependencies: "@ethersproject/bytes" "^5.7.0" -"@ethersproject/basex@5.5.0", "@ethersproject/basex@^5.5.0": - version "5.5.0" - resolved "https://registry.yarnpkg.com/@ethersproject/basex/-/basex-5.5.0.tgz#e40a53ae6d6b09ab4d977bd037010d4bed21b4d3" - integrity sha512-ZIodwhHpVJ0Y3hUCfUucmxKsWQA5TMnavp5j/UOuDdzZWzJlRmuOjcTMIGgHCYuZmHt36BfiSyQPSRskPxbfaQ== - dependencies: - "@ethersproject/bytes" "^5.5.0" - "@ethersproject/properties" "^5.5.0" - "@ethersproject/basex@5.7.0", "@ethersproject/basex@^5.7.0": version "5.7.0" resolved "https://registry.yarnpkg.com/@ethersproject/basex/-/basex-5.7.0.tgz#97034dc7e8938a8ca943ab20f8a5e492ece4020b" @@ -591,15 +526,6 @@ "@ethersproject/bytes" "^5.7.0" "@ethersproject/properties" "^5.7.0" -"@ethersproject/bignumber@5.5.0", "@ethersproject/bignumber@^5.5.0": - version "5.5.0" - resolved "https://registry.yarnpkg.com/@ethersproject/bignumber/-/bignumber-5.5.0.tgz#875b143f04a216f4f8b96245bde942d42d279527" - integrity sha512-6Xytlwvy6Rn3U3gKEc1vP7nR92frHkv6wtVr95LFR3jREXiCPzdWxKQ1cx4JGQBXxcguAwjA8murlYN2TSiEbg== - dependencies: - "@ethersproject/bytes" "^5.5.0" - "@ethersproject/logger" "^5.5.0" - bn.js "^4.11.9" - "@ethersproject/bignumber@5.7.0", "@ethersproject/bignumber@^5.7.0": version "5.7.0" resolved "https://registry.yarnpkg.com/@ethersproject/bignumber/-/bignumber-5.7.0.tgz#e2f03837f268ba655ffba03a57853e18a18dc9c2" @@ -609,13 +535,6 @@ "@ethersproject/logger" "^5.7.0" bn.js "^5.2.1" -"@ethersproject/bytes@5.5.0", "@ethersproject/bytes@^5.5.0": - version "5.5.0" - resolved "https://registry.yarnpkg.com/@ethersproject/bytes/-/bytes-5.5.0.tgz#cb11c526de657e7b45d2e0f0246fb3b9d29a601c" - integrity sha512-ABvc7BHWhZU9PNM/tANm/Qx4ostPGadAuQzWTr3doklZOhDlmcBqclrQe/ZXUIj3K8wC28oYeuRa+A37tX9kog== - dependencies: - "@ethersproject/logger" "^5.5.0" - "@ethersproject/bytes@5.7.0", "@ethersproject/bytes@^5.7.0": version "5.7.0" resolved "https://registry.yarnpkg.com/@ethersproject/bytes/-/bytes-5.7.0.tgz#a00f6ea8d7e7534d6d87f47188af1148d71f155d" @@ -623,13 +542,6 @@ dependencies: "@ethersproject/logger" "^5.7.0" -"@ethersproject/constants@5.5.0", "@ethersproject/constants@^5.5.0": - version "5.5.0" - resolved "https://registry.yarnpkg.com/@ethersproject/constants/-/constants-5.5.0.tgz#d2a2cd7d94bd1d58377d1d66c4f53c9be4d0a45e" - integrity sha512-2MsRRVChkvMWR+GyMGY4N1sAX9Mt3J9KykCsgUFd/1mwS0UH1qw+Bv9k1UJb3X3YJYFco9H20pjSlOIfCG5HYQ== - dependencies: - "@ethersproject/bignumber" "^5.5.0" - "@ethersproject/constants@5.7.0", "@ethersproject/constants@^5.7.0": version "5.7.0" resolved "https://registry.yarnpkg.com/@ethersproject/constants/-/constants-5.7.0.tgz#df80a9705a7e08984161f09014ea012d1c75295e" @@ -637,22 +549,6 @@ dependencies: "@ethersproject/bignumber" "^5.7.0" -"@ethersproject/contracts@5.5.0": - version "5.5.0" - resolved "https://registry.yarnpkg.com/@ethersproject/contracts/-/contracts-5.5.0.tgz#b735260d4bd61283a670a82d5275e2a38892c197" - integrity sha512-2viY7NzyvJkh+Ug17v7g3/IJC8HqZBDcOjYARZLdzRxrfGlRgmYgl6xPRKVbEzy1dWKw/iv7chDcS83pg6cLxg== - dependencies: - "@ethersproject/abi" "^5.5.0" - "@ethersproject/abstract-provider" "^5.5.0" - "@ethersproject/abstract-signer" "^5.5.0" - "@ethersproject/address" "^5.5.0" - "@ethersproject/bignumber" "^5.5.0" - "@ethersproject/bytes" "^5.5.0" - "@ethersproject/constants" "^5.5.0" - "@ethersproject/logger" "^5.5.0" - "@ethersproject/properties" "^5.5.0" - "@ethersproject/transactions" "^5.5.0" - "@ethersproject/contracts@5.7.0": version "5.7.0" resolved "https://registry.yarnpkg.com/@ethersproject/contracts/-/contracts-5.7.0.tgz#c305e775abd07e48aa590e1a877ed5c316f8bd1e" @@ -669,20 +565,6 @@ "@ethersproject/properties" "^5.7.0" "@ethersproject/transactions" "^5.7.0" -"@ethersproject/hash@5.5.0", "@ethersproject/hash@^5.5.0": - version "5.5.0" - resolved "https://registry.yarnpkg.com/@ethersproject/hash/-/hash-5.5.0.tgz#7cee76d08f88d1873574c849e0207dcb32380cc9" - integrity sha512-dnGVpK1WtBjmnp3mUT0PlU2MpapnwWI0PibldQEq1408tQBAbZpPidkWoVVuNMOl/lISO3+4hXZWCL3YV7qzfg== - dependencies: - "@ethersproject/abstract-signer" "^5.5.0" - "@ethersproject/address" "^5.5.0" - "@ethersproject/bignumber" "^5.5.0" - "@ethersproject/bytes" "^5.5.0" - "@ethersproject/keccak256" "^5.5.0" - "@ethersproject/logger" "^5.5.0" - "@ethersproject/properties" "^5.5.0" - "@ethersproject/strings" "^5.5.0" - "@ethersproject/hash@5.7.0", "@ethersproject/hash@^5.7.0": version "5.7.0" resolved "https://registry.yarnpkg.com/@ethersproject/hash/-/hash-5.7.0.tgz#eb7aca84a588508369562e16e514b539ba5240a7" @@ -698,24 +580,6 @@ "@ethersproject/properties" "^5.7.0" "@ethersproject/strings" "^5.7.0" -"@ethersproject/hdnode@5.5.0", "@ethersproject/hdnode@^5.5.0": - version "5.5.0" - resolved "https://registry.yarnpkg.com/@ethersproject/hdnode/-/hdnode-5.5.0.tgz#4a04e28f41c546f7c978528ea1575206a200ddf6" - integrity sha512-mcSOo9zeUg1L0CoJH7zmxwUG5ggQHU1UrRf8jyTYy6HxdZV+r0PBoL1bxr+JHIPXRzS6u/UW4mEn43y0tmyF8Q== - dependencies: - "@ethersproject/abstract-signer" "^5.5.0" - "@ethersproject/basex" "^5.5.0" - "@ethersproject/bignumber" "^5.5.0" - "@ethersproject/bytes" "^5.5.0" - "@ethersproject/logger" "^5.5.0" - "@ethersproject/pbkdf2" "^5.5.0" - "@ethersproject/properties" "^5.5.0" - "@ethersproject/sha2" "^5.5.0" - "@ethersproject/signing-key" "^5.5.0" - "@ethersproject/strings" "^5.5.0" - "@ethersproject/transactions" "^5.5.0" - "@ethersproject/wordlists" "^5.5.0" - "@ethersproject/hdnode@5.7.0", "@ethersproject/hdnode@^5.7.0": version "5.7.0" resolved "https://registry.yarnpkg.com/@ethersproject/hdnode/-/hdnode-5.7.0.tgz#e627ddc6b466bc77aebf1a6b9e47405ca5aef9cf" @@ -734,25 +598,6 @@ "@ethersproject/transactions" "^5.7.0" "@ethersproject/wordlists" "^5.7.0" -"@ethersproject/json-wallets@5.5.0", "@ethersproject/json-wallets@^5.5.0": - version "5.5.0" - resolved "https://registry.yarnpkg.com/@ethersproject/json-wallets/-/json-wallets-5.5.0.tgz#dd522d4297e15bccc8e1427d247ec8376b60e325" - integrity sha512-9lA21XQnCdcS72xlBn1jfQdj2A1VUxZzOzi9UkNdnokNKke/9Ya2xA9aIK1SC3PQyBDLt4C+dfps7ULpkvKikQ== - dependencies: - "@ethersproject/abstract-signer" "^5.5.0" - "@ethersproject/address" "^5.5.0" - "@ethersproject/bytes" "^5.5.0" - "@ethersproject/hdnode" "^5.5.0" - "@ethersproject/keccak256" "^5.5.0" - "@ethersproject/logger" "^5.5.0" - "@ethersproject/pbkdf2" "^5.5.0" - "@ethersproject/properties" "^5.5.0" - "@ethersproject/random" "^5.5.0" - "@ethersproject/strings" "^5.5.0" - "@ethersproject/transactions" "^5.5.0" - aes-js "3.0.0" - scrypt-js "3.0.1" - "@ethersproject/json-wallets@5.7.0", "@ethersproject/json-wallets@^5.7.0": version "5.7.0" resolved "https://registry.yarnpkg.com/@ethersproject/json-wallets/-/json-wallets-5.7.0.tgz#5e3355287b548c32b368d91014919ebebddd5360" @@ -772,14 +617,6 @@ aes-js "3.0.0" scrypt-js "3.0.1" -"@ethersproject/keccak256@5.5.0", "@ethersproject/keccak256@^5.5.0": - version "5.5.0" - resolved "https://registry.yarnpkg.com/@ethersproject/keccak256/-/keccak256-5.5.0.tgz#e4b1f9d7701da87c564ffe336f86dcee82983492" - integrity sha512-5VoFCTjo2rYbBe1l2f4mccaRFN/4VQEYFwwn04aJV2h7qf4ZvI2wFxUE1XOX+snbwCLRzIeikOqtAoPwMza9kg== - dependencies: - "@ethersproject/bytes" "^5.5.0" - js-sha3 "0.8.0" - "@ethersproject/keccak256@5.7.0", "@ethersproject/keccak256@^5.7.0": version "5.7.0" resolved "https://registry.yarnpkg.com/@ethersproject/keccak256/-/keccak256-5.7.0.tgz#3186350c6e1cd6aba7940384ec7d6d9db01f335a" @@ -788,23 +625,11 @@ "@ethersproject/bytes" "^5.7.0" js-sha3 "0.8.0" -"@ethersproject/logger@5.5.0", "@ethersproject/logger@^5.5.0": - version "5.5.0" - resolved "https://registry.yarnpkg.com/@ethersproject/logger/-/logger-5.5.0.tgz#0c2caebeff98e10aefa5aef27d7441c7fd18cf5d" - integrity sha512-rIY/6WPm7T8n3qS2vuHTUBPdXHl+rGxWxW5okDfo9J4Z0+gRRZT0msvUdIJkE4/HS29GUMziwGaaKO2bWONBrg== - "@ethersproject/logger@5.7.0", "@ethersproject/logger@^5.7.0": version "5.7.0" resolved "https://registry.yarnpkg.com/@ethersproject/logger/-/logger-5.7.0.tgz#6ce9ae168e74fecf287be17062b590852c311892" integrity sha512-0odtFdXu/XHtjQXJYA3u9G0G8btm0ND5Cu8M7i5vhEcE8/HmF4Lbdqanwyv4uQTr2tx6b7fQRmgLrsnpQlmnig== -"@ethersproject/networks@5.5.2", "@ethersproject/networks@^5.5.0": - version "5.5.2" - resolved "https://registry.yarnpkg.com/@ethersproject/networks/-/networks-5.5.2.tgz#784c8b1283cd2a931114ab428dae1bd00c07630b" - integrity sha512-NEqPxbGBfy6O3x4ZTISb90SjEDkWYDUbEeIFhJly0F7sZjoQMnj5KYzMSkMkLKZ+1fGpx00EDpHQCy6PrDupkQ== - dependencies: - "@ethersproject/logger" "^5.5.0" - "@ethersproject/networks@5.7.1", "@ethersproject/networks@^5.7.0": version "5.7.1" resolved "https://registry.yarnpkg.com/@ethersproject/networks/-/networks-5.7.1.tgz#118e1a981d757d45ccea6bb58d9fd3d9db14ead6" @@ -812,14 +637,6 @@ dependencies: "@ethersproject/logger" "^5.7.0" -"@ethersproject/pbkdf2@5.5.0", "@ethersproject/pbkdf2@^5.5.0": - version "5.5.0" - resolved "https://registry.yarnpkg.com/@ethersproject/pbkdf2/-/pbkdf2-5.5.0.tgz#e25032cdf02f31505d47afbf9c3e000d95c4a050" - integrity sha512-SaDvQFvXPnz1QGpzr6/HToLifftSXGoXrbpZ6BvoZhmx4bNLHrxDe8MZisuecyOziP1aVEwzC2Hasj+86TgWVg== - dependencies: - "@ethersproject/bytes" "^5.5.0" - "@ethersproject/sha2" "^5.5.0" - "@ethersproject/pbkdf2@5.7.0", "@ethersproject/pbkdf2@^5.7.0": version "5.7.0" resolved "https://registry.yarnpkg.com/@ethersproject/pbkdf2/-/pbkdf2-5.7.0.tgz#d2267d0a1f6e123f3771007338c47cccd83d3102" @@ -828,13 +645,6 @@ "@ethersproject/bytes" "^5.7.0" "@ethersproject/sha2" "^5.7.0" -"@ethersproject/properties@5.5.0", "@ethersproject/properties@^5.5.0": - version "5.5.0" - resolved "https://registry.yarnpkg.com/@ethersproject/properties/-/properties-5.5.0.tgz#61f00f2bb83376d2071baab02245f92070c59995" - integrity sha512-l3zRQg3JkD8EL3CPjNK5g7kMx4qSwiR60/uk5IVjd3oq1MZR5qUg40CNOoEJoX5wc3DyY5bt9EbMk86C7x0DNA== - dependencies: - "@ethersproject/logger" "^5.5.0" - "@ethersproject/properties@5.7.0", "@ethersproject/properties@^5.7.0": version "5.7.0" resolved "https://registry.yarnpkg.com/@ethersproject/properties/-/properties-5.7.0.tgz#a6e12cb0439b878aaf470f1902a176033067ed30" @@ -842,31 +652,6 @@ dependencies: "@ethersproject/logger" "^5.7.0" -"@ethersproject/providers@5.5.3", "@ethersproject/providers@^5.4.5": - version "5.5.3" - resolved "https://registry.yarnpkg.com/@ethersproject/providers/-/providers-5.5.3.tgz#56c2b070542ac44eb5de2ed3cf6784acd60a3130" - integrity sha512-ZHXxXXXWHuwCQKrgdpIkbzMNJMvs+9YWemanwp1fA7XZEv7QlilseysPvQe0D7Q7DlkJX/w/bGA1MdgK2TbGvA== - dependencies: - "@ethersproject/abstract-provider" "^5.5.0" - "@ethersproject/abstract-signer" "^5.5.0" - "@ethersproject/address" "^5.5.0" - "@ethersproject/basex" "^5.5.0" - "@ethersproject/bignumber" "^5.5.0" - "@ethersproject/bytes" "^5.5.0" - "@ethersproject/constants" "^5.5.0" - "@ethersproject/hash" "^5.5.0" - "@ethersproject/logger" "^5.5.0" - "@ethersproject/networks" "^5.5.0" - "@ethersproject/properties" "^5.5.0" - "@ethersproject/random" "^5.5.0" - "@ethersproject/rlp" "^5.5.0" - "@ethersproject/sha2" "^5.5.0" - "@ethersproject/strings" "^5.5.0" - "@ethersproject/transactions" "^5.5.0" - "@ethersproject/web" "^5.5.0" - bech32 "1.1.4" - ws "7.4.6" - "@ethersproject/providers@5.7.2", "@ethersproject/providers@^5.5.3": version "5.7.2" resolved "https://registry.yarnpkg.com/@ethersproject/providers/-/providers-5.7.2.tgz#f8b1a4f275d7ce58cf0a2eec222269a08beb18cb" @@ -893,14 +678,6 @@ bech32 "1.1.4" ws "7.4.6" -"@ethersproject/random@5.5.1", "@ethersproject/random@^5.5.0": - version "5.5.1" - resolved "https://registry.yarnpkg.com/@ethersproject/random/-/random-5.5.1.tgz#7cdf38ea93dc0b1ed1d8e480ccdaf3535c555415" - integrity sha512-YaU2dQ7DuhL5Au7KbcQLHxcRHfgyNgvFV4sQOo0HrtW3Zkrc9ctWNz8wXQ4uCSfSDsqX2vcjhroxU5RQRV0nqA== - dependencies: - "@ethersproject/bytes" "^5.5.0" - "@ethersproject/logger" "^5.5.0" - "@ethersproject/random@5.7.0", "@ethersproject/random@^5.7.0": version "5.7.0" resolved "https://registry.yarnpkg.com/@ethersproject/random/-/random-5.7.0.tgz#af19dcbc2484aae078bb03656ec05df66253280c" @@ -909,14 +686,6 @@ "@ethersproject/bytes" "^5.7.0" "@ethersproject/logger" "^5.7.0" -"@ethersproject/rlp@5.5.0", "@ethersproject/rlp@^5.5.0": - version "5.5.0" - resolved "https://registry.yarnpkg.com/@ethersproject/rlp/-/rlp-5.5.0.tgz#530f4f608f9ca9d4f89c24ab95db58ab56ab99a0" - integrity sha512-hLv8XaQ8PTI9g2RHoQGf/WSxBfTB/NudRacbzdxmst5VHAqd1sMibWG7SENzT5Dj3yZ3kJYx+WiRYEcQTAkcYA== - dependencies: - "@ethersproject/bytes" "^5.5.0" - "@ethersproject/logger" "^5.5.0" - "@ethersproject/rlp@5.7.0", "@ethersproject/rlp@^5.7.0": version "5.7.0" resolved "https://registry.yarnpkg.com/@ethersproject/rlp/-/rlp-5.7.0.tgz#de39e4d5918b9d74d46de93af80b7685a9c21304" @@ -925,15 +694,6 @@ "@ethersproject/bytes" "^5.7.0" "@ethersproject/logger" "^5.7.0" -"@ethersproject/sha2@5.5.0", "@ethersproject/sha2@^5.5.0": - version "5.5.0" - resolved "https://registry.yarnpkg.com/@ethersproject/sha2/-/sha2-5.5.0.tgz#a40a054c61f98fd9eee99af2c3cc6ff57ec24db7" - integrity sha512-B5UBoglbCiHamRVPLA110J+2uqsifpZaTmid2/7W5rbtYVz6gus6/hSDieIU/6gaKIDcOj12WnOdiymEUHIAOA== - dependencies: - "@ethersproject/bytes" "^5.5.0" - "@ethersproject/logger" "^5.5.0" - hash.js "1.1.7" - "@ethersproject/sha2@5.7.0", "@ethersproject/sha2@^5.7.0": version "5.7.0" resolved "https://registry.yarnpkg.com/@ethersproject/sha2/-/sha2-5.7.0.tgz#9a5f7a7824ef784f7f7680984e593a800480c9fb" @@ -943,18 +703,6 @@ "@ethersproject/logger" "^5.7.0" hash.js "1.1.7" -"@ethersproject/signing-key@5.5.0", "@ethersproject/signing-key@^5.5.0": - version "5.5.0" - resolved "https://registry.yarnpkg.com/@ethersproject/signing-key/-/signing-key-5.5.0.tgz#2aa37169ce7e01e3e80f2c14325f624c29cedbe0" - integrity sha512-5VmseH7qjtNmDdZBswavhotYbWB0bOwKIlOTSlX14rKn5c11QmJwGt4GHeo7NrL/Ycl7uo9AHvEqs5xZgFBTng== - dependencies: - "@ethersproject/bytes" "^5.5.0" - "@ethersproject/logger" "^5.5.0" - "@ethersproject/properties" "^5.5.0" - bn.js "^4.11.9" - elliptic "6.5.4" - hash.js "1.1.7" - "@ethersproject/signing-key@5.7.0", "@ethersproject/signing-key@^5.7.0": version "5.7.0" resolved "https://registry.yarnpkg.com/@ethersproject/signing-key/-/signing-key-5.7.0.tgz#06b2df39411b00bc57c7c09b01d1e41cf1b16ab3" @@ -967,18 +715,6 @@ elliptic "6.5.4" hash.js "1.1.7" -"@ethersproject/solidity@5.5.0": - version "5.5.0" - resolved "https://registry.yarnpkg.com/@ethersproject/solidity/-/solidity-5.5.0.tgz#2662eb3e5da471b85a20531e420054278362f93f" - integrity sha512-9NgZs9LhGMj6aCtHXhtmFQ4AN4sth5HuFXVvAQtzmm0jpSCNOTGtrHZJAeYTh7MBjRR8brylWZxBZR9zDStXbw== - dependencies: - "@ethersproject/bignumber" "^5.5.0" - "@ethersproject/bytes" "^5.5.0" - "@ethersproject/keccak256" "^5.5.0" - "@ethersproject/logger" "^5.5.0" - "@ethersproject/sha2" "^5.5.0" - "@ethersproject/strings" "^5.5.0" - "@ethersproject/solidity@5.7.0": version "5.7.0" resolved "https://registry.yarnpkg.com/@ethersproject/solidity/-/solidity-5.7.0.tgz#5e9c911d8a2acce2a5ebb48a5e2e0af20b631cb8" @@ -991,15 +727,6 @@ "@ethersproject/sha2" "^5.7.0" "@ethersproject/strings" "^5.7.0" -"@ethersproject/strings@5.5.0", "@ethersproject/strings@^5.5.0": - version "5.5.0" - resolved "https://registry.yarnpkg.com/@ethersproject/strings/-/strings-5.5.0.tgz#e6784d00ec6c57710755699003bc747e98c5d549" - integrity sha512-9fy3TtF5LrX/wTrBaT8FGE6TDJyVjOvXynXJz5MT5azq+E6D92zuKNx7i29sWW2FjVOaWjAsiZ1ZWznuduTIIQ== - dependencies: - "@ethersproject/bytes" "^5.5.0" - "@ethersproject/constants" "^5.5.0" - "@ethersproject/logger" "^5.5.0" - "@ethersproject/strings@5.7.0", "@ethersproject/strings@^5.7.0": version "5.7.0" resolved "https://registry.yarnpkg.com/@ethersproject/strings/-/strings-5.7.0.tgz#54c9d2a7c57ae8f1205c88a9d3a56471e14d5ed2" @@ -1009,21 +736,6 @@ "@ethersproject/constants" "^5.7.0" "@ethersproject/logger" "^5.7.0" -"@ethersproject/transactions@5.5.0", "@ethersproject/transactions@^5.5.0": - version "5.5.0" - resolved "https://registry.yarnpkg.com/@ethersproject/transactions/-/transactions-5.5.0.tgz#7e9bf72e97bcdf69db34fe0d59e2f4203c7a2908" - integrity sha512-9RZYSKX26KfzEd/1eqvv8pLauCKzDTub0Ko4LfIgaERvRuwyaNV78mJs7cpIgZaDl6RJui4o49lHwwCM0526zA== - dependencies: - "@ethersproject/address" "^5.5.0" - "@ethersproject/bignumber" "^5.5.0" - "@ethersproject/bytes" "^5.5.0" - "@ethersproject/constants" "^5.5.0" - "@ethersproject/keccak256" "^5.5.0" - "@ethersproject/logger" "^5.5.0" - "@ethersproject/properties" "^5.5.0" - "@ethersproject/rlp" "^5.5.0" - "@ethersproject/signing-key" "^5.5.0" - "@ethersproject/transactions@5.7.0", "@ethersproject/transactions@^5.7.0": version "5.7.0" resolved "https://registry.yarnpkg.com/@ethersproject/transactions/-/transactions-5.7.0.tgz#91318fc24063e057885a6af13fdb703e1f993d3b" @@ -1039,15 +751,6 @@ "@ethersproject/rlp" "^5.7.0" "@ethersproject/signing-key" "^5.7.0" -"@ethersproject/units@5.5.0": - version "5.5.0" - resolved "https://registry.yarnpkg.com/@ethersproject/units/-/units-5.5.0.tgz#104d02db5b5dc42cc672cc4587bafb87a95ee45e" - integrity sha512-7+DpjiZk4v6wrikj+TCyWWa9dXLNU73tSTa7n0TSJDxkYbV3Yf1eRh9ToMLlZtuctNYu9RDNNy2USq3AdqSbag== - dependencies: - "@ethersproject/bignumber" "^5.5.0" - "@ethersproject/constants" "^5.5.0" - "@ethersproject/logger" "^5.5.0" - "@ethersproject/units@5.7.0": version "5.7.0" resolved "https://registry.yarnpkg.com/@ethersproject/units/-/units-5.7.0.tgz#637b563d7e14f42deeee39245275d477aae1d8b1" @@ -1057,27 +760,6 @@ "@ethersproject/constants" "^5.7.0" "@ethersproject/logger" "^5.7.0" -"@ethersproject/wallet@5.5.0": - version "5.5.0" - resolved "https://registry.yarnpkg.com/@ethersproject/wallet/-/wallet-5.5.0.tgz#322a10527a440ece593980dca6182f17d54eae75" - integrity sha512-Mlu13hIctSYaZmUOo7r2PhNSd8eaMPVXe1wxrz4w4FCE4tDYBywDH+bAR1Xz2ADyXGwqYMwstzTrtUVIsKDO0Q== - dependencies: - "@ethersproject/abstract-provider" "^5.5.0" - "@ethersproject/abstract-signer" "^5.5.0" - "@ethersproject/address" "^5.5.0" - "@ethersproject/bignumber" "^5.5.0" - "@ethersproject/bytes" "^5.5.0" - "@ethersproject/hash" "^5.5.0" - "@ethersproject/hdnode" "^5.5.0" - "@ethersproject/json-wallets" "^5.5.0" - "@ethersproject/keccak256" "^5.5.0" - "@ethersproject/logger" "^5.5.0" - "@ethersproject/properties" "^5.5.0" - "@ethersproject/random" "^5.5.0" - "@ethersproject/signing-key" "^5.5.0" - "@ethersproject/transactions" "^5.5.0" - "@ethersproject/wordlists" "^5.5.0" - "@ethersproject/wallet@5.7.0": version "5.7.0" resolved "https://registry.yarnpkg.com/@ethersproject/wallet/-/wallet-5.7.0.tgz#4e5d0790d96fe21d61d38fb40324e6c7ef350b2d" @@ -1099,17 +781,6 @@ "@ethersproject/transactions" "^5.7.0" "@ethersproject/wordlists" "^5.7.0" -"@ethersproject/web@5.5.1", "@ethersproject/web@^5.5.0": - version "5.5.1" - resolved "https://registry.yarnpkg.com/@ethersproject/web/-/web-5.5.1.tgz#cfcc4a074a6936c657878ac58917a61341681316" - integrity sha512-olvLvc1CB12sREc1ROPSHTdFCdvMh0J5GSJYiQg2D0hdD4QmJDy8QYDb1CvoqD/bF1c++aeKv2sR5uduuG9dQg== - dependencies: - "@ethersproject/base64" "^5.5.0" - "@ethersproject/bytes" "^5.5.0" - "@ethersproject/logger" "^5.5.0" - "@ethersproject/properties" "^5.5.0" - "@ethersproject/strings" "^5.5.0" - "@ethersproject/web@5.7.1", "@ethersproject/web@^5.7.0": version "5.7.1" resolved "https://registry.yarnpkg.com/@ethersproject/web/-/web-5.7.1.tgz#de1f285b373149bee5928f4eb7bcb87ee5fbb4ae" @@ -1121,17 +792,6 @@ "@ethersproject/properties" "^5.7.0" "@ethersproject/strings" "^5.7.0" -"@ethersproject/wordlists@5.5.0", "@ethersproject/wordlists@^5.5.0": - version "5.5.0" - resolved "https://registry.yarnpkg.com/@ethersproject/wordlists/-/wordlists-5.5.0.tgz#aac74963aa43e643638e5172353d931b347d584f" - integrity sha512-bL0UTReWDiaQJJYOC9sh/XcRu/9i2jMrzf8VLRmPKx58ckSlOJiohODkECCO50dtLZHcGU6MLXQ4OOrgBwP77Q== - dependencies: - "@ethersproject/bytes" "^5.5.0" - "@ethersproject/hash" "^5.5.0" - "@ethersproject/logger" "^5.5.0" - "@ethersproject/properties" "^5.5.0" - "@ethersproject/strings" "^5.5.0" - "@ethersproject/wordlists@5.7.0", "@ethersproject/wordlists@^5.7.0": version "5.7.0" resolved "https://registry.yarnpkg.com/@ethersproject/wordlists/-/wordlists-5.7.0.tgz#8fb2c07185d68c3e09eb3bfd6e779ba2774627f5" @@ -1623,6 +1283,16 @@ dependencies: node-gyp-build "4.4.0" +"@trufflesuite/uws-js-unofficial@20.10.0-unofficial.2": + version "20.10.0-unofficial.2" + resolved "https://registry.yarnpkg.com/@trufflesuite/uws-js-unofficial/-/uws-js-unofficial-20.10.0-unofficial.2.tgz#7ed613ce3260cd5d1773a4d5787a2a106acd1a91" + integrity sha512-oQQlnS3oNeGsgS4K3KCSSavJgSb0W9D5ktZs4FacX9VbM7b+NlhjH96d6/G4fMrz+bc5MXRyco419on0X0dvRA== + dependencies: + ws "8.2.3" + optionalDependencies: + bufferutil "4.0.5" + utf-8-validate "5.0.7" + "@tsconfig/node10@^1.0.7": version "1.0.8" resolved "https://registry.yarnpkg.com/@tsconfig/node10/-/node10-1.0.8.tgz#c1e4e80d6f964fbecb3359c43bd48b40f7cadad9" @@ -1644,9 +1314,9 @@ integrity sha512-eZxlbI8GZscaGS7kkc/trHTT5xgrjH3/1n2JDwusC9iahPKWMRvRjJSAN5mCXviuTGQ/lHnhvv8Q1YTpnfz9gA== "@typechain/ethers-v5@^7.1.2": - version "7.1.2" - resolved "https://registry.yarnpkg.com/@typechain/ethers-v5/-/ethers-v5-7.1.2.tgz#dbf31663f75cc50f2d9ad232f6e354c6a3e81465" - integrity sha512-sD4HVkTL5aIJa3Ft+CmqiOapba0zzZ8xa+QywcWH40Rm/dcxvZWwcCMnnI3En0JebkxOcAVfH3do+kQ9rKSxYw== + version "7.2.0" + resolved "https://registry.yarnpkg.com/@typechain/ethers-v5/-/ethers-v5-7.2.0.tgz#d559cffe0efe6bdbc20e644b817f6fa8add5e8f8" + integrity sha512-jfcmlTvaaJjng63QsT49MT6R1HFhtO/TBMWbyzPFSzMmVIqb2tL6prnKBs4ZJrSvmgIXWy+ttSjpaxCTq8D/Tw== dependencies: lodash "^4.17.15" ts-essentials "^7.0.1" @@ -1819,7 +1489,12 @@ resolved "https://registry.yarnpkg.com/@types/parse-json/-/parse-json-4.0.0.tgz#2f8bb441434d163b35fb8ffdccd7138927ffb8c0" integrity sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA== -"@types/prettier@^2.1.1", "@types/prettier@^2.1.5": +"@types/prettier@^2.1.1": + version "2.7.3" + resolved "https://registry.yarnpkg.com/@types/prettier/-/prettier-2.7.3.tgz#3e51a17e291d01d17d3fc61422015a933af7a08f" + integrity sha512-+68kP9yzs4LMp7VNh8gdzMSPZFL44MLGqiHWvttYJe+6qnuVr4Ek9wSBQoveqY/r+LwjCcU29kNVkidwim+kYA== + +"@types/prettier@^2.1.5": version "2.4.1" resolved "https://registry.yarnpkg.com/@types/prettier/-/prettier-2.4.1.tgz#e1303048d5389563e130f5bdd89d37a99acb75eb" integrity sha512-Fo79ojj3vdEZOHg3wR9ksAMRz4P3S5fDB5e/YWZiFnyFQI1WY2Vftu9XoXVVtJfxB7Bpce/QTqWSSntkz2Znrw== @@ -2102,6 +1777,19 @@ abstract-level@1.0.3: module-error "^1.0.1" queue-microtask "^1.2.3" +abstract-level@^1.0.2, abstract-level@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/abstract-level/-/abstract-level-1.0.4.tgz#3ad8d684c51cc9cbc9cf9612a7100b716c414b57" + integrity sha512-eUP/6pbXBkMbXFdx4IH2fVgvB7M0JvR7/lIL33zcs0IBcwjdzSSl31TOJsaCzmKSSDF9h8QYSOJux4Nd4YJqFg== + dependencies: + buffer "^6.0.3" + catering "^2.1.0" + is-buffer "^2.0.5" + level-supports "^4.0.0" + level-transcoder "^1.0.1" + module-error "^1.0.1" + queue-microtask "^1.2.3" + abstract-leveldown@7.2.0, abstract-leveldown@^7.2.0: version "7.2.0" resolved "https://registry.yarnpkg.com/abstract-leveldown/-/abstract-leveldown-7.2.0.tgz#08d19d4e26fb5be426f7a57004851b39e1795a2e" @@ -2163,7 +1851,7 @@ acorn@^8.2.4, acorn@^8.4.1, acorn@^8.5.0: aes-js@3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/aes-js/-/aes-js-3.0.0.tgz#e21df10ad6c2053295bcbb8dab40b09dbea87e4d" - integrity sha1-4h3xCtbCBTKVvLuNq0Cwnb6ofk0= + integrity sha512-H7wUZRn8WpTq9jocdxQ2c8x2sKo9ZVmzfRE13GiNJXfp7NcKYEdvl3vspKjXox6RIG2VtaRe4JFvxG4rqp2Zuw== agent-base@6, agent-base@^6.0.2: version "6.0.2" @@ -2330,7 +2018,7 @@ argparse@^1.0.7: array-back@^1.0.3, array-back@^1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/array-back/-/array-back-1.0.4.tgz#644ba7f095f7ffcf7c43b5f0dc39d3c1f03c063b" - integrity sha1-ZEun8JX3/898Q7Xw3DnTwfA8Bjs= + integrity sha512-1WxbZvrmyhkNoeYcizokbmh5oiOCIfyvGtcqbK3Ls1v1fKcquzxnQSceOx6tzq7jmai2kFLWIpGND2cLhH6TPw== dependencies: typical "^2.6.0" @@ -2526,7 +2214,17 @@ braces@^3.0.1, braces@~3.0.2: brorand@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/brorand/-/brorand-1.1.0.tgz#12c25efe40a45e3c323eb8675a0a0ce57b22371f" - integrity sha1-EsJe/kCkXjwyPrhnWgoM5XsiNx8= + integrity sha512-cKV8tMCEpQs4hK/ik71d6LrPOnpkpGBR0wzxqr68g2m/LB2GxVYQroAjMJZRVM1Y4BCjCKc3vAamxSzOY2RP+w== + +browser-level@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/browser-level/-/browser-level-1.0.1.tgz#36e8c3183d0fe1c405239792faaab5f315871011" + integrity sha512-XECYKJ+Dbzw0lbydyQuJzwNXtOpbMSq737qxJN11sIRTErOMShvDpbzTlgju7orJKvx4epULolZAuJGLzCmWRQ== + dependencies: + abstract-level "^1.0.2" + catering "^2.1.1" + module-error "^1.0.2" + run-parallel-limit "^1.1.0" browser-process-hrtime@^1.0.0: version "1.0.0" @@ -2665,7 +2363,7 @@ case@^1.6.3: resolved "https://registry.yarnpkg.com/case/-/case-1.6.3.tgz#0a4386e3e9825351ca2e6216c60467ff5f1ea1c9" integrity sha512-mzDSXIPaFwVDvZAHqZ9VlbyF4yyXRuX6IvB06WvPYkqJVO24kX1PPhv9bfpKNFZyxYFmmgo03HUiD8iklmJYRQ== -catering@^2.0.0, catering@^2.1.0: +catering@^2.0.0, catering@^2.1.0, catering@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/catering/-/catering-2.1.1.tgz#66acba06ed5ee28d5286133982a927de9a04b510" integrity sha512-K7Qy8O9p76sL3/3m7/zLKbRkyOlSZAgzEaLhyj2mXS8PsCud2Eo4hAb8aLtZqHh0QGqLcb9dlJSu6lHRVENm1w== @@ -2759,6 +2457,17 @@ class-validator@^0.14.0: libphonenumber-js "^1.10.14" validator "^13.7.0" +classic-level@^1.2.0: + version "1.4.1" + resolved "https://registry.yarnpkg.com/classic-level/-/classic-level-1.4.1.tgz#169ecf9f9c6200ad42a98c8576af449c1badbaee" + integrity sha512-qGx/KJl3bvtOHrGau2WklEZuXhS3zme+jf+fsu6Ej7W7IP/C49v7KNlWIsT1jZu0YnfzSIYDGcEWpCa1wKGWXQ== + dependencies: + abstract-level "^1.0.2" + catering "^2.1.0" + module-error "^1.0.1" + napi-macros "^2.2.2" + node-gyp-build "^4.3.0" + clean-stack@^2.0.0: version "2.2.0" resolved "https://registry.yarnpkg.com/clean-stack/-/clean-stack-2.2.0.tgz#ee8472dbb129e727b31e8a10a427dee9dfe4008b" @@ -3064,13 +2773,20 @@ debug@2.6.9: dependencies: ms "2.0.0" -debug@4, debug@^4.0.1, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1: +debug@4, debug@^4.0.1, debug@^4.1.0, debug@^4.3.1: version "4.3.2" resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.2.tgz#f0a49c18ac8779e31d4a0c6029dfb76873c7428b" integrity sha512-mOp8wKcvj7XxC78zLgw/ZA+6TSgkoE2C/ienthhRD298T7UNwAg9diBpLRxC0mOezLl4B0xV7M0cCO6P/O0Xhw== dependencies: ms "2.1.2" +debug@^4.1.1: + version "4.3.6" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.6.tgz#2ab2c38fbaffebf8aa95fdfe6d88438c7a13c52b" + integrity sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg== + dependencies: + ms "2.1.2" + debug@^4.3.3: version "4.3.4" resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865" @@ -3497,43 +3213,7 @@ etag@~1.8.1: resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887" integrity sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc= -ethers@^5.4.7: - version "5.5.4" - resolved "https://registry.yarnpkg.com/ethers/-/ethers-5.5.4.tgz#e1155b73376a2f5da448e4a33351b57a885f4352" - integrity sha512-N9IAXsF8iKhgHIC6pquzRgPBJEzc9auw3JoRkaKe+y4Wl/LFBtDDunNe7YmdomontECAcC5APaAgWZBiu1kirw== - dependencies: - "@ethersproject/abi" "5.5.0" - "@ethersproject/abstract-provider" "5.5.1" - "@ethersproject/abstract-signer" "5.5.0" - "@ethersproject/address" "5.5.0" - "@ethersproject/base64" "5.5.0" - "@ethersproject/basex" "5.5.0" - "@ethersproject/bignumber" "5.5.0" - "@ethersproject/bytes" "5.5.0" - "@ethersproject/constants" "5.5.0" - "@ethersproject/contracts" "5.5.0" - "@ethersproject/hash" "5.5.0" - "@ethersproject/hdnode" "5.5.0" - "@ethersproject/json-wallets" "5.5.0" - "@ethersproject/keccak256" "5.5.0" - "@ethersproject/logger" "5.5.0" - "@ethersproject/networks" "5.5.2" - "@ethersproject/pbkdf2" "5.5.0" - "@ethersproject/properties" "5.5.0" - "@ethersproject/providers" "5.5.3" - "@ethersproject/random" "5.5.1" - "@ethersproject/rlp" "5.5.0" - "@ethersproject/sha2" "5.5.0" - "@ethersproject/signing-key" "5.5.0" - "@ethersproject/solidity" "5.5.0" - "@ethersproject/strings" "5.5.0" - "@ethersproject/transactions" "5.5.0" - "@ethersproject/units" "5.5.0" - "@ethersproject/wallet" "5.5.0" - "@ethersproject/web" "5.5.1" - "@ethersproject/wordlists" "5.5.0" - -ethers@^5.5.4: +ethers@5.7.2, ethers@^5.5.4: version "5.7.2" resolved "https://registry.yarnpkg.com/ethers/-/ethers-5.7.2.tgz#3a7deeabbb8c030d4126b24f84e525466145872e" integrity sha512-wswUsmWo1aOK8rR7DIKiWSw9DbLWe6x98Jrn8wcTflTVvaXhAMaB5zGAXy0GYQEQp9iO1iSHWVyARQm11zUtyg== @@ -3778,7 +3458,7 @@ finalhandler@~1.1.2: find-replace@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/find-replace/-/find-replace-1.0.3.tgz#b88e7364d2d9c959559f388c66670d6130441fa0" - integrity sha1-uI5zZNLZyVlVnziMZmcNYTBEH6A= + integrity sha512-KrUnjzDCD9426YnCP56zGYy/eieTnhtK6Vn++j+JJzmlsWWwEkDnsyVF575spT6HJ6Ow9tlbT3TQTDsa+O4UWA== dependencies: array-back "^1.0.4" test-value "^2.1.0" @@ -3897,7 +3577,7 @@ fs-monkey@1.0.3: fs.realpath@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" - integrity sha1-FQStJSMVjKpA20onh8sBQRmU6k8= + integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw== fsevents@^2.3.2, fsevents@~2.3.2: version "2.3.2" @@ -3914,12 +3594,13 @@ functional-red-black-tree@^1.0.1: resolved "https://registry.yarnpkg.com/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz#1b0ab3bd553b2a0d6399d29c0e3ea0b252078327" integrity sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc= -ganache@7.7.5: - version "7.7.5" - resolved "https://registry.yarnpkg.com/ganache/-/ganache-7.7.5.tgz#e95f205c9d5d5483764cc1667a583d88d2c29ead" - integrity sha512-H8ybC7l9hhvor61uS4wvQ7UOYpgc4OcqZvFb3XGutPwbxQL4yYDoNjOumfUcq4JalYZF9ojzy95ss7lOvrw76w== +ganache@7.9.0: + version "7.9.0" + resolved "https://registry.yarnpkg.com/ganache/-/ganache-7.9.0.tgz#561deceb376b1c4e8998ac8e5a842574507d3295" + integrity sha512-KdsTZaAKqDXTNDMKnLzg0ngX8wnZKyVGm7HD03GIyUMVRuXI83s0CUEaGIDWRUWTQP7BE8sDh7QtbW+NoX4zrQ== dependencies: "@trufflesuite/bigint-buffer" "1.1.10" + "@trufflesuite/uws-js-unofficial" "20.10.0-unofficial.2" "@types/bn.js" "^5.1.0" "@types/lru-cache" "5.1.1" "@types/seedrandom" "3.0.1" @@ -3996,7 +3677,7 @@ glob-to-regexp@^0.4.1: resolved "https://registry.yarnpkg.com/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz#c75297087c851b9a578bd217dd59a92f59fe546e" integrity sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw== -glob@^7.0.0, glob@^7.1.1, glob@^7.1.2, glob@^7.1.3, glob@^7.1.4, glob@^7.1.6: +glob@^7.0.0, glob@^7.1.1, glob@^7.1.2, glob@^7.1.3, glob@^7.1.4: version "7.2.0" resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.0.tgz#d15535af7732e02e948f4c41628bd910293f6023" integrity sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q== @@ -4008,6 +3689,18 @@ glob@^7.0.0, glob@^7.1.1, glob@^7.1.2, glob@^7.1.3, glob@^7.1.4, glob@^7.1.6: once "^1.3.0" path-is-absolute "^1.0.0" +glob@^7.1.6: + version "7.2.3" + resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.3.tgz#b8df0fb802bbfa8e89bd1d938b4e16578ed44f2b" + integrity sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q== + dependencies: + fs.realpath "^1.0.0" + inflight "^1.0.4" + inherits "2" + minimatch "^3.1.1" + once "^1.3.0" + path-is-absolute "^1.0.0" + globals@^11.1.0: version "11.12.0" resolved "https://registry.yarnpkg.com/globals/-/globals-11.12.0.tgz#ab8795338868a0babd8525758018c2a7eb95c42e" @@ -4032,7 +3725,12 @@ globby@^11.0.3: merge2 "^1.3.0" slash "^3.0.0" -graceful-fs@^4.1.2, graceful-fs@^4.1.6, graceful-fs@^4.2.0, graceful-fs@^4.2.4: +graceful-fs@^4.1.2, graceful-fs@^4.1.6: + version "4.2.11" + resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3" + integrity sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ== + +graceful-fs@^4.2.0, graceful-fs@^4.2.4: version "4.2.8" resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.8.tgz#e412b8d33f5e006593cbd3cee6df9f2cebbe802a" integrity sha512-qkIilPUYcNhJpd33n0GBXTB1MMPp14TxEsEs0pTrsSVucApsYzW5V+Q8Qxhik6KU3evy+qkAAowTByymK0avdg== @@ -4099,7 +3797,7 @@ hdr-histogram-percentiles-obj@^3.0.0: hmac-drbg@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/hmac-drbg/-/hmac-drbg-1.0.1.tgz#d2745701025a6c775a6c545793ed502fc0c649a1" - integrity sha1-0nRXAQJabHdabFRXk+1QL8DGSaE= + integrity sha512-Tti3gMqLdZfhOQY1Mzf/AanLiqh1WTiJgEj26ZuYQ9fbkLomzGchCws4FyrSd4VkpBfiNhaE1On+lOz894jvXg== dependencies: hash.js "^1.0.3" minimalistic-assert "^1.0.0" @@ -4230,7 +3928,7 @@ infer-owner@^1.0.4: inflight@^1.0.4: version "1.0.6" resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9" - integrity sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk= + integrity sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA== dependencies: once "^1.3.0" wrappy "1" @@ -4967,7 +4665,7 @@ jsonc-parser@3.0.0: jsonfile@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-4.0.0.tgz#8771aae0799b64076b76640fca058f9c10e33ecb" - integrity sha1-h3Gq4HmbZAdrdmQPygWPnBDjPss= + integrity sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg== optionalDependencies: graceful-fs "^4.1.6" @@ -5034,6 +4732,15 @@ level-transcoder@^1.0.1: buffer "^6.0.3" module-error "^1.0.1" +level@^8.0.1: + version "8.0.1" + resolved "https://registry.yarnpkg.com/level/-/level-8.0.1.tgz#737161db1bc317193aca4e7b6f436e7e1df64379" + integrity sha512-oPBGkheysuw7DmzFQYyFe8NAia5jFLAgEnkgWnK3OXAuJr8qFT+xBQIwokAZPME2bhPFzS8hlYcL16m8UZrtwQ== + dependencies: + abstract-level "^1.0.4" + browser-level "^1.0.1" + classic-level "^1.2.0" + leveldown@6.1.0: version "6.1.0" resolved "https://registry.yarnpkg.com/leveldown/-/leveldown-6.1.0.tgz#7ab1297706f70c657d1a72b31b40323aa612b9ee" @@ -5303,9 +5010,9 @@ minimalistic-assert@^1.0.0, minimalistic-assert@^1.0.1: minimalistic-crypto-utils@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz#f6c00c1c0b082246e5c4d99dfb8c7c083b2b582a" - integrity sha1-9sAMHAsIIkblxNmd+4x8CDsrWCo= + integrity sha512-JIYlbt6g8i5jKfJ3xz7rF0LXmv2TkDxBLUkiBeZ7bAx4GnnNMr8xFpGnOxn6GhTEHx3SjRrZEoU+j04prX1ktg== -minimatch@^3.0.4: +minimatch@^3.0.4, minimatch@^3.1.1: version "3.1.2" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b" integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw== @@ -5388,7 +5095,7 @@ mkdirp@^1.0.3, mkdirp@^1.0.4: resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e" integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw== -module-error@^1.0.1: +module-error@^1.0.1, module-error@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/module-error/-/module-error-1.0.2.tgz#8d1a48897ca883f47a45816d4fb3e3c6ba404d86" integrity sha512-0yuvsqSCv8LbaOKhnsQ/T5JhyFlCYLPXK3U2sgV10zoKQwzs/MyfuQUOZQ1V/6OCOJsK/TRgNVrPuPDqtdMFtA== @@ -5432,6 +5139,11 @@ nanocolors@^0.2.12: resolved "https://registry.yarnpkg.com/nanocolors/-/nanocolors-0.2.12.tgz#4d05932e70116078673ea4cc6699a1c56cc77777" integrity sha512-SFNdALvzW+rVlzqexid6epYdt8H9Zol7xDoQarioEFcFN0JHo4CYNztAxmtfgGTVRCmFlEOqqhBpoFGKqSAMug== +napi-macros@^2.2.2: + version "2.2.2" + resolved "https://registry.yarnpkg.com/napi-macros/-/napi-macros-2.2.2.tgz#817fef20c3e0e40a963fbf7b37d1600bd0201044" + integrity sha512-hmEVtAGYzVQpCKdbQea4skABsdXW4RUh5t5mJ2zzqowJS2OyXZTU1KhDVFhx+NlWZ4ap9mqR9TcDO3LTTttd+g== + napi-macros@~2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/napi-macros/-/napi-macros-2.0.0.tgz#2b6bae421e7b96eb687aa6c77a7858640670001b" @@ -5619,7 +5331,7 @@ on-finished@^2.3.0, on-finished@~2.3.0: once@^1.3.0, once@^1.3.1, once@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" - integrity sha1-WDsap3WWHUsROsF9nFC6753Xa9E= + integrity sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w== dependencies: wrappy "1" @@ -5782,7 +5494,7 @@ path-exists@^4.0.0: path-is-absolute@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f" - integrity sha1-F0uSaHNVNP+8es5r9TpanhtcX18= + integrity sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg== path-key@^3.0.0, path-key@^3.1.0: version "3.1.1" @@ -5861,7 +5573,12 @@ prettier-linter-helpers@^1.0.0: dependencies: fast-diff "^1.1.2" -prettier@^2.1.2, prettier@^2.3.2: +prettier@^2.1.2: + version "2.8.8" + resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.8.8.tgz#e8c5d7e98a4305ffe3de2e1fc4aca1a71c28b1da" + integrity sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q== + +prettier@^2.3.2: version "2.4.1" resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.4.1.tgz#671e11c89c14a4cfc876ce564106c4a6726c9f5c" integrity sha512-9fbDAXSBcc6Bs1mZrDYb3XKzDLm4EXXL9sC1LqKP5rZkT6KRr/rf9amVUcODVXgguK/isJz0d0hP72WeaKWsvA== @@ -6105,6 +5822,13 @@ run-async@^2.4.0: resolved "https://registry.yarnpkg.com/run-async/-/run-async-2.4.1.tgz#8440eccf99ea3e70bd409d49aab88e10c189a455" integrity sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ== +run-parallel-limit@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/run-parallel-limit/-/run-parallel-limit-1.1.0.tgz#be80e936f5768623a38a963262d6bef8ff11e7ba" + integrity sha512-jJA7irRNM91jaKc3Hcl1npHsFLOXOoTkPCUL1JEa1R82O2miplXXRaGdjW/KM/98YQWDhJLiSs793CnXfblJUw== + dependencies: + queue-microtask "^1.2.2" + run-parallel@^1.1.9: version "1.2.0" resolved "https://registry.yarnpkg.com/run-parallel/-/run-parallel-1.2.0.tgz#66d1368da7bdf921eb9d95bd1a9229e7f21a43ee" @@ -6589,7 +6313,7 @@ test-exclude@^6.0.0: test-value@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/test-value/-/test-value-2.1.0.tgz#11da6ff670f3471a73b625ca4f3fdcf7bb748291" - integrity sha1-Edpv9nDzRxpztiXKTz/c97t0gpE= + integrity sha512-+1epbAxtKeXttkGFMTX9H42oqzOTufR1ceCF+GYA5aOmvaPq9wd4PUS8329fn2RRLGNeUkgRLnVpycjx8DsO2w== dependencies: array-back "^1.0.3" typical "^2.6.0" @@ -6828,9 +6552,9 @@ type@^2.5.0: integrity sha512-eiDBDOmkih5pMbo9OqsqPRGMljLodLcwd5XD5JbtNB0o89xZAwynY9EdCDsJU7LtcVCClu9DvM7/0Ep1hYX3EQ== typechain@^5.1.2: - version "5.1.2" - resolved "https://registry.yarnpkg.com/typechain/-/typechain-5.1.2.tgz#c8784d6155a8e69397ca47f438a3b4fb2aa939da" - integrity sha512-FuaCxJd7BD3ZAjVJoO+D6TnqKey3pQdsqOBsC83RKYWKli5BDhdf0TPkwfyjt20TUlZvOzJifz+lDwXsRkiSKA== + version "5.2.0" + resolved "https://registry.yarnpkg.com/typechain/-/typechain-5.2.0.tgz#10525a44773a34547eb2eed8978cb72c0a39a0f4" + integrity sha512-0INirvQ+P+MwJOeMct+WLkUE4zov06QxC96D+i3uGFEHoiSkZN70MKDQsaj8zkL86wQwByJReI2e7fOUwECFuw== dependencies: "@types/prettier" "^2.1.1" command-line-args "^4.0.7" @@ -6868,7 +6592,7 @@ typescript@^4.3.5: typical@^2.6.0, typical@^2.6.1: version "2.6.1" resolved "https://registry.yarnpkg.com/typical/-/typical-2.6.1.tgz#5c080e5d661cbbe38259d2e70a3c7253e873881d" - integrity sha1-XAgOXWYcu+OCWdLnCjxyU+hziB0= + integrity sha512-ofhi8kjIje6npGozTip9Fr8iecmYfEbS06i0JnIg+rh51KakryWF4+jX8lLKZVhy6N+ID45WYSFCxPOdTWCzNg== unique-filename@^1.1.1: version "1.1.1" @@ -7135,7 +6859,7 @@ wrap-ansi@^7.0.0: wrappy@1: version "1.0.2" resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" - integrity sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8= + integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ== write-file-atomic@^3.0.0: version "3.0.3" @@ -7152,6 +6876,11 @@ ws@7.4.6: resolved "https://registry.yarnpkg.com/ws/-/ws-7.4.6.tgz#5654ca8ecdeee47c33a9a4bf6d28e2be2980377c" integrity sha512-YmhHDO4MzaDLB+M9ym/mDA5z0naX8j7SIlT8f8z+I0VtzsRbekxEutHSme7NPS2qE8StCYQNUnfWdXta/Yu85A== +ws@8.2.3: + version "8.2.3" + resolved "https://registry.yarnpkg.com/ws/-/ws-8.2.3.tgz#63a56456db1b04367d0b721a0b80cae6d8becbba" + integrity sha512-wBuoj1BDpC6ZQ1B7DWQBYVLphPWkm8i9Y0/3YdHjHKHiohOJ1ws+3OccDWtH+PoC9DZD5WOTrJvNbWvjS6JWaA== + ws@^7.4.6: version "7.5.5" resolved "https://registry.yarnpkg.com/ws/-/ws-7.5.5.tgz#8b4bc4af518cfabd0473ae4f99144287b33eb881"