diff --git a/packages/brain/src/index.ts b/packages/brain/src/index.ts index edaf7b92..ada2d640 100644 --- a/packages/brain/src/index.ts +++ b/packages/brain/src/index.ts @@ -15,9 +15,9 @@ import { params } from "./params.js"; import { CronJob, reloadValidators, - trackValidatorsPerformance, + trackValidatorsPerformanceCron, sendProofsOfValidation, - getSecondsToNextEpoch + startWithinTenFirstPercentageOfEpoch } from "./modules/cron/index.js"; import { PostgresClient } from "./modules/apiClients/index.js"; import { brainConfig } from "./modules/config/index.js"; @@ -102,29 +102,24 @@ const proofOfValidationCron = new CronJob(shareCronInterval, () => sendProofsOfValidation(signerApi, brainDb, dappnodeSignatureVerifierApi, shareDataWithDappnode) ); proofOfValidationCron.start(); -const trackValidatorsPerformanceCron = new CronJob(slotsPerEpoch * secondsPerSlot * 1000, () => - // once every epoch - trackValidatorsPerformance({ - brainDb, - postgresClient, - beaconchainApi, - minGenesisTime, - secondsPerSlot, - executionClient, - consensusClient - }) + +// executes once every epoch +export const trackValidatorsPerformanceCronTask = new CronJob(slotsPerEpoch * secondsPerSlot * 1000, () => + trackValidatorsPerformanceCron({ brainDb, postgresClient, beaconchainApi, executionClient, consensusClient }) ); -const secondsToNextEpoch = getSecondsToNextEpoch({ minGenesisTime, secondsPerSlot }); -// start the cron within the first minute of an epoch -// If it remains more than 1 minute then wait for the next epoch (+ 10 seconds of margin) -if (secondsToNextEpoch > 60) setTimeout(() => trackValidatorsPerformanceCron.start(), (secondsToNextEpoch + 10) * 1000); -else trackValidatorsPerformanceCron.start(); +startWithinTenFirstPercentageOfEpoch({ + minGenesisTime, + secondsPerSlot, + slotsPerEpoch, + jobFunction: trackValidatorsPerformanceCronTask +}); // Graceful shutdown function handle(signal: string): void { logger.info(`${signal} received. Shutting down...`); reloadValidatorsCron.stop(); proofOfValidationCron.stop(); + trackValidatorsPerformanceCronTask.stop(); brainDb.close(); postgresClient.close().catch((err) => logger.error(`Error closing postgres client`, err)); // postgresClient db connection is the only external resource that needs to be closed uiServer.close(); diff --git a/packages/brain/src/modules/apiClients/beaconchain/index.ts b/packages/brain/src/modules/apiClients/beaconchain/index.ts index 7d0c0a90..a5409d6e 100644 --- a/packages/brain/src/modules/apiClients/beaconchain/index.ts +++ b/packages/brain/src/modules/apiClients/beaconchain/index.ts @@ -13,7 +13,8 @@ import { BeaconchainSyncingStatusGetResponse, BeaconchainSyncCommitteePostResponse, BeaconchainBlockRewardsGetResponse, - BeaconchainProposerDutiesGetResponse + BeaconchainProposerDutiesGetResponse, + BlockId } from "./types.js"; import { StandardApi } from "../standard.js"; import path from "path"; @@ -21,9 +22,6 @@ import { ApiParams } from "../types.js"; import { Network } from "@stakingbrain/common"; import { BeaconchainApiError } from "./error.js"; -// TODO: BlockId can also be a simple slot in the form of a string. Is this type still necessary? -type BlockId = "head" | "genesis" | "finalized" | string | `0x${string}`; - export class BeaconchainApi extends StandardApi { private SLOTS_PER_EPOCH: number; diff --git a/packages/brain/src/modules/apiClients/beaconchain/types.ts b/packages/brain/src/modules/apiClients/beaconchain/types.ts index 40632582..074e49f1 100644 --- a/packages/brain/src/modules/apiClients/beaconchain/types.ts +++ b/packages/brain/src/modules/apiClients/beaconchain/types.ts @@ -207,3 +207,5 @@ export interface BeaconchainLivenessPostResponse { is_live: boolean; }[]; } + +export type BlockId = "head" | "genesis" | "finalized" | string | `0x${string}`; diff --git a/packages/brain/src/modules/apiClients/postgres/index.ts b/packages/brain/src/modules/apiClients/postgres/index.ts index a1f71a8b..cd82a004 100644 --- a/packages/brain/src/modules/apiClients/postgres/index.ts +++ b/packages/brain/src/modules/apiClients/postgres/index.ts @@ -32,7 +32,7 @@ enum Columns { liveness = "liveness", blockProposalStatus = "block_proposal_status", syncCommitteeRewards = "sync_comittee_rewards", - attestationsRewards = "attestations_rewards", + attestationsTotalRewards = "attestations_total_rewards", error = "error" } @@ -123,9 +123,9 @@ CREATE TABLE IF NOT EXISTS ${this.tableName} ( ${Columns.consensusClient} ${this.CONSENSUS_CLIENT} NOT NULL, ${Columns.slot} BIGINT, ${Columns.liveness} BOOLEAN, - ${Columns.blockProposalStatus} ${this.BLOCK_PROPOSAL_STATUS}, + ${Columns.blockProposalStatus} ${this.BLOCK_PROPOSAL_STATUS} NOT NULL, ${Columns.syncCommitteeRewards} BIGINT, - ${Columns.attestationsRewards} JSONB, + ${Columns.attestationsTotalRewards} JSONB NOT NULL, ${Columns.error} TEXT, PRIMARY KEY (${Columns.validatorIndex}, ${Columns.epoch}) ); @@ -150,12 +150,12 @@ CREATE TABLE IF NOT EXISTS ${this.tableName} ( * Inserts the given performance data into the database. * * @param data - The performance data to insert. - * @example insertPerformanceData({ validatorIndex: 1, epoch: 1, slot: 1, liveness: true, blockProposalStatus: "missed", syncCommitteeRewards: 100, attestationsRewards: { attestation1: 10, attestation2: 20 } }) + * @example insertPerformanceData({ validatorIndex: 1, epoch: 1, slot: 1, liveness: true, blockProposalStatus: "missed", syncCommitteeRewards: 100, attestationsTotalRewards: { attestation1: 10, attestation2: 20 } }) */ public async insertPerformanceData(data: ValidatorPerformance): Promise { const query = ` -INSERT INTO ${this.tableName} (${Columns.validatorIndex}, ${Columns.epoch}, ${Columns.slot}, ${Columns.liveness}, ${Columns.blockProposalStatus}, ${Columns.syncCommitteeRewards}, ${Columns.attestationsRewards}, ${Columns.error}) -VALUES ($1, $2, $3, $4, $5, $6, $7, $8) +INSERT INTO ${this.tableName} (${Columns.validatorIndex}, ${Columns.epoch}, ${Columns.executionClient}, ${Columns.consensusClient}, ${Columns.slot}, ${Columns.liveness}, ${Columns.blockProposalStatus}, ${Columns.syncCommitteeRewards}, ${Columns.attestationsTotalRewards}, ${Columns.error}) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) `; await this.sql.unsafe(query, [ @@ -165,15 +165,16 @@ VALUES ($1, $2, $3, $4, $5, $6, $7, $8) data.consensusClient, data.slot ?? null, data.liveness ?? null, - data.blockProposalStatus ?? null, + data.blockProposalStatus, data.syncCommitteeRewards ?? null, - JSON.stringify(data.attestationsRewards) ?? null, // JSONB expects a string + JSON.stringify(data.attestationsTotalRewards), // JSONB expects a string data.error ?? null ]); } /** - * Get the validators data for the given validator indexes from all epochs. + * Get the validators data for the given validator indexes from all epochs. In order to improve data process + * it will return a map with the validator index as key and the performance data as value. * * @param validatorIndexes - The indexes of the validators to get the data for. * @returns The performance data for the given validators. @@ -185,7 +186,7 @@ WHERE ${Columns.validatorIndex} = ANY($1) `; const result = await this.sql.unsafe(query, [validatorIndexes]); - // TODO: add type for result + // TODO: add type to result // eslint-disable-next-line @typescript-eslint/no-explicit-any return result.map((row: any) => ({ validatorIndex: row.validator_index, @@ -196,11 +197,64 @@ WHERE ${Columns.validatorIndex} = ANY($1) liveness: row.liveness, blockProposalStatus: row.block_proposal_status, syncCommitteeRewards: row.sync_comittee_rewards, - attestationsRewards: row.attestations_rewards, + attestationsTotalRewards: JSON.parse(row.attestations_total_rewards), error: row.error })); } + /** + * Get tje validators data for the given validator indexes and an epoch start and end range. In order to improve data process + * it will return a map with the validator index as key and the performance data as value. + * + * @param validatorIndexes - The indexes of the validators to get the data for. + * @param startEpoch - The start epoch number. + * @param endEpoch - The end epoch number. + * @returns The performance data for the given validators. + */ + public async getValidatorsDataMapForEpochRange({ + validatorIndexes, + startEpoch, + endEpoch + }: { + validatorIndexes: string[]; + startEpoch: number; + endEpoch: number; + }): Promise> { + const query = ` +SELECT * FROM ${this.tableName} +WHERE ${Columns.validatorIndex} = ANY($1) +AND ${Columns.epoch} >= $2 +AND ${Columns.epoch} <= $3 + `; + + const result = await this.sql.unsafe(query, [validatorIndexes, startEpoch, endEpoch]); + // TODO: add type to result + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return result.reduce((map: Map, row: any) => { + const key = row.validator_index; + const performanceData = { + validatorIndex: row.validator_index, + epoch: row.epoch, + executionClient: row.execution_client, + consensusClient: row.consensus_client, + slot: row.slot, + liveness: row.liveness, + blockProposalStatus: row.block_proposal_status, + syncCommitteeRewards: row.sync_comittee_rewards, + attestationsTotalRewards: JSON.parse(row.attestations_total_rewards), + error: row.error + }; + + if (map.has(key)) { + map.get(key)?.push(performanceData); + } else { + map.set(key, [performanceData]); + } + + return map; + }, new Map()); + } + /** * Method to close the database connection. */ diff --git a/packages/brain/src/modules/apiClients/postgres/types.ts b/packages/brain/src/modules/apiClients/postgres/types.ts index f8653d9b..99b501e3 100644 --- a/packages/brain/src/modules/apiClients/postgres/types.ts +++ b/packages/brain/src/modules/apiClients/postgres/types.ts @@ -7,15 +7,24 @@ export enum BlockProposalStatus { Error = "Error" } +export interface AttestationsTotalRewards { + validator_index: string; + head: string; + target: string; + source: string; + inclusion_delay: string; + inactivity: string; +} + export interface ValidatorPerformance { validatorIndex: number; epoch: number; executionClient: ExecutionClient; consensusClient: ConsensusClient; + blockProposalStatus: BlockProposalStatus; + attestationsTotalRewards: AttestationsTotalRewards; slot?: number; liveness?: boolean; - blockProposalStatus?: BlockProposalStatus; syncCommitteeRewards?: number; - attestationsRewards?: object; error?: string; } diff --git a/packages/brain/src/modules/config/networks/gnosis.ts b/packages/brain/src/modules/config/networks/gnosis.ts index bc69db4e..97e0fb1f 100644 --- a/packages/brain/src/modules/config/networks/gnosis.ts +++ b/packages/brain/src/modules/config/networks/gnosis.ts @@ -15,7 +15,7 @@ export const gnosisBrainConfig = ( consensusClient, isMevBoostSet, executionClientUrl: "http://execution.gnosis.dncore.dappnode:8545", - validatorUrl: "http://validator.gnosis.dncore.dappnode:3500", + validatorUrl: `${consensusClient === "teku" ? "https" : "http"}://validator.gnosis.dncore.dappnode:3500`, beaconchainUrl: "http:/beacon-chain.gnosis.dncore.dappnode:3500", blockExplorerUrl: "https://gnosischa.in", signerUrl: "http://web3signer.web3signer-gnosis.dappnode:9000", diff --git a/packages/brain/src/modules/config/networks/holesky.ts b/packages/brain/src/modules/config/networks/holesky.ts index 15eb5a38..744b1032 100644 --- a/packages/brain/src/modules/config/networks/holesky.ts +++ b/packages/brain/src/modules/config/networks/holesky.ts @@ -15,7 +15,7 @@ export const holeskyBrainConfig = ( consensusClient, isMevBoostSet, executionClientUrl: "http://execution.holesky.dncore.dappnode:8545", - validatorUrl: "http://validator.holesky.dncore.dappnode:3500", + validatorUrl: `${consensusClient === "teku" ? "https" : "http"}://validator.holesky.dncore.dappnode:3500`, beaconchainUrl: "http:/beacon-chain.holesky.dncore.dappnode:3500", blockExplorerUrl: "https://holesky.beaconcha.in", signerUrl: "http://web3signer.web3signer-holesky.dappnode:9000", diff --git a/packages/brain/src/modules/config/networks/lukso.ts b/packages/brain/src/modules/config/networks/lukso.ts index 388e95d1..bb8e0bb3 100644 --- a/packages/brain/src/modules/config/networks/lukso.ts +++ b/packages/brain/src/modules/config/networks/lukso.ts @@ -15,7 +15,7 @@ export const luksoBrainConfig = ( consensusClient, isMevBoostSet, executionClientUrl: "http://execution.lukso.dncore.dappnode:8545", - validatorUrl: "http://validator.lukso.dncore.dappnode:3500", + validatorUrl: `${consensusClient === "teku" ? "https" : "http"}://validator.lukso.dncore.dappnode:3500`, beaconchainUrl: "http:/beacon-chain.lukso.dncore.dappnode:3500", blockExplorerUrl: "https://explorer.consensus.mainnet.lukso.network/", signerUrl: "http://web3signer.web3signer-lukso.dappnode:9000", diff --git a/packages/brain/src/modules/config/networks/mainnet.ts b/packages/brain/src/modules/config/networks/mainnet.ts index d8e4776a..39954586 100644 --- a/packages/brain/src/modules/config/networks/mainnet.ts +++ b/packages/brain/src/modules/config/networks/mainnet.ts @@ -15,7 +15,7 @@ export const mainnetBrainConfig = ( consensusClient, isMevBoostSet, executionClientUrl: "http://execution.mainnet.dncore.dappnode:8545", - validatorUrl: "http://validator.mainnet.dncore.dappnode:3500", + validatorUrl: `${consensusClient === "teku" ? "https" : "http"}://validator.mainnet.dncore.dappnode:3500`, beaconchainUrl: "http:/beacon-chain.mainnet.dncore.dappnode:3500", blockExplorerUrl: "https://beaconcha.in", signerUrl: "http://web3signer.web3signer.dappnode:9000", diff --git a/packages/brain/src/modules/config/networks/prater.ts b/packages/brain/src/modules/config/networks/prater.ts index 213bd088..54ecd81b 100644 --- a/packages/brain/src/modules/config/networks/prater.ts +++ b/packages/brain/src/modules/config/networks/prater.ts @@ -15,7 +15,7 @@ export const praterBrainConfig = ( consensusClient, isMevBoostSet, executionClientUrl: "http://execution.prater.dncore.dappnode:8545", - validatorUrl: "http://validator.prater.dncore.dappnode:3500", + validatorUrl: `${consensusClient === "teku" ? "https" : "http"}://validator.prater.dncore.dappnode:3500`, beaconchainUrl: "http:/beacon-chain.prater.dncore.dappnode:3500", blockExplorerUrl: "https://prater.beaconcha.in", signerUrl: "http://web3signer.web3signer-prater.dappnode:9000", diff --git a/packages/brain/src/modules/cron/index.ts b/packages/brain/src/modules/cron/index.ts index c40eb52f..55cdd064 100644 --- a/packages/brain/src/modules/cron/index.ts +++ b/packages/brain/src/modules/cron/index.ts @@ -1,4 +1,7 @@ export { CronJob } from "./cron.js"; export { reloadValidators } from "./reloadValidators/index.js"; export { sendProofsOfValidation } from "./sendProofsOfValidation/index.js"; -export { trackValidatorsPerformance, getSecondsToNextEpoch } from "./trackValidatorsPerformance/index.js"; +export { + trackValidatorsPerformanceCron, + startWithinTenFirstPercentageOfEpoch +} from "./trackValidatorsPerformance/index.js"; diff --git a/packages/brain/src/modules/cron/trackValidatorsPerformance/checkNodeHealth.ts b/packages/brain/src/modules/cron/trackValidatorsPerformance/checkNodeHealth.ts deleted file mode 100644 index 2d576227..00000000 --- a/packages/brain/src/modules/cron/trackValidatorsPerformance/checkNodeHealth.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { BeaconchainApi } from "../../apiClients/index.js"; -import logger from "../../logger/index.js"; -import { logPrefix } from "./logPrefix.js"; - -/** - * Check the health of the node, if the EL node is offline or the node is syncing, an error will be thrown. - * - * @param {BeaconchainApi} beaconchainApi - Beaconchain API client. - * @throws {Error} - If the EL node is offline or the node is syncing. - */ -export async function checkNodeHealth({ beaconchainApi }: { beaconchainApi: BeaconchainApi }): Promise { - const { el_offline, is_syncing } = (await beaconchainApi.getSyncingStatus()).data; - logger.debug(`${logPrefix}EL Node offline: ${el_offline}, Node syncing: ${is_syncing}`); - if (el_offline) throw Error("EL Node is offline"); - if (is_syncing) throw Error("Node is syncing"); -} diff --git a/packages/brain/src/modules/cron/trackValidatorsPerformance/getActiveValidatorsLoadedInBrain.ts b/packages/brain/src/modules/cron/trackValidatorsPerformance/getActiveValidatorsLoadedInBrain.ts index 61120a58..244f9db1 100644 --- a/packages/brain/src/modules/cron/trackValidatorsPerformance/getActiveValidatorsLoadedInBrain.ts +++ b/packages/brain/src/modules/cron/trackValidatorsPerformance/getActiveValidatorsLoadedInBrain.ts @@ -16,16 +16,14 @@ import { logPrefix } from "./logPrefix.js"; */ export async function getActiveValidatorsLoadedInBrain({ beaconchainApi, - brainDb, - activeValidatorsIndexes + brainDb }: { beaconchainApi: BeaconchainApi; brainDb: BrainDataBase; - activeValidatorsIndexes: string[]; -}): Promise { +}): Promise { const validatorIndexes = await getValidatorIndexesAndSaveInDb({ beaconchainApi, brainDb }); - if (validatorIndexes.length === 0) return; - ( + if (validatorIndexes.length === 0) return []; + return ( await beaconchainApi.postStateValidators({ body: { ids: validatorIndexes, @@ -33,7 +31,7 @@ export async function getActiveValidatorsLoadedInBrain({ }, stateId: "finalized" }) - ).data.forEach((validator) => activeValidatorsIndexes.push(validator.index)); + ).data.map((validator) => validator.index.toString()); } /** diff --git a/packages/brain/src/modules/cron/trackValidatorsPerformance/getAttestationsTotalRewards.ts b/packages/brain/src/modules/cron/trackValidatorsPerformance/getAttestationsTotalRewards.ts index 67299aa0..7adb0a1e 100644 --- a/packages/brain/src/modules/cron/trackValidatorsPerformance/getAttestationsTotalRewards.ts +++ b/packages/brain/src/modules/cron/trackValidatorsPerformance/getAttestationsTotalRewards.ts @@ -6,24 +6,22 @@ import { TotalRewards } from "../../apiClients/types.js"; * * @param {BeaconchainApi} beaconchainApi - Beaconchain API client. * @param {string} epoch - The epoch to get the rewards. - * @param {string[]} validatorIndexes - Array of validator indexes. + * @param {string[]} activeValidatorsIndexes - Array of active validator indexes. * @returns {TotalRewards[]} - Array of total rewards for the validators. */ export async function getAttestationsTotalRewards({ beaconchainApi, epoch, - validatorIndexes, - totalRewards + activeValidatorsIndexes }: { beaconchainApi: BeaconchainApi; epoch: string; - validatorIndexes: string[]; - totalRewards: TotalRewards[]; -}): Promise { - ( + activeValidatorsIndexes: string[]; +}): Promise { + return ( await beaconchainApi.getAttestationsRewards({ epoch, - pubkeysOrIndexes: validatorIndexes + pubkeysOrIndexes: activeValidatorsIndexes }) - ).data.total_rewards.forEach((reward) => totalRewards.push(reward)); + ).data.total_rewards; } diff --git a/packages/brain/src/modules/cron/trackValidatorsPerformance/setBlockProposalStatusMap.ts b/packages/brain/src/modules/cron/trackValidatorsPerformance/getBlockProposalStatusMap.ts similarity index 73% rename from packages/brain/src/modules/cron/trackValidatorsPerformance/setBlockProposalStatusMap.ts rename to packages/brain/src/modules/cron/trackValidatorsPerformance/getBlockProposalStatusMap.ts index d06aebb3..7ee85900 100644 --- a/packages/brain/src/modules/cron/trackValidatorsPerformance/setBlockProposalStatusMap.ts +++ b/packages/brain/src/modules/cron/trackValidatorsPerformance/getBlockProposalStatusMap.ts @@ -8,19 +8,19 @@ import { logPrefix } from "./logPrefix.js"; * * @param {BeaconchainApi} beaconchainApi - Beaconchain API client. * @param {string} epoch - The epoch to get the block proposal duties. - * @param {string[]} validatorIndexes - Array of validator indexes. + * @param {string[]} activeValidatorIndexes - Array of validator indexes. */ -export async function setBlockProposalStatusMap({ +export async function getBlockProposalStatusMap({ beaconchainApi, epoch, - validatorIndexes, - validatorBlockStatusMap + activeValidatorsIndexes }: { beaconchainApi: BeaconchainApi; epoch: string; - validatorIndexes: string[]; - validatorBlockStatusMap: Map; -}): Promise { + activeValidatorsIndexes: string[]; +}): Promise> { + // Initialize the map with the block proposal status of each validator. + const validatorBlockStatusMap = new Map(); // Get the block proposal duties for the given epoch. Which validators // are supposed to propose a block in which slot? const blockProposalsResponse = await beaconchainApi.getProposerDuties({ @@ -28,7 +28,7 @@ export async function setBlockProposalStatusMap({ }); // Utilize a Set for quick lookup. We assume that the validator indexes are unique. - const validatorIndexesSet = new Set(validatorIndexes); + const validatorIndexesSet = new Set(activeValidatorsIndexes); // Initialize all validator's status to Unchosen. validatorIndexesSet.forEach((validatorIndex) => { @@ -43,14 +43,13 @@ export async function setBlockProposalStatusMap({ // enter loop if one of our monitored validators had to propose in this slot if (validatorIndexesSet.has(validator_index)) { try { - // Get the block header for the slot. It has the proposer index. - const blockHeader = await beaconchainApi.getBlockHeader({ blockId: slot }); + // Get the proposer index from the block header for the slot + const proposerIndex = (await beaconchainApi.getBlockHeader({ blockId: slot })).data.header.message + .proposer_index; // If the proposer index in the block header matches the validator index, the block was proposed correctly. validatorBlockStatusMap.set( validator_index, - blockHeader.data.header.message.proposer_index === validator_index - ? BlockProposalStatus.Proposed - : BlockProposalStatus.Error + proposerIndex === validator_index ? BlockProposalStatus.Proposed : BlockProposalStatus.Error ); } catch (error) { if (error.status === 404) { @@ -64,4 +63,5 @@ export async function setBlockProposalStatusMap({ } } } + return validatorBlockStatusMap; } diff --git a/packages/brain/src/modules/cron/trackValidatorsPerformance/index.ts b/packages/brain/src/modules/cron/trackValidatorsPerformance/index.ts index e3e833fa..8714c2c4 100644 --- a/packages/brain/src/modules/cron/trackValidatorsPerformance/index.ts +++ b/packages/brain/src/modules/cron/trackValidatorsPerformance/index.ts @@ -1,156 +1,2 @@ -import { BeaconchainApi } from "../../apiClients/beaconchain/index.js"; -import { PostgresClient } from "../../apiClients/postgres/index.js"; -import logger from "../../logger/index.js"; -import { BrainDataBase } from "../../db/index.js"; -import { insertPerformanceDataNotThrow } from "./insertPerformanceData.js"; -import { getAttestationsTotalRewards } from "./getAttestationsTotalRewards.js"; -import { setBlockProposalStatusMap } from "./setBlockProposalStatusMap.js"; -import { checkNodeHealth } from "./checkNodeHealth.js"; -import { getActiveValidatorsLoadedInBrain } from "./getActiveValidatorsLoadedInBrain.js"; -import { logPrefix } from "./logPrefix.js"; -import { TotalRewards } from "../../apiClients/types.js"; -import { BlockProposalStatus } from "../../apiClients/postgres/types.js"; -import { ConsensusClient, ExecutionClient } from "@stakingbrain/common"; - -const MINUTE_IN_SECONDS = 60; - -// TODO: at this moment Lighthouse client does not support retrieving: -// - liveness of validator from finalized epoch: -// ```400: BAD_REQUEST: request epoch 79833 is more than one epoch from the current epoch 79835``` -// - sync committee rewards: -// ```404: NOT_FOUND: Parent state is not available! MissingBeaconState(0xa9592014ad4aa3d5dcc4ef67b669278a85fb4dbe80f12364f2486444b7db3927)``` - -/** - * Cron task that will track validators performance for the epoch finalized and store it in the Postgres DB. - * If any issue is arisen during the process, it will be retried after 1 minute. If the issue persists until the epoch - * finalized changes, the issue will be logged and stored in the DB. - * - * @param validatorPubkeys - The pubkeys of the validators to track. - * @param postgresClient - Postgres client to interact with the DB. - * @param beaconchainApi - Beaconchain API client to interact with the Beaconchain API. - * @param minGenesisTime - The minimum genesis time of the chain. - */ -export async function trackValidatorsPerformance({ - brainDb, - postgresClient, - beaconchainApi, - minGenesisTime, - secondsPerSlot, - executionClient, - consensusClient -}: { - brainDb: BrainDataBase; - postgresClient: PostgresClient; - beaconchainApi: BeaconchainApi; - minGenesisTime: number; - secondsPerSlot: number; - executionClient: ExecutionClient; - consensusClient: ConsensusClient; -}): Promise { - try { - const epochFinalized = await beaconchainApi.getEpochHeader({ blockId: "finalized" }); - let errorGettingValidatorData: Error | undefined; - let newEpochFinalized = epochFinalized; - const activeValidatorsIndexes: string[] = []; - const validatorsAttestationsRewards: TotalRewards[] = []; - const validatorBlockStatusMap: Map = new Map(); - - label: while (epochFinalized === newEpochFinalized) { - try { - logger.debug(`${logPrefix}Epoch finalized: ${epochFinalized}`); - - // active validators indexes - await getActiveValidatorsLoadedInBrain({ beaconchainApi, brainDb, activeValidatorsIndexes }); - if (activeValidatorsIndexes.length === 0) { - logger.info(`${logPrefix}No active validators found`); - return; - } - logger.debug(`${logPrefix}Active validators: ${activeValidatorsIndexes}`); - - // check node health - await checkNodeHealth({ beaconchainApi }); - - // get block attestations rewards - await getAttestationsTotalRewards({ - beaconchainApi, - epoch: epochFinalized.toString(), - validatorIndexes: activeValidatorsIndexes, - totalRewards: validatorsAttestationsRewards - }); - - // get block proposal status - await setBlockProposalStatusMap({ - beaconchainApi, - epoch: epochFinalized.toString(), - validatorIndexes: activeValidatorsIndexes, - validatorBlockStatusMap - }); - - // update error to undefined if no error occurred in last iteration - errorGettingValidatorData = undefined; - } catch (error) { - logger.error(`${logPrefix}Error occurred: ${error}. Updating epoch finalized and retrying in 1 minute`); - // update error if an error occurred - errorGettingValidatorData = error; - - // skip if the seconds to the next epoch is less than 1 minute - const secondsToNextEpoch = getSecondsToNextEpoch({ minGenesisTime, secondsPerSlot }); - if (secondsToNextEpoch < MINUTE_IN_SECONDS) { - logger.warn( - `${logPrefix}Could not get validator data for epoch ${epochFinalized}. Writing error and skipping to next epoch.` - ); - // TODO: collect report of the staker setup status: el is offline, node is syncing, signer is not up and original error - // exit the while loop and write the error to the DB - break label; - } - // wait 1 minute without blocking the event loop and update epoch finalized - newEpochFinalized = await new Promise((resolve) => - setTimeout( - async () => resolve(await beaconchainApi.getEpochHeader({ blockId: "finalized" })), - MINUTE_IN_SECONDS * 1000 - ) - ); - } - } - logger.debug(`${logPrefix}Epoch finalized changed: ${newEpochFinalized}`); - - // insert performance data or each validator - await insertPerformanceDataNotThrow({ - postgresClient, - validatorIndexes: activeValidatorsIndexes, - epochFinalized, - validatorBlockStatus: validatorBlockStatusMap, - validatorsAttestationsRewards, - error: errorGettingValidatorData, - executionClient, - consensusClient - }); - logger.debug(`${logPrefix}Performance data inserted for epoch ${epochFinalized}`); - } catch (e) { - logger.error(`${logPrefix}Error in trackValidatorsPerformance: ${e}`); - return; - } -} - -/** - * Get the seconds to the start of the next epoch based on the current Unix time and the minimum genesis time of the chain. - * - * @param {number} minGenesisTime - Minimum genesis time of the chain. - * @param {number} secondsPerSlot - Seconds per slot. - * @returns {number} - Seconds to the start of the next epoch. - */ -export function getSecondsToNextEpoch({ - minGenesisTime, - secondsPerSlot -}: { - minGenesisTime: number; - secondsPerSlot: number; -}): number { - const currentUnixTime = Math.floor(Date.now() / 1000); - const timeDifference = currentUnixTime - minGenesisTime; // Time difference in seconds - const stlotsSinceGenesis = timeDifference / secondsPerSlot; // Slots since genesis - const currentEpoch = Math.floor(stlotsSinceGenesis / 32); // Current epoch - const nextEpochStartSlot = (currentEpoch + 1) * 32; // Slot at the start of the next epoch - const nextEpochStartTime = nextEpochStartSlot * secondsPerSlot + minGenesisTime; // Time at the start of the next epoch in seconds - return nextEpochStartTime - currentUnixTime; // Return the difference in seconds -} +export { trackValidatorsPerformanceCron } from "./trackValidatorsPerformance.js"; +export { startWithinTenFirstPercentageOfEpoch } from "./startWithinTenFirstPercentageOfEpoch.js"; diff --git a/packages/brain/src/modules/cron/trackValidatorsPerformance/insertPerformanceData.ts b/packages/brain/src/modules/cron/trackValidatorsPerformance/insertPerformanceData.ts index e31268da..0e31d9f7 100644 --- a/packages/brain/src/modules/cron/trackValidatorsPerformance/insertPerformanceData.ts +++ b/packages/brain/src/modules/cron/trackValidatorsPerformance/insertPerformanceData.ts @@ -11,42 +11,42 @@ import { logPrefix } from "./logPrefix.js"; * with the next validator. * * @param postgresClient - Postgres client to interact with the DB. - * @param validatorIndexes - Array of validator indexes. + * @param activeValidatorIndexes - Array of validator indexes. * @param epochFinalized - The epoch finalized. - * @param validatorBlockStatus - Map with the block proposal status of each validator. - * @param validatorsAttestationsRewards - Array of total rewards for the validators. + * @param validatorBlockStatusMap - Map with the block proposal status of each validator. + * @param validatorsAttestationsTotalRewards - Array of total rewards for the validators. */ export async function insertPerformanceDataNotThrow({ postgresClient, - validatorIndexes, - epochFinalized, - validatorBlockStatus, - validatorsAttestationsRewards, + activeValidatorsIndexes, + currentEpoch, + validatorBlockStatusMap, + validatorsAttestationsTotalRewards, executionClient, consensusClient, error }: { postgresClient: PostgresClient; - validatorIndexes: string[]; - epochFinalized: number; - validatorBlockStatus: Map; - validatorsAttestationsRewards: TotalRewards[]; + activeValidatorsIndexes: string[]; + currentEpoch: number; + validatorBlockStatusMap: Map; + validatorsAttestationsTotalRewards: TotalRewards[]; executionClient: ExecutionClient; consensusClient: ConsensusClient; error?: Error; }): Promise { - for (const validatorIndex of validatorIndexes) { + for (const validatorIndex of activeValidatorsIndexes) { //const liveness = validatorsLiveness.find((liveness) => liveness.index === validatorIndex)?.is_live; - const attestationsRewards = validatorsAttestationsRewards.find( + const attestationsTotalRewards = validatorsAttestationsTotalRewards.find( (attestationReward) => attestationReward.validator_index === validatorIndex ); - if (!attestationsRewards) { - logger.error(`${logPrefix}Missing data for validator ${validatorIndex}, att: ${attestationsRewards}`); + if (!attestationsTotalRewards) { + logger.error(`${logPrefix}Missing data for validator ${validatorIndex}, att: ${attestationsTotalRewards}`); continue; } - const blockProposalStatus = validatorBlockStatus.get(validatorIndex); + const blockProposalStatus = validatorBlockStatusMap.get(validatorIndex); if (!blockProposalStatus) { logger.error( `${logPrefix}Missing block proposal data for validator ${validatorIndex}, block: ${blockProposalStatus}` @@ -54,18 +54,18 @@ export async function insertPerformanceDataNotThrow({ continue; } - // write on db - logger.debug(`${logPrefix}Inserting performance data for validator ${validatorIndex}`); try { + logger.debug(`${logPrefix}Inserting performance data for validator ${validatorIndex}`); await postgresClient.insertPerformanceData({ validatorIndex: parseInt(validatorIndex), - epoch: epochFinalized, + epoch: currentEpoch, blockProposalStatus, - attestationsRewards, + attestationsTotalRewards, error: error?.message, executionClient, consensusClient }); + logger.debug(`${logPrefix}Performance data inserted for epoch ${currentEpoch}`); } catch (e) { logger.error(`${logPrefix}Error inserting performance data for validator ${validatorIndex}: ${e}`); continue; diff --git a/packages/brain/src/modules/cron/trackValidatorsPerformance/startWithinTenFirstPercentageOfEpoch.ts b/packages/brain/src/modules/cron/trackValidatorsPerformance/startWithinTenFirstPercentageOfEpoch.ts new file mode 100644 index 00000000..8b4b239c --- /dev/null +++ b/packages/brain/src/modules/cron/trackValidatorsPerformance/startWithinTenFirstPercentageOfEpoch.ts @@ -0,0 +1,50 @@ +import { CronJob } from "../cron"; + +/** + * Start a cron job within the first 10% of the epoch. + * if we are in the first 10% of the epoch we start the cron job if not we wait until the next epoch with a timeout. + * - gnosis chain 80 seconds per epoch -> 8 seconds + * - ethereum 384 seconds per epoch -> 38.4 seconds + * + * @param minGenesisTime - Minimum genesis time of the chain. + * @param secondsPerSlot - Seconds per slot. + * @param jobFunction - Cron job function. + */ +export function startWithinTenFirstPercentageOfEpoch({ + minGenesisTime, + secondsPerSlot, + slotsPerEpoch, + jobFunction +}: { + minGenesisTime: number; + secondsPerSlot: number; + slotsPerEpoch: number; + jobFunction: CronJob; +}): void { + const secondsToNextEpoch = getSecondsToNextEpoch({ minGenesisTime, secondsPerSlot }); + if (secondsToNextEpoch <= slotsPerEpoch * secondsPerSlot * 0.1) jobFunction.start(); + else setTimeout(() => jobFunction.start(), (secondsToNextEpoch + 3) * 1000); +} + +/** + * Get the seconds to the start of the next epoch based on the current Unix time and the minimum genesis time of the chain. + * + * @param {number} minGenesisTime - Minimum genesis time of the chain. + * @param {number} secondsPerSlot - Seconds per slot. + * @returns {number} - Seconds to the start of the next epoch. + */ +function getSecondsToNextEpoch({ + minGenesisTime, + secondsPerSlot +}: { + minGenesisTime: number; + secondsPerSlot: number; +}): number { + const currentUnixTime = Math.floor(Date.now() / 1000); + const timeDifference = currentUnixTime - minGenesisTime; // Time difference in seconds + const stlotsSinceGenesis = timeDifference / secondsPerSlot; // Slots since genesis + const currentEpoch = Math.floor(stlotsSinceGenesis / 32); // Current epoch + const nextEpochStartSlot = (currentEpoch + 1) * 32; // Slot at the start of the next epoch + const nextEpochStartTime = nextEpochStartSlot * secondsPerSlot + minGenesisTime; // Time at the start of the next epoch in seconds + return nextEpochStartTime - currentUnixTime; // Return the difference in seconds +} diff --git a/packages/brain/src/modules/cron/trackValidatorsPerformance/trackValidatorsPerformance.ts b/packages/brain/src/modules/cron/trackValidatorsPerformance/trackValidatorsPerformance.ts new file mode 100644 index 00000000..c05d8ba7 --- /dev/null +++ b/packages/brain/src/modules/cron/trackValidatorsPerformance/trackValidatorsPerformance.ts @@ -0,0 +1,132 @@ +import { BeaconchainApi } from "../../apiClients/beaconchain/index.js"; +import { PostgresClient } from "../../apiClients/postgres/index.js"; +import logger from "../../logger/index.js"; +import { BrainDataBase } from "../../db/index.js"; +import { insertPerformanceDataNotThrow } from "./insertPerformanceData.js"; +import { getAttestationsTotalRewards } from "./getAttestationsTotalRewards.js"; +import { getBlockProposalStatusMap } from "./getBlockProposalStatusMap.js"; +import { getActiveValidatorsLoadedInBrain } from "./getActiveValidatorsLoadedInBrain.js"; +import { logPrefix } from "./logPrefix.js"; +import { ConsensusClient, ExecutionClient } from "@stakingbrain/common"; + +export async function trackValidatorsPerformanceCron({ + brainDb, + postgresClient, + beaconchainApi, + executionClient, + consensusClient +}: { + brainDb: BrainDataBase; + postgresClient: PostgresClient; + beaconchainApi: BeaconchainApi; + executionClient: ExecutionClient; + consensusClient: ConsensusClient; +}): Promise { + try { + const currentEpoch = await beaconchainApi.getEpochHeader({ blockId: "finalized" }); + await trackValidatorsPerformance({ + currentEpoch, + brainDb, + postgresClient, + beaconchainApi, + executionClient, + consensusClient + }); + } catch (e) { + logger.error(`Error tracking validator performance: ${e}`); + } +} + +/** + * Cron task that will track validators performance for the epoch finalized and store it in the Postgres DB. + * If any issue is arisen during the process, it will be retried after 30 seconds. If the issue persists until the epoch + * finalized changes, the issue will be logged and stored in the DB. + * + * @param validatorPubkeys - The pubkeys of the validators to track. + * @param postgresClient - Postgres client to interact with the DB. + * @param beaconchainApi - Beaconchain API client to interact with the Beaconchain API. + * @param executionClient - The execution client to interact with. + * @param consensusClient - The consensus client to interact with. + * + * @throws {Error} If there is an error when updating the latestEpoch in the error handling + */ +async function trackValidatorsPerformance({ + currentEpoch, + brainDb, + postgresClient, + beaconchainApi, + executionClient, + consensusClient +}: { + currentEpoch: number; + brainDb: BrainDataBase; + postgresClient: PostgresClient; + beaconchainApi: BeaconchainApi; + executionClient: ExecutionClient; + consensusClient: ConsensusClient; +}): Promise { + let latestEpoch = currentEpoch; + + while (currentEpoch === latestEpoch) { + try { + logger.debug(`${logPrefix}Starting to track performance for epoch: ${currentEpoch}`); + + const activeValidatorsIndexes = await getActiveValidatorsLoadedInBrain({ beaconchainApi, brainDb }); + if (activeValidatorsIndexes.length === 0) { + logger.info(`${logPrefix}No active validators found`); + return; // Exit if no active validators are found + } + + const { el_offline, is_syncing } = (await beaconchainApi.getSyncingStatus()).data; + if (is_syncing) { + logger.debug(`${logPrefix}Node is syncing, skipping epoch ${currentEpoch}`); + return; // Exit if the node is syncing. Head finalized will change + } + if (el_offline) throw new Error("EL Node offline"); // throw error and retry + + const validatorsAttestationsTotalRewards = await getAttestationsTotalRewards({ + beaconchainApi, + epoch: currentEpoch.toString(), + activeValidatorsIndexes + }); + + const validatorBlockStatusMap = await getBlockProposalStatusMap({ + beaconchainApi, + epoch: currentEpoch.toString(), + activeValidatorsIndexes + }); + + await insertPerformanceDataNotThrow({ + postgresClient, + activeValidatorsIndexes, + currentEpoch, + validatorBlockStatusMap, + validatorsAttestationsTotalRewards, + error: undefined, + executionClient, + consensusClient + }); + + logger.debug(`${logPrefix}Finished tracking performance for epoch: ${currentEpoch}`); + return; // Success, exit function + } catch (e) { + logger.error(`${logPrefix}Error tracking validator peformance for epoch ${currentEpoch}: ${e}`); + latestEpoch = await beaconchainApi.getEpochHeader({ blockId: "finalized" }); + if (latestEpoch !== currentEpoch) { + logger.info(`${logPrefix}Epoch has changed from ${currentEpoch} to ${latestEpoch}, aborting retry.`); + await insertPerformanceDataNotThrow({ + postgresClient, + activeValidatorsIndexes: [], + currentEpoch, + validatorBlockStatusMap: new Map(), + validatorsAttestationsTotalRewards: [], + error: e.message, // Store the error in the DB after all retries are exhausted + executionClient, + consensusClient + }); + return; // Exit after final attempt + } + await new Promise((resolve) => setTimeout(resolve, 30 * 1000)); // Wait 30 seconds before retrying + } + } +} diff --git a/packages/brain/src/modules/validatorsDataIngest/getAttestationSuccessRate.ts b/packages/brain/src/modules/validatorsDataIngest/getAttestationSuccessRate.ts new file mode 100644 index 00000000..0a531d7d --- /dev/null +++ b/packages/brain/src/modules/validatorsDataIngest/getAttestationSuccessRate.ts @@ -0,0 +1,37 @@ +import type { ValidatorPerformance } from "../apiClients/postgres/types.js"; +import logger from "../logger/index.js"; + +/** + * Calculates the attestation success rate for a given validator. The attestation success rate is the percentage of successful attestations + * Being the total attestation opportunities the number of epochs between the first and last epoch in the data set of a specific validator. + * And the total successful attestations the number of epochs where the validator successfully attested: source must be >= 0. + * + * The epoch must be greater or equal to the startEpoch and less than the endEpoch. + * + * @param validatorData the data of the validator from the postgres database + * @param startEpoch the start epoch of the data set + * @param endEpoch the end epoch of the data set + */ +export function getAttestationSuccessRate({ + validatorData, + startEpoch, + endEpoch +}: { + validatorData: ValidatorPerformance[]; + startEpoch: number; + endEpoch: number; +}): number { + // Calculate the total attestation opportunities + const totalAttestationOpportunities = endEpoch - startEpoch; + if (totalAttestationOpportunities <= 0) { + logger.warn("totalAttestationOpportunities is less than or equal to 0"); + return 0; + } + + // Calculate the total successful attestations + const totalSuccessfulAttestations = validatorData.filter( + (data) => data.epoch >= startEpoch && data.epoch < endEpoch && parseInt(data.attestationsTotalRewards.source) >= 0 + ).length; + + return Math.round((totalSuccessfulAttestations / totalAttestationOpportunities) * 100); +} diff --git a/packages/brain/src/modules/validatorsDataIngest/getAttestationSuccessRatePerClients.ts b/packages/brain/src/modules/validatorsDataIngest/getAttestationSuccessRatePerClients.ts new file mode 100644 index 00000000..f7fd9fe6 --- /dev/null +++ b/packages/brain/src/modules/validatorsDataIngest/getAttestationSuccessRatePerClients.ts @@ -0,0 +1,35 @@ +import { getAttestationSuccessRate } from "./getAttestationSuccessRate.js"; +import type { ExecutionConsensusConcatenated } from "./types.js"; +import type { ValidatorPerformance } from "../apiClients/postgres/types.js"; + +/** + * Calculates the attestation success rate for a given validator per Execution and Consensus client. + * + * @param validatorData the data of the validator from the postgres database + * @param startEpoch the start epoch of the data set + * @param endEpoch the end epoch of the data set + */ +export function getAttestationSuccessRatePerClients({ + validatorData, + startEpoch, + endEpoch +}: { + validatorData: ValidatorPerformance[]; + startEpoch: number; + endEpoch: number; +}): Map { + const attestationSuccessRatePerClients = new Map(); + + const dataByClient = new Map(); + for (const data of validatorData) { + const key: ExecutionConsensusConcatenated = `${data.executionClient}-${data.consensusClient}`; + if (!dataByClient.has(key)) dataByClient.set(key, []); + dataByClient.get(key)?.push(data); + } + + // calculate the attestation success rate for each client combination + for (const [key, data] of dataByClient.entries()) + attestationSuccessRatePerClients.set(key, getAttestationSuccessRate({ validatorData: data, startEpoch, endEpoch })); + + return attestationSuccessRatePerClients; +} diff --git a/packages/brain/src/modules/validatorsDataIngest/getClientsUsedPerIntervalsMap.ts b/packages/brain/src/modules/validatorsDataIngest/getClientsUsedPerIntervalsMap.ts new file mode 100644 index 00000000..6ca32e23 --- /dev/null +++ b/packages/brain/src/modules/validatorsDataIngest/getClientsUsedPerIntervalsMap.ts @@ -0,0 +1,27 @@ +import type { ValidatorPerformance } from "../apiClients/postgres/types.js"; +import type { ExecutionConsensusConcatenated } from "./types.js"; + +export function getClientsUsedPerIntervalsMap({ + validatorData, + startEpoch, + endEpoch +}: { + validatorData: ValidatorPerformance[]; + startEpoch: number; + endEpoch: number; +}): Map { + const clientsUsedInInterval = new Map(); + + const dataByClient = new Map(); + for (const data of validatorData) { + const key: ExecutionConsensusConcatenated = `${data.executionClient}-${data.consensusClient}`; + if (!dataByClient.has(key)) dataByClient.set(key, []); + dataByClient.get(key)?.push(data); + } + + // calculate the number of epochs the client was used in the interval + for (const [key, data] of dataByClient.entries()) + clientsUsedInInterval.set(key, data.filter((data) => data.epoch >= startEpoch && data.epoch < endEpoch).length); + + return clientsUsedInInterval; +} diff --git a/packages/brain/src/modules/validatorsDataIngest/getIntervalsEpochs.ts b/packages/brain/src/modules/validatorsDataIngest/getIntervalsEpochs.ts new file mode 100644 index 00000000..10bef2c9 --- /dev/null +++ b/packages/brain/src/modules/validatorsDataIngest/getIntervalsEpochs.ts @@ -0,0 +1,41 @@ +import { getStartAndEndEpochs } from "./getStartAndEndEpochs.js"; +import { Granularity } from "./types.js"; + +export function getIntervalsEpochs({ + startDate, + endDate, + granularity, + minGenesisTime, + secondsPerSlot +}: { + startDate: Date; + endDate: Date; + granularity: Granularity; + minGenesisTime: number; + secondsPerSlot: number; +}): { startEpoch: number; endEpoch: number }[] { + // Calculate the number of intervals based on the granularity + const numberOfIntervals = getNumberOfIntervals({ startDate, endDate, granularity }); + return Array.from({ length: numberOfIntervals }, (_, idx) => { + return getStartAndEndEpochs({ + minGenesisTime, + secondsPerSlot, + startDate: new Date(startDate.getTime() + idx * granularity), + endDate: new Date(startDate.getTime() + (idx + 1) * granularity) + }); + }); +} + +function getNumberOfIntervals({ + startDate, + endDate, + granularity +}: { + startDate: Date; + endDate: Date; + granularity: Granularity; +}): number { + // Calculate the total amount of time based on the granularity + const totalAmountOfTime = endDate.getTime() - startDate.getTime(); + return Math.floor(totalAmountOfTime / granularity); // Use Math.floor for proper interval count +} diff --git a/packages/brain/src/modules/validatorsDataIngest/getStartAndEndEpochs.ts b/packages/brain/src/modules/validatorsDataIngest/getStartAndEndEpochs.ts new file mode 100644 index 00000000..604c21b0 --- /dev/null +++ b/packages/brain/src/modules/validatorsDataIngest/getStartAndEndEpochs.ts @@ -0,0 +1,32 @@ +/** + * Get the start and end epochs for the given date range + * + * @param minGenesisTime - The minimum genesis time of the chain + * @param secondsPerSlot - The number of seconds per slot in the chain + * @param startDate - The start date of the date range + * @param endDate - The end date of the date range + * @returns the start and end epochs for the given date range + */ +export function getStartAndEndEpochs({ + minGenesisTime, + secondsPerSlot, + startDate, + endDate +}: { + minGenesisTime: number; + secondsPerSlot: number; + startDate: Date; + endDate: Date; +}): { startEpoch: number; endEpoch: number } { + return { + startEpoch: getEpochFromDate(startDate, minGenesisTime, secondsPerSlot), + endEpoch: getEpochFromDate(endDate, minGenesisTime, secondsPerSlot) + }; +} + +function getEpochFromDate(date: Date, minGenesisTime: number, secondsPerSlot: number): number { + const currentUnixTime = Math.floor(date.getTime() / 1000); + const timeDifference = currentUnixTime - minGenesisTime; // Time difference in seconds + const slotsSinceGenesis = timeDifference / secondsPerSlot; // Slots since genesis + return Math.floor(slotsSinceGenesis / 32); // Current epoch +} diff --git a/packages/brain/src/modules/validatorsDataIngest/index.ts b/packages/brain/src/modules/validatorsDataIngest/index.ts new file mode 100644 index 00000000..488a504f --- /dev/null +++ b/packages/brain/src/modules/validatorsDataIngest/index.ts @@ -0,0 +1,95 @@ +import { PostgresClient } from "../apiClients/index.js"; +import logger from "../logger/index.js"; +import { getStartAndEndEpochs } from "./getStartAndEndEpochs.js"; +import { getAttestationSuccessRate } from "./getAttestationSuccessRate.js"; +import { Granularity, NumberOfDaysToQuery, ValidatorsDataProcessed } from "./types.js"; +import { getIntervalsEpochs } from "./getIntervalsEpochs.js"; +import { getAttestationSuccessRatePerClients } from "./getAttestationSuccessRatePerClients.js"; +import { getClientsUsedPerIntervalsMap } from "./getClientsUsedPerIntervalsMap.js"; + +// Module in charge of querying and processin the data of the validators to get the performance metrics: +// - Attestation success rate +// - Blocks proposed success rate +// - Mean attestation success rate +// - Mean blocks proposed success rate + +// Note: It is overkill to store in db the attestation success rate for each epoch since it is only useful froma a global perspective +// taking into account the historical data. As for now we will calculate dynamicall the attestation success rate with the arguments: epoch start and epoch end. + +// TODO: return current validator balance: 2 ways of doing it: 1) **get the balance from the beaconchain API**, 2) store the ideal rewards with the effective balance and get the balance from the postgres DB. The second option is more efficient but it is not real time. +// TODO: return to the frontend the remaining seconds to next epoch. In the frontend use this parameter to query the backend every time the epoch changes. +// TODO: add to block proposed epoch and slot + +/** + * Get the processed data for the validators in the given date range and the given validators indexes. + * + * @param validatorIndexes - Array of validator indexes. + * @param postgresClient - Postgres client to interact with the DB. + * @param minGenesisTime - The genesis time of the chain. + * @param secondsPerSlot - The number of seconds per slot. + * @param dateRange - The date range to get the data from. + * @returns the processed data for the validators + */ +export async function fetchAndProcessValidatorsData({ + validatorIndexes, + postgresClient, + minGenesisTime, + secondsPerSlot, + numberOfDaysToQuery = 1, + granularity = Granularity.Hourly +}: { + validatorIndexes: string[]; + postgresClient: PostgresClient; // import from backend index + minGenesisTime: number; // import from backend index + secondsPerSlot: number; // immport from backend index + numberOfDaysToQuery?: NumberOfDaysToQuery; + granularity?: Granularity; +}): Promise< + Map< + string, // validatorIndex + ValidatorsDataProcessed // processed data of the validator + > +> { + logger.info("Processing validators data"); + const mapValidatorPerformance = new Map(); + + // Get start timestamp and end timestamp + const endDate = new Date(); + const startDate = new Date(); + startDate.setDate(endDate.getDate() - numberOfDaysToQuery); + + // Calculate the epochs for the given dates + const { startEpoch, endEpoch } = getStartAndEndEpochs({ minGenesisTime, secondsPerSlot, startDate, endDate }); + + // Get the start and end epochs for each interval + const intervals = getIntervalsEpochs({ startDate, endDate, granularity, minGenesisTime, secondsPerSlot }); + + // Get the validators data from the postgres database with the start and end epoch + const validatorsDataMap = await postgresClient.getValidatorsDataMapForEpochRange({ + validatorIndexes, + startEpoch, + endEpoch + }); + + // Calculate the attestation success rate for each validator + for (const [validatorIndex, validatorData] of validatorsDataMap.entries()) + mapValidatorPerformance.set(validatorIndex, { + attestationSuccessRate: getAttestationSuccessRate({ validatorData, startEpoch, endEpoch }), + attestationSuccessRatePerClients: getAttestationSuccessRatePerClients({ validatorData, startEpoch, endEpoch }), + attestationSuccessRatePerInterval: intervals.map(({ startEpoch, endEpoch }) => { + return { + startEpoch, + endEpoch, + attestationSuccessRate: getAttestationSuccessRate({ validatorData, startEpoch, endEpoch }), + clientsUsedInInterval: getClientsUsedPerIntervalsMap({ validatorData, startEpoch, endEpoch }) + }; + }), + blocks: { + proposed: validatorData.filter((data) => data.blockProposalStatus === "Proposed").length, + missed: validatorData.filter((data) => data.blockProposalStatus === "Missed").length + } + }); + + // Return the processed data + return mapValidatorPerformance; +} diff --git a/packages/brain/src/modules/validatorsDataIngest/types.ts b/packages/brain/src/modules/validatorsDataIngest/types.ts new file mode 100644 index 00000000..5a9cd314 --- /dev/null +++ b/packages/brain/src/modules/validatorsDataIngest/types.ts @@ -0,0 +1,32 @@ +import { ConsensusClient, ExecutionClient } from "@stakingbrain/common"; + +export type ExecutionConsensusConcatenated = `${ExecutionClient}-${ConsensusClient}`; + +export interface ValidatorsDataProcessed { + attestationSuccessRate: number; // mean attestationSuccessRate of the validator + attestationSuccessRatePerClients: Map; + // attestationSuccessRate in each interval + attestationSuccessRatePerInterval: { + startEpoch: number; // start epoch of the interval + endEpoch: number; // end epoch of the interval + attestationSuccessRate: number | null; // attestationSuccessRate in the interval + clientsUsedInInterval: Map; // Map indexed by ["execution-consensus"] (i.e "geth-lighthouse") with the number of epochs the client was used in the interval + }[]; + blocks: { + proposed: number; // number of blocks proposed + missed: number; // number of blocks missed + }; + // TODO: instead blocks should look as folloes + // blocks: { + // proposed: {epoch: number, slot: number}[], + // missed: {epoch: number, slot: number}[] + // } +} + +export enum Granularity { + Hourly = 3600000, // 1 hour in milliseconds + Daily = 86400000, // 1 day in milliseconds + Weekly = 604800000 // 7 days in milliseconds +} + +export type NumberOfDaysToQuery = 1 | 7 | 28; diff --git a/packages/brain/src/modules/validatorsPerformance/index.ts b/packages/brain/src/modules/validatorsPerformance/index.ts deleted file mode 100644 index 7fd016a1..00000000 --- a/packages/brain/src/modules/validatorsPerformance/index.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { PostgresClient } from "../apiClients/index.js"; - -// Module in charge of queriyng the validators attestation rewards, block proposals and sync committee rewards and -// processing the data to be displayed in the validators performance page. - -// FRONTEND - -// Will display the following data: -// - Attestation success rate (not chart until granularity) -// - Blocks proposed success rate (not chart until granularity) -// - Sync committee success rate (not chart until granularity) -// - Balance -> No chart -// - Means: mean attestation success rate, mean blocks proposed success rate, mean balance -> No chart - -// BACKEND - -// The frontend will call backend with arguments: -// - startDate and endDate -> backend will translate these dates to epochs. -// The backend will calculate ValidatorsPerformanceProcessed for the given dates -// If no arguments passeed to backend then the backend will use last 7 days epoch and latest epoch -// - Clients (execution and consensus) -> optional -// - Attestation/block success rate granularity (future): admit granularity of att success rate: by epoch, by day, by week, by month -> THIS enables chart visualization - -// Return also current balance for each validator - -// Note: It is overkill to store in db the attestation success rate for each epoch since it is only useful froma a global perspective -// taking into account the historical data. As for now we will calculate dynamicall the attestation success rate with the arguments: epoch start and epoch end. - -// (%) = (Number of Successful Attestations + Number of Successful Proposals) / (Total Attestation Opportunities + Total Proposal Opportunities) * 100 -// Total Attestation Opportunities: is the number of epochs between the first and last epoch in the data set of a specific validator. -// Total Proposal Opportunities: - -// TODO: blocksProposedByEpochAndSlot - -export interface ValidatorsPerformanceProcessed { - mapValidatorPerformance: Map< - string, - { - attestationSuccessRate: number; - blocksProposedSuccessRate: number; - balance: number; - syncCommitteeSuccessRate?: number; - } - >; - meanAttestationSuccessRate: number; - meanBlocksProposedSuccessRate: number; - meanBalance: number; -} - -/** - * - */ -export async function processValidatorsData({ - validatorIndexes, - postgresClient -}: { - validatorIndexes: string[]; - postgresClient: PostgresClient; -}) { - console.log("Processing validators data"); - console.log("Validator indexes: ", validatorIndexes); - console.log("Postgres client: ", postgresClient); -} diff --git a/packages/brain/test/unit/modules/ cron/trackValidatorsPerformance/getActiveValidatorsLoadedInBrain.unit.test.ts b/packages/brain/test/unit/modules/ cron/trackValidatorsPerformance/getActiveValidatorsLoadedInBrain.unit.test.ts new file mode 100644 index 00000000..62ae74af --- /dev/null +++ b/packages/brain/test/unit/modules/ cron/trackValidatorsPerformance/getActiveValidatorsLoadedInBrain.unit.test.ts @@ -0,0 +1,145 @@ +import { expect } from "chai"; +import { BeaconchainApi } from "../../../../../src/modules/apiClients/index.js"; +import { + BeaconchainValidatorFromStateGetResponse, + BeaconchainValidatorStatePostResponse, + BlockId, + ValidatorStatus +} from "../../../../../src/modules/apiClients/types.js"; +import { BrainDataBase } from "../../../../../src/modules/db/index.js"; +import { StakingBrainDb } from "../../../../../src/modules/db/types.js"; +import { getActiveValidatorsLoadedInBrain } from "../../../../../src/modules/cron/trackValidatorsPerformance/getActiveValidatorsLoadedInBrain.js"; +import { Network } from "@stakingbrain/common"; + +const validatorIndexOne = 1802289; +const pubkeyOne = "0x86531f35f71730767e72692442a2020a6f252c15bc73d11e201d658ed90dde0dd15d9614e6c115b2dd0221ce35dcdcb3"; +const validatorIndexTwo = 1802291; +const pubkeyTwo = "0x86531f35f71730767e72692442a2020a6f252c15bc73d11e201d658ed90dde0dd15d9614e6c115b2dd0221ce35dcdcb4"; + +// Create class mock that implements BeaconchainApi and overwrites the method postStateValidators +class BeaconchainApiMock extends BeaconchainApi { + async getStateValidator({ + state, + pubkey + }: { + state: BlockId; + pubkey: string; + }): Promise { + console.log(`state: ${state}, pubkey: ${pubkey}`); + if (pubkey === pubkeyOne) + return { + execution_optimistic: false, + data: { + index: validatorIndexOne.toString(), + balance: "0", + status: ValidatorStatus.ACTIVE_EXITING, + validator: { + pubkey: pubkeyOne, + withdrawal_credentials: "", + effective_balance: "", + slashed: false, + activation_eligibility_epoch: "", + activation_epoch: "", + exit_epoch: "", + withdrawable_epoch: "" + } + } + }; + if (pubkey === pubkeyTwo) + return { + execution_optimistic: false, + data: { + index: validatorIndexTwo.toString(), + balance: "0", + status: ValidatorStatus.ACTIVE_EXITING, + validator: { + pubkey: pubkeyTwo, + withdrawal_credentials: "", + effective_balance: "", + slashed: false, + activation_eligibility_epoch: "", + activation_epoch: "", + exit_epoch: "", + withdrawable_epoch: "" + } + } + }; + throw new Error("pubkey not found"); + } + + async postStateValidators({ + stateId, + body + }: { + stateId: BlockId; + body: { ids: string[]; statuses: ValidatorStatus[] }; + }): Promise { + console.log(`stateId: ${stateId}, body: ${JSON.stringify(body)}`); + return { + execution_optimistic: false, + finalized: true, + data: body.ids.map((id) => ({ + index: id, + balance: "0", + status: ValidatorStatus.ACTIVE_ONGOING, + validator: { + pubkey: "", + withdrawal_credentials: "", + effective_balance: "", + slashed: false, + activation_eligibility_epoch: "", + activation_epoch: "", + exit_epoch: "", + withdrawable_epoch: "" + } + })) + }; + } +} + +// Mock the BrainDataBase class +class BrainDataBaseMock extends BrainDataBase { + data: StakingBrainDb = { + [pubkeyOne]: { + tag: "obol", + feeRecipient: "0x52908400098527886E0F7030069857D2E4169EE7", + automaticImport: true, + index: validatorIndexOne // validator index exists + }, + [pubkeyTwo]: { + tag: "solo", + feeRecipient: "0x52908400098527886E0F7030069857D2E4169EE6", + automaticImport: true + // validator index does not exist + } + }; + + getData() { + return this.data; + } + + updateValidators({ validators }: { validators: StakingBrainDb }) { + this.data = { ...this.data, ...validators }; + } +} + +describe("Cron - trackValidatorsPerformance - getActiveValidatorsLoadedInBrain", () => { + it("should return the active validators loaded in the brain and not update validator index one and update validator index two in db", async () => { + const beaconchainApi = new BeaconchainApiMock( + { baseUrl: "http://localhost:3000", apiPath: "", authToken: "" }, + Network.Holesky + ); + const brainDb = new BrainDataBaseMock("test.json"); + + const activeValidatorsIndexes = await getActiveValidatorsLoadedInBrain({ + beaconchainApi, + brainDb + }); + + expect(activeValidatorsIndexes).to.be.an("array").that.includes(validatorIndexOne.toString()); + expect(activeValidatorsIndexes).to.be.an("array").that.includes(validatorIndexTwo.toString()); + expect(activeValidatorsIndexes.length).to.be.equal(2); + expect(brainDb.getData()[pubkeyOne].index).to.be.equal(validatorIndexOne); + expect(brainDb.getData()[pubkeyTwo].index).to.be.equal(validatorIndexTwo); + }); +}); diff --git a/packages/brain/test/unit/modules/ cron/trackValidatorsPerformance/getBlockProposalStatusMap.unit.test.ts b/packages/brain/test/unit/modules/ cron/trackValidatorsPerformance/getBlockProposalStatusMap.unit.test.ts new file mode 100644 index 00000000..f0632e95 --- /dev/null +++ b/packages/brain/test/unit/modules/ cron/trackValidatorsPerformance/getBlockProposalStatusMap.unit.test.ts @@ -0,0 +1,109 @@ +import { expect } from "chai"; +import { BeaconchainApi } from "../../../../../src/modules/apiClients/index.js"; +import type { + BeaconchainBlockHeaderGetResponse, + BeaconchainProposerDutiesGetResponse, + BlockId +} from "../../../../../src/modules/apiClients/types.js"; +import { BlockProposalStatus } from "../../../../../src/modules/apiClients/postgres/types.js"; +import { Network } from "@stakingbrain/common"; +import { getBlockProposalStatusMap } from "../../../../../src/modules/cron/trackValidatorsPerformance/getBlockProposalStatusMap.js"; + +// validator index 1802289 is supposed to propose in slot 1 +// validator index 1802291 is supposed to propose in slot 2 +// validator index 1802292 is not supposed to propose in any slot +const validatorsBlockProposal: { + index: string; + slot: string; +}[] = [ + { + index: "1802289", + slot: "1" + }, + { + index: "1802291", + slot: "2" + }, + { + index: "1802292", + slot: "" + } +]; + +const validatorMissedBlockProposal = { + index: "1802283", + slot: "3" +}; + +class BeaconchainApiMock extends BeaconchainApi { + async getProposerDuties({ epoch }: { epoch: string }): Promise { + console.log(`epoch: ${epoch}`); + return { + dependent_root: "", + execution_optimistic: false, + data: [ + { + pubkey: "", + validator_index: validatorsBlockProposal[0].index, + slot: validatorsBlockProposal[0].slot + }, + { + pubkey: "", + validator_index: validatorsBlockProposal[1].index, + slot: validatorsBlockProposal[1].slot + }, + { + pubkey: "", + validator_index: validatorMissedBlockProposal.index, + slot: validatorMissedBlockProposal.slot + } + ] + }; + } + + async getBlockHeader({ blockId }: { blockId: BlockId }): Promise { + console.log(`blockId: ${blockId}`); + + // find in the validatorsBlockProposal array the slot that matches the blockId if not found reject with an error with code 404 + const foundValidator = validatorsBlockProposal.find((validator) => validator.slot === blockId); + if (!foundValidator) return Promise.reject({ status: 404 }); + return { + execution_optimistic: true, + data: { + root: "", + canonical: true, + header: { + message: { + slot: foundValidator.slot, + proposer_index: foundValidator.index, + parent_root: "", + state_root: "", + body_root: "" + }, + signature: "" + } + } + }; + } +} + +describe("Cron - trackValidatorsPerformance - getBlockProposalStatusMap", () => { + const beaconchainApi = new BeaconchainApiMock({ baseUrl: "http://localhost:3000" }, Network.Mainnet); + + it("should return the block proposal status of each validator: ", async () => { + const epoch = "1"; + const blockProposalStatusMap = await getBlockProposalStatusMap({ + beaconchainApi, + epoch, + activeValidatorsIndexes: [ + ...validatorsBlockProposal.map((validator) => validator.index), + validatorMissedBlockProposal.index + ] + }); + + expect(blockProposalStatusMap.get(validatorsBlockProposal[0].index)).to.equal(BlockProposalStatus.Proposed); + expect(blockProposalStatusMap.get(validatorsBlockProposal[1].index)).to.equal(BlockProposalStatus.Proposed); + expect(blockProposalStatusMap.get(validatorsBlockProposal[2].index)).to.equal(BlockProposalStatus.Unchosen); + expect(blockProposalStatusMap.get(validatorMissedBlockProposal.index)).to.equal(BlockProposalStatus.Missed); + }); +}); diff --git a/packages/brain/test/unit/modules/validatorsDataIngest/getAttestationSuccessRate.unit.test.ts b/packages/brain/test/unit/modules/validatorsDataIngest/getAttestationSuccessRate.unit.test.ts new file mode 100644 index 00000000..43b674d5 --- /dev/null +++ b/packages/brain/test/unit/modules/validatorsDataIngest/getAttestationSuccessRate.unit.test.ts @@ -0,0 +1,112 @@ +import { ExecutionClient, ConsensusClient } from "@stakingbrain/common"; +import { expect } from "chai"; +import { ValidatorPerformance, BlockProposalStatus } from "../../../../src/modules/apiClients/postgres/types.js"; +import { getAttestationSuccessRate } from "../../../../src/modules/validatorsDataIngest/getAttestationSuccessRate"; + +describe("validatorsDataIngest - getAttestationSuccessRate", () => { + it("should return the attestation success rate for a given validator", () => { + const validatorData: ValidatorPerformance[] = [ + { + validatorIndex: 0, + epoch: 1, + executionClient: ExecutionClient.Geth, + consensusClient: ConsensusClient.Lighthouse, + blockProposalStatus: BlockProposalStatus.Proposed, + attestationsTotalRewards: { + validator_index: "0", + head: "someHead", + target: "someTarget", + source: "1", + inclusion_delay: "0", + inactivity: "0" + } + }, + { + validatorIndex: 0, + epoch: 2, + executionClient: ExecutionClient.Geth, + consensusClient: ConsensusClient.Lighthouse, + blockProposalStatus: BlockProposalStatus.Proposed, + attestationsTotalRewards: { + validator_index: "0", + head: "someHead", + target: "someTarget", + source: "0", + inclusion_delay: "0", + inactivity: "0" + } + }, + { + validatorIndex: 0, + epoch: 3, + executionClient: ExecutionClient.Geth, + consensusClient: ConsensusClient.Lighthouse, + blockProposalStatus: BlockProposalStatus.Proposed, + attestationsTotalRewards: { + validator_index: "0", + head: "someHead", + target: "someTarget", + source: "-1", + inclusion_delay: "0", + inactivity: "0" + } + } + ]; + + const startEpoch = 1; + const endEpoch = 4; // Total opportunities: 3 (1, 2, 3) + + const successRate = getAttestationSuccessRate({ + validatorData, + startEpoch, + endEpoch + }); + + expect(successRate).to.equal(67); // 2 successful attestations out of 3 opportunities + }); + + it("should return 0 if the total attestation opportunities are less than or equal to 0", () => { + const validatorData: ValidatorPerformance[] = []; + const startEpoch = 3; + const endEpoch = 3; // Total opportunities: 0 + + const successRate = getAttestationSuccessRate({ + validatorData, + startEpoch, + endEpoch + }); + + expect(successRate).to.equal(0); + }); + + it("should correctly handle edge case with no successful attestations", () => { + const validatorData: ValidatorPerformance[] = [ + { + validatorIndex: 0, + epoch: 1, + executionClient: ExecutionClient.Geth, + consensusClient: ConsensusClient.Lighthouse, + blockProposalStatus: BlockProposalStatus.Proposed, + attestationsTotalRewards: { + validator_index: "0", + head: "someHead", + target: "someTarget", + source: "-1", // Unsuccessful + inclusion_delay: "0", + inactivity: "0" + } + } + ]; + + const startEpoch = 1; + const endEpoch = 2; // Total opportunities: 1 + + const successRate = getAttestationSuccessRate({ + validatorData, + startEpoch, + endEpoch + }); + + expect(successRate).to.equal(0); // No successful attestations + }); +}); diff --git a/packages/brain/test/unit/modules/validatorsDataIngest/getAttestationSuccessRatePerClients.unit.test.ts b/packages/brain/test/unit/modules/validatorsDataIngest/getAttestationSuccessRatePerClients.unit.test.ts new file mode 100644 index 00000000..5ed167a0 --- /dev/null +++ b/packages/brain/test/unit/modules/validatorsDataIngest/getAttestationSuccessRatePerClients.unit.test.ts @@ -0,0 +1,177 @@ +import { expect } from "chai"; +import { getAttestationSuccessRatePerClients } from "../../../../src/modules/validatorsDataIngest/getAttestationSuccessRatePerClients.js"; +import { ExecutionClient, ConsensusClient } from "@stakingbrain/common"; +import { ValidatorPerformance, BlockProposalStatus } from "../../../../src/modules/apiClients/postgres/types.js"; + +describe("validatorsDataIngest - getAttestationSuccessRatePerClients", () => { + // Sample validator data + const validatorData: ValidatorPerformance[] = [ + { + validatorIndex: 1, + epoch: 0, + executionClient: ExecutionClient.Geth, + consensusClient: ConsensusClient.Teku, + blockProposalStatus: BlockProposalStatus.Proposed, + attestationsTotalRewards: { + validator_index: "1", + head: "head1", + target: "target1", + source: "0", // Successful attestation + inclusion_delay: "0", + inactivity: "0" + } + }, + { + validatorIndex: 1, + epoch: 1, + executionClient: ExecutionClient.Geth, + consensusClient: ConsensusClient.Teku, + blockProposalStatus: BlockProposalStatus.Proposed, + attestationsTotalRewards: { + validator_index: "1", + head: "head2", + target: "target2", + source: "-1", // Failed attestation + inclusion_delay: "0", + inactivity: "0" + } + }, + { + validatorIndex: 1, + epoch: 2, + executionClient: ExecutionClient.Geth, + consensusClient: ConsensusClient.Teku, + blockProposalStatus: BlockProposalStatus.Proposed, + attestationsTotalRewards: { + validator_index: "1", + head: "head3", + target: "target3", + source: "1", // Successful attestation + inclusion_delay: "0", + inactivity: "0" + } + }, + { + validatorIndex: 2, + epoch: 0, + executionClient: ExecutionClient.Besu, + consensusClient: ConsensusClient.Prysm, + blockProposalStatus: BlockProposalStatus.Proposed, + attestationsTotalRewards: { + validator_index: "2", + head: "head1", + target: "target1", + source: "0", // Successful attestation + inclusion_delay: "0", + inactivity: "0" + } + }, + { + validatorIndex: 2, + epoch: 1, + executionClient: ExecutionClient.Besu, + consensusClient: ConsensusClient.Prysm, + blockProposalStatus: BlockProposalStatus.Proposed, + attestationsTotalRewards: { + validator_index: "2", + head: "head2", + target: "target2", + source: "0", // Successful attestation + inclusion_delay: "0", + inactivity: "0" + } + }, + { + validatorIndex: 2, + epoch: 2, + executionClient: ExecutionClient.Besu, + consensusClient: ConsensusClient.Prysm, + blockProposalStatus: BlockProposalStatus.Proposed, + attestationsTotalRewards: { + validator_index: "2", + head: "head3", + target: "target3", + source: "-1", // Failed attestation + inclusion_delay: "0", + inactivity: "0" + } + } + ]; + + it("should calculate the attestation success rate per clients correctly", () => { + const startEpoch = 0; + const endEpoch = 3; // Covering epochs 0, 1, and 2 + + const result = getAttestationSuccessRatePerClients({ + validatorData, + startEpoch, + endEpoch + }); + + // Check success rates for Geth-Teku + expect(result.get(`${ExecutionClient.Geth}-${ConsensusClient.Teku}`)).to.equal(67); // 2 successful out of 3 + + // Check success rates for Besu-Prysm + expect(result.get(`${ExecutionClient.Besu}-${ConsensusClient.Prysm}`)).to.equal(67); // 2 successful out of 3 + }); + + it("should return 0% success rate if there are no opportunities", () => { + const startEpoch = 3; + const endEpoch = 3; // No opportunities + + const result = getAttestationSuccessRatePerClients({ + validatorData, + startEpoch, + endEpoch + }); + + expect(result.get(`${ExecutionClient.Geth}-${ConsensusClient.Teku}`)).to.equal(0); // No opportunities + expect(result.get(`${ExecutionClient.Besu}-${ConsensusClient.Prysm}`)).to.equal(0); // No opportunities + }); + + it("should handle a scenario where there are no successful attestations", () => { + const validatorDataNoSuccess: ValidatorPerformance[] = [ + { + validatorIndex: 1, + epoch: 0, + executionClient: ExecutionClient.Geth, + consensusClient: ConsensusClient.Teku, + blockProposalStatus: BlockProposalStatus.Proposed, + attestationsTotalRewards: { + validator_index: "1", + head: "head1", + target: "target1", + source: "-1", // Failed attestation + inclusion_delay: "0", + inactivity: "0" + } + }, + { + validatorIndex: 1, + epoch: 1, + executionClient: ExecutionClient.Geth, + consensusClient: ConsensusClient.Teku, + blockProposalStatus: BlockProposalStatus.Proposed, + attestationsTotalRewards: { + validator_index: "1", + head: "head2", + target: "target2", + source: "-1", // Failed attestation + inclusion_delay: "0", + inactivity: "0" + } + } + ]; + + const startEpoch = 0; + const endEpoch = 2; // Covering epochs 0 and 1 + + const result = getAttestationSuccessRatePerClients({ + validatorData: validatorDataNoSuccess, + startEpoch, + endEpoch + }); + + expect(result.get(`${ExecutionClient.Geth}-${ConsensusClient.Teku}`)).to.equal(0); // No successful attestations + }); +}); diff --git a/packages/brain/test/unit/modules/validatorsDataIngest/getClientsUsedPerIntervalsMap.unit.test.ts b/packages/brain/test/unit/modules/validatorsDataIngest/getClientsUsedPerIntervalsMap.unit.test.ts new file mode 100644 index 00000000..22709a93 --- /dev/null +++ b/packages/brain/test/unit/modules/validatorsDataIngest/getClientsUsedPerIntervalsMap.unit.test.ts @@ -0,0 +1,219 @@ +import { expect } from "chai"; +import { getClientsUsedPerIntervalsMap } from "../../../../src/modules/validatorsDataIngest/getClientsUsedPerIntervalsMap.js"; +import { ExecutionClient, ConsensusClient } from "@stakingbrain/common"; +import { ValidatorPerformance, BlockProposalStatus } from "../../../../src/modules/apiClients/postgres/types.js"; + +describe("validatorsDataIngest - getClientsUsedPerIntervalsMap", () => { + const validatorData: ValidatorPerformance[] = [ + { + validatorIndex: 1, + epoch: 0, + executionClient: ExecutionClient.Geth, + consensusClient: ConsensusClient.Teku, + blockProposalStatus: BlockProposalStatus.Proposed, + attestationsTotalRewards: { + validator_index: "1", + head: "head1", + target: "target1", + source: "source1", + inclusion_delay: "0", + inactivity: "0" + } + }, + { + validatorIndex: 2, + epoch: 1, + executionClient: ExecutionClient.Besu, + consensusClient: ConsensusClient.Prysm, + blockProposalStatus: BlockProposalStatus.Missed, + attestationsTotalRewards: { + validator_index: "2", + head: "head2", + target: "target2", + source: "source2", + inclusion_delay: "1", + inactivity: "0" + } + }, + { + validatorIndex: 3, + epoch: 1, + executionClient: ExecutionClient.Geth, + consensusClient: ConsensusClient.Lighthouse, + blockProposalStatus: BlockProposalStatus.Unchosen, + attestationsTotalRewards: { + validator_index: "3", + head: "head3", + target: "target3", + source: "source3", + inclusion_delay: "0", + inactivity: "0" + } + }, + { + validatorIndex: 4, + epoch: 2, + executionClient: ExecutionClient.Erigon, + consensusClient: ConsensusClient.Nimbus, + blockProposalStatus: BlockProposalStatus.Error, + attestationsTotalRewards: { + validator_index: "4", + head: "head4", + target: "target4", + source: "source4", + inclusion_delay: "0", + inactivity: "0" + } + }, + { + validatorIndex: 5, + epoch: 1, + executionClient: ExecutionClient.Geth, + consensusClient: ConsensusClient.Teku, + blockProposalStatus: BlockProposalStatus.Proposed, + attestationsTotalRewards: { + validator_index: "5", + head: "head5", + target: "target5", + source: "source5", + inclusion_delay: "0", + inactivity: "0" + } + }, + { + validatorIndex: 6, + epoch: 2, + executionClient: ExecutionClient.Geth, + consensusClient: ConsensusClient.Teku, + blockProposalStatus: BlockProposalStatus.Proposed, + attestationsTotalRewards: { + validator_index: "6", + head: "head6", + target: "target6", + source: "source6", + inclusion_delay: "0", + inactivity: "0" + } + }, + { + validatorIndex: 7, + epoch: 2, + executionClient: ExecutionClient.Besu, + consensusClient: ConsensusClient.Prysm, + blockProposalStatus: BlockProposalStatus.Missed, + attestationsTotalRewards: { + validator_index: "7", + head: "head7", + target: "target7", + source: "source7", + inclusion_delay: "0", + inactivity: "0" + } + }, + { + validatorIndex: 8, + epoch: 3, + executionClient: ExecutionClient.Besu, + consensusClient: ConsensusClient.Lighthouse, + blockProposalStatus: BlockProposalStatus.Unchosen, + attestationsTotalRewards: { + validator_index: "8", + head: "head8", + target: "target8", + source: "source8", + inclusion_delay: "0", + inactivity: "0" + } + }, + { + validatorIndex: 9, + epoch: 3, + executionClient: ExecutionClient.Geth, + consensusClient: ConsensusClient.Nimbus, + blockProposalStatus: BlockProposalStatus.Error, + attestationsTotalRewards: { + validator_index: "9", + head: "head9", + target: "target9", + source: "source9", + inclusion_delay: "0", + inactivity: "0" + } + }, + { + validatorIndex: 10, + epoch: 4, + executionClient: ExecutionClient.Erigon, + consensusClient: ConsensusClient.Teku, + blockProposalStatus: BlockProposalStatus.Proposed, + attestationsTotalRewards: { + validator_index: "10", + head: "head10", + target: "target10", + source: "source10", + inclusion_delay: "0", + inactivity: "0" + } + }, + { + validatorIndex: 11, + epoch: 4, + executionClient: ExecutionClient.Besu, + consensusClient: ConsensusClient.Prysm, + blockProposalStatus: BlockProposalStatus.Missed, + attestationsTotalRewards: { + validator_index: "11", + head: "head11", + target: "target11", + source: "source11", + inclusion_delay: "0", + inactivity: "0" + } + }, + { + validatorIndex: 12, + epoch: 4, + executionClient: ExecutionClient.Geth, + consensusClient: ConsensusClient.Teku, + blockProposalStatus: BlockProposalStatus.Unchosen, + attestationsTotalRewards: { + validator_index: "12", + head: "head12", + target: "target12", + source: "source12", + inclusion_delay: "0", + inactivity: "0" + } + } + ]; + + it("should return correct counts for a given epoch range", () => { + const startEpoch = 1; + const endEpoch = 4; + + const result = getClientsUsedPerIntervalsMap({ validatorData, startEpoch, endEpoch }); + expect(result.size).to.equal(7); // Geth-Teku, Besu-Prysm, Geth-Lighthouse, Erigon-Teku + expect(result.get(`${ExecutionClient.Geth}-${ConsensusClient.Teku}`)).to.equal(2); + expect(result.get(`${ExecutionClient.Besu}-${ConsensusClient.Prysm}`)).to.equal(2); + expect(result.get(`${ExecutionClient.Geth}-${ConsensusClient.Lighthouse}`)).to.equal(1); + expect(result.get(`${ExecutionClient.Erigon}-${ConsensusClient.Nimbus}`)).to.equal(1); + expect(result.get(`${ExecutionClient.Geth}-${ConsensusClient.Nimbus}`)).to.equal(1); + expect(result.get(`${ExecutionClient.Erigon}-${ConsensusClient.Teku}`)).to.equal(0); + }); + + it("should return zero counts for an epoch range with no data", () => { + const startEpoch = 12; + const endEpoch = 15; + + const result = getClientsUsedPerIntervalsMap({ validatorData, startEpoch, endEpoch }); + for (const value of result.values()) expect(value).to.equal(0); + }); + + it("should handle cases where startEpoch is equal to endEpoch. Nothing should be displayed since the intervals takes the first epoch and not the last one", () => { + const startEpoch = 1; + const endEpoch = 1; + + const result = getClientsUsedPerIntervalsMap({ validatorData, startEpoch, endEpoch }); + for (const value of result.values()) expect(value).to.equal(0); + }); +}); diff --git a/packages/brain/test/unit/modules/validatorsDataIngest/getIntervalsEpochs.unit.test.ts b/packages/brain/test/unit/modules/validatorsDataIngest/getIntervalsEpochs.unit.test.ts new file mode 100644 index 00000000..858961b3 --- /dev/null +++ b/packages/brain/test/unit/modules/validatorsDataIngest/getIntervalsEpochs.unit.test.ts @@ -0,0 +1,108 @@ +import { expect } from "chai"; +import { getIntervalsEpochs } from "../../../../src/modules/validatorsDataIngest/getIntervalsEpochs.js"; +import { Granularity } from "../../../../src/modules/validatorsDataIngest/types.js"; + +describe("validatorsDataIngest - getIntervalsEpochs", () => { + const minGenesisTime = 1695902100; // Min genesis time Holesky + const secondsPerSlot = 12; // Seconds per slot + + it("should return correct intervals for daily granularity", () => { + const startDate = new Date("2024-09-22T00:00:00Z"); + const endDate = new Date("2024-09-23T00:00:00Z"); + const granularity = Granularity.Daily; + + const intervals = getIntervalsEpochs({ + startDate, + endDate, + granularity, + minGenesisTime, + secondsPerSlot + }); + + expect(intervals.length).to.equal(1); + expect(intervals[0]).to.deep.equal({ + startEpoch: 80888, + endEpoch: 81113 + }); + }); + + it("should return correct intervals for hourly granularity", () => { + const startDate = new Date("2024-09-22T00:00:00Z"); + const endDate = new Date("2024-09-22T02:00:00Z"); + const granularity = Granularity.Hourly; + + const intervals = getIntervalsEpochs({ + startDate, + endDate, + granularity, + minGenesisTime, + secondsPerSlot + }); + + expect(intervals.length).to.equal(2); + expect(intervals[0]).to.deep.equal({ + startEpoch: 80888, + endEpoch: 80897 + }); + expect(intervals[1]).to.deep.equal({ + startEpoch: 80897, + endEpoch: 80907 + }); + }); + + it("should return correct intervals for weekly granularity", () => { + const startDate = new Date("2024-08-01T00:00:00Z"); + const endDate = new Date("2024-08-15T00:00:00Z"); + const granularity = Granularity.Weekly; + + const intervals = getIntervalsEpochs({ + startDate, + endDate, + granularity, + minGenesisTime, + secondsPerSlot + }); + + expect(intervals.length).to.equal(2); + expect(intervals[0]).to.deep.equal({ + startEpoch: 69188, + endEpoch: 70763 + }); + expect(intervals[1]).to.deep.equal({ + startEpoch: 70763, + endEpoch: 72338 + }); + }); + + it("should handle cases where endDate is the same as startDate", () => { + const startDate = new Date("2023-01-01T00:00:00Z"); + const endDate = new Date("2023-01-01T00:00:00Z"); + const granularity = Granularity.Hourly; + + const intervals = getIntervalsEpochs({ + startDate, + endDate, + granularity, + minGenesisTime, + secondsPerSlot + }); + + expect(intervals.length).to.equal(0); + }); + + it("should return an empty array for invalid date ranges", () => { + const startDate = new Date("2023-01-02T00:00:00Z"); + const endDate = new Date("2023-01-01T00:00:00Z"); + const granularity = Granularity.Hourly; + + const intervals = getIntervalsEpochs({ + startDate, + endDate, + granularity, + minGenesisTime, + secondsPerSlot + }); + + expect(intervals.length).to.equal(0); + }); +}); diff --git a/packages/brain/test/unit/modules/validatorsDataIngest/getStartAndEndEpochs.unit.test.ts b/packages/brain/test/unit/modules/validatorsDataIngest/getStartAndEndEpochs.unit.test.ts new file mode 100644 index 00000000..55f90369 --- /dev/null +++ b/packages/brain/test/unit/modules/validatorsDataIngest/getStartAndEndEpochs.unit.test.ts @@ -0,0 +1,56 @@ +import { getStartAndEndEpochs } from "../../../../dist/modules/validatorsDataIngest/getStartAndEndEpochs.js"; +import { expect } from "chai"; + +describe("validatorsDataIngest - getStartAndEndEpochs", () => { + it("should return correct start and end epochs for the given date range in Holesky", () => { + // Define constants + const minGenesisTime = 1695902100; // Use the provided minGenesisTime + const secondsPerSlot = 12; // Use the provided secondsPerSlot + + // Define date range for testing + const startDate = new Date("2024-09-24T06:40:00.000Z"); + const endDate = new Date("2024-09-24T07:05:36.000Z"); + + // Calculate expected epochs + const expectedStartEpoch = 81400; + const expectedEndEpoch = 81404; + + // Call the function + const result = getStartAndEndEpochs({ + minGenesisTime, + secondsPerSlot, + startDate, + endDate + }); + + // Assert the results + expect(result).to.deep.equal({ + startEpoch: expectedStartEpoch, + endEpoch: expectedEndEpoch + }); + }); + + it("should return correct start and end epochs for the given date range in Holesky", () => { + // Define constants + const minGenesisTime = 1606824000; // Min genesis time ethereum + const secondsPerSlot = 12; // Use the provided secondsPerSlot + + // Define date range for testing + const startDate = new Date("Sep-25-2024 16:46:47 UTC+2"); + const endDate = new Date("Sep-25-2024 17:57:11 UTC+2"); + + // Call the function + const result = getStartAndEndEpochs({ + minGenesisTime, + secondsPerSlot, + startDate, + endDate + }); + + // Assert the results + expect(result).to.deep.equal({ + startEpoch: 313676, + endEpoch: 313687 + }); + }); +}); diff --git a/packages/brain/test/unit/modules/validatorsDataIngest/index.unit.test.ts b/packages/brain/test/unit/modules/validatorsDataIngest/index.unit.test.ts new file mode 100644 index 00000000..ce211523 --- /dev/null +++ b/packages/brain/test/unit/modules/validatorsDataIngest/index.unit.test.ts @@ -0,0 +1,29 @@ +import { PostgresClient } from "../../../../src/modules/apiClients/index.js"; +import { fetchAndProcessValidatorsData } from "../../../../src/modules/validatorsDataIngest/index.js"; +import { Granularity } from "../../../../src/modules/validatorsDataIngest/types.js"; + +// This test must be executed with a real database connection + +describe.skip("Validators data ingest", function () { + this.timeout(10 * 1000); + // change the dbUrl on demmand + const dbUrl = "postgres://postgres:password@postgres.web3signer-holesky.dappnode:5432/web3signer"; + const postgresClient = new PostgresClient(dbUrl); + + it("should fetch and process validators data", async () => { + const validatorIndexes = ["1802289", "1802258"]; + const minGenesisTime = 1695902100; + const secondsPerSlot = 12; + + const data = await fetchAndProcessValidatorsData({ + validatorIndexes, + postgresClient, + minGenesisTime, + secondsPerSlot, + numberOfDaysToQuery: 1, + granularity: Granularity.Hourly + }); + + console.log(data); + }); +});