diff --git a/packages/brain/src/index.ts b/packages/brain/src/index.ts index 7a47e66b..3968d9db 100644 --- a/packages/brain/src/index.ts +++ b/packages/brain/src/index.ts @@ -6,7 +6,8 @@ import { BeaconchainApi, BlockExplorerApi, ValidatorApi, - DappnodeSignatureVerifier + DappnodeSignatureVerifier, + DappmanagerApi } from "./modules/apiClients/index.js"; import { startUiServer, startLaunchpadApi } from "./modules/apiServers/index.js"; import * as dotenv from "dotenv"; @@ -75,6 +76,7 @@ export const validatorApi = new ValidatorApi( ); export const beaconchainApi = new BeaconchainApi({ baseUrl: beaconchainUrl }, network); export const dappnodeSignatureVerifierApi = new DappnodeSignatureVerifier(network, validatorsMonitorUrl); +export const dappmanagerApi = new DappmanagerApi({ baseUrl: "http://my.dappnode" }, network); // Create DB instance export const brainDb = new BrainDataBase( @@ -111,7 +113,9 @@ export const trackValidatorsPerformanceCronTask = new CronJob( postgresClient, beaconchainApi, executionClient, - consensusClient + consensusClient, + dappmanagerApi, + sendNotification: true }); } ); diff --git a/packages/brain/src/modules/apiClients/dappmanager/error.ts b/packages/brain/src/modules/apiClients/dappmanager/error.ts new file mode 100644 index 00000000..126dc299 --- /dev/null +++ b/packages/brain/src/modules/apiClients/dappmanager/error.ts @@ -0,0 +1,8 @@ +import { ApiError } from "../error.js"; + +export class DappmanagerApiError extends ApiError { + constructor(message: string) { + super(message); + this.name = "DappmanagerApiError"; + } +} diff --git a/packages/brain/src/modules/apiClients/dappmanager/index.ts b/packages/brain/src/modules/apiClients/dappmanager/index.ts new file mode 100644 index 00000000..f17cc338 --- /dev/null +++ b/packages/brain/src/modules/apiClients/dappmanager/index.ts @@ -0,0 +1,28 @@ +import logger from "../../logger/index.js"; +import { StandardApi } from "../standard.js"; +import { NotificationType } from "./types.js"; + +export class DappmanagerApi extends StandardApi { + /** + * Triggers a notification in the dappmanager. + */ + public async sendDappmanagerNotification({ + notificationType, + title, + body + }: { + notificationType: NotificationType; + title: string; + body: string; + }): Promise { + try { + await this.request({ + method: "POST", + endpoint: `/notification-send?type=${encodeURIComponent(notificationType)}&title=${encodeURIComponent(title)}&body=${encodeURIComponent(body)}` + }); + } catch (error) { + logger.error("Failed to send notification to dappmanager", error); + throw error; + } + } +} diff --git a/packages/brain/src/modules/apiClients/dappmanager/types.ts b/packages/brain/src/modules/apiClients/dappmanager/types.ts new file mode 100644 index 00000000..69229824 --- /dev/null +++ b/packages/brain/src/modules/apiClients/dappmanager/types.ts @@ -0,0 +1,7 @@ +// Must be in sync with dappmanager +export enum NotificationType { + Success = "success", + Info = "info", + Warning = "warning", + Danger = "danger" +} diff --git a/packages/brain/src/modules/apiClients/index.ts b/packages/brain/src/modules/apiClients/index.ts index 277ee2fe..752c626c 100644 --- a/packages/brain/src/modules/apiClients/index.ts +++ b/packages/brain/src/modules/apiClients/index.ts @@ -1,4 +1,5 @@ export { BlockExplorerApi } from "./blockExplorer/index.js"; +export { DappmanagerApi } from "./dappmanager/index.js"; export { BeaconchainApi } from "./beaconchain/index.js"; export { ValidatorApi } from "./validator/index.js"; export { StandardApi } from "./standard.js"; diff --git a/packages/brain/src/modules/cron/trackValidatorsPerformance/insertPerformanceData.ts b/packages/brain/src/modules/cron/trackValidatorsPerformance/insertPerformanceDataAndSendNotification.ts similarity index 87% rename from packages/brain/src/modules/cron/trackValidatorsPerformance/insertPerformanceData.ts rename to packages/brain/src/modules/cron/trackValidatorsPerformance/insertPerformanceDataAndSendNotification.ts index d772262d..510eb67b 100644 --- a/packages/brain/src/modules/cron/trackValidatorsPerformance/insertPerformanceData.ts +++ b/packages/brain/src/modules/cron/trackValidatorsPerformance/insertPerformanceDataAndSendNotification.ts @@ -1,5 +1,5 @@ import { ConsensusClient, ExecutionClient } from "@stakingbrain/common"; -import { PostgresClient } from "../../apiClients/index.js"; +import { DappmanagerApi, PostgresClient } from "../../apiClients/index.js"; import { BlockProposalStatus, ValidatorPerformance, @@ -9,6 +9,7 @@ import { import { TotalRewards } from "../../apiClients/types.js"; import logger from "../../logger/index.js"; import { logPrefix } from "./logPrefix.js"; +import { sendValidatorsPerformanceNotifications } from "./sendValidatorsPerformanceNotifications.js"; /** * Insert the performance data for the validators in the Postgres DB. On any error @@ -21,7 +22,9 @@ import { logPrefix } from "./logPrefix.js"; * @param validatorBlockStatusMap - Map with the block proposal status of each validator. * @param validatorsAttestationsTotalRewards - Array of total rewards for the validators. */ -export async function insertPerformanceData({ +export async function insertPerformanceDataAndSendNotification({ + sendNotification, + dappmanagerApi, postgresClient, activeValidatorsIndexes, currentEpoch, @@ -31,6 +34,8 @@ export async function insertPerformanceData({ consensusClient, error }: { + sendNotification: boolean; + dappmanagerApi: DappmanagerApi; postgresClient: PostgresClient; activeValidatorsIndexes: string[]; currentEpoch: number; @@ -104,6 +109,15 @@ export async function insertPerformanceData({ attestationsTotalRewards } }); + + await sendValidatorsPerformanceNotifications({ + sendNotification, + dappmanagerApi, + currentEpoch: currentEpoch.toString(), + validatorBlockStatusMap, + validatorsAttestationsTotalRewards, + error + }); } } diff --git a/packages/brain/src/modules/cron/trackValidatorsPerformance/sendValidatorsPerformanceNotifications.ts b/packages/brain/src/modules/cron/trackValidatorsPerformance/sendValidatorsPerformanceNotifications.ts new file mode 100644 index 00000000..ddc3aa94 --- /dev/null +++ b/packages/brain/src/modules/cron/trackValidatorsPerformance/sendValidatorsPerformanceNotifications.ts @@ -0,0 +1,115 @@ +import { DappmanagerApi } from "../../apiClients/index.js"; +import { NotificationType } from "../../apiClients/dappmanager/types.js"; +import { BlockProposalStatus, ValidatorPerformanceError } from "../../apiClients/postgres/types.js"; +import logger from "../../logger/index.js"; +import { logPrefix } from "./logPrefix.js"; +import { TotalRewards } from "../../apiClients/types.js"; + +/** + * Sends validator performance notification to the dappmanager. The notification will have the following format: + * ``` + * **Validator(s) performance notification for epoch ** + * - Blocks: + * - Proposed: Validator(s) proposed a block + * - Missed: Validator(s) missed a block + * - Attestations + * - Error + * ``` + */ +export async function sendValidatorsPerformanceNotifications({ + sendNotification, + dappmanagerApi, + currentEpoch, + validatorBlockStatusMap, + validatorsAttestationsTotalRewards, + error +}: { + sendNotification: boolean; + dappmanagerApi: DappmanagerApi; + currentEpoch: string; + validatorBlockStatusMap: Map; + validatorsAttestationsTotalRewards: TotalRewards[]; + error?: ValidatorPerformanceError; +}): Promise { + if (!sendNotification) return; + if (error) + await dappmanagerApi.sendDappmanagerNotification({ + title: "Failed to fetch performance data", + notificationType: NotificationType.Danger, + body: `Failed to fetch performance data for epoch ${currentEpoch}: ${error}` + }); + else { + await Promise.all([ + sendSuccessNotificationNotThrow({ dappmanagerApi, validatorBlockStatusMap, currentEpoch }), + sendWarningNotificationNotThrow({ + dappmanagerApi, + validatorBlockStatusMap, + validatorsAttestationsTotalRewards, + currentEpoch + }) + ]); + } +} + +/** + * Triggers sending success notification in the dappmanager if any: + * - blocks proposed + */ +async function sendSuccessNotificationNotThrow({ + dappmanagerApi, + validatorBlockStatusMap, + currentEpoch +}: { + dappmanagerApi: DappmanagerApi; + validatorBlockStatusMap: Map; + currentEpoch: string; +}): Promise { + const validatorsProposedBlocks = Array.from(validatorBlockStatusMap).filter( + ([_, blockStatus]) => blockStatus === "Proposed" + ); + + if (validatorsProposedBlocks.length === 0) return; + await dappmanagerApi + .sendDappmanagerNotification({ + title: `Validator(s) proposed a block in epoch ${currentEpoch}`, + notificationType: NotificationType.Success, + body: `Validator(s) ${validatorsProposedBlocks.join(", ")} proposed a block` + }) + .catch((error) => logger.error(`${logPrefix}Failed to send success notification to dappmanager`, error)); +} + +/** + * Triggers sending warning notification in the dappmanager if any: + * - blocks missed + * - attestations missed + */ +async function sendWarningNotificationNotThrow({ + dappmanagerApi, + validatorBlockStatusMap, + validatorsAttestationsTotalRewards, + currentEpoch +}: { + dappmanagerApi: DappmanagerApi; + validatorBlockStatusMap: Map; + validatorsAttestationsTotalRewards: TotalRewards[]; + currentEpoch: string; +}): Promise { + // Send the warning notification together: block missed and att missed + const validatorsMissedBlocks = Array.from(validatorBlockStatusMap).filter( + ([_, blockStatus]) => blockStatus === "Missed" + ); + const validatorsMissedAttestations = validatorsAttestationsTotalRewards + .filter((validator) => parseInt(validator.source) === 0) + .map((validator) => validator.validator_index); + + if (validatorsMissedBlocks.length === 0 && validatorsMissedAttestations.length === 0) return; + await dappmanagerApi + .sendDappmanagerNotification({ + title: `Validator(s) missed a block or attestation in epoch ${currentEpoch}`, + notificationType: NotificationType.Warning, + body: `Validator(s) ${validatorsMissedBlocks.join(", ")} missed a block. Validator(s) ${validatorsMissedAttestations.join( + ", " + )} missed an attestation` + }) + .catch((error) => logger.error(`${logPrefix}Failed to send warning notification to dappmanager`, error)); +} diff --git a/packages/brain/src/modules/cron/trackValidatorsPerformance/trackValidatorsPerformance.ts b/packages/brain/src/modules/cron/trackValidatorsPerformance/trackValidatorsPerformance.ts index 4ff7f5ce..404b244e 100644 --- a/packages/brain/src/modules/cron/trackValidatorsPerformance/trackValidatorsPerformance.ts +++ b/packages/brain/src/modules/cron/trackValidatorsPerformance/trackValidatorsPerformance.ts @@ -2,17 +2,22 @@ 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 { insertPerformanceData } from "./insertPerformanceData.js"; +import { insertPerformanceDataAndSendNotification } from "./insertPerformanceDataAndSendNotification.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"; import { TotalRewards } from "../../apiClients/types.js"; -import { ValidatorPerformanceError, ValidatorPerformanceErrorCode } from "../../apiClients/postgres/types.js"; +import { + BlockProposalStatus, + ValidatorPerformanceError, + ValidatorPerformanceErrorCode +} from "../../apiClients/postgres/types.js"; import { BeaconchainApiError } from "../../apiClients/beaconchain/error.js"; import { BrainDbError } from "../../db/error.js"; import { ExecutionOfflineError, NodeSyncingError } from "./error.js"; +import { DappmanagerApi } from "../../apiClients/index.js"; let lastProcessedEpoch: number | undefined = undefined; let lastEpochProcessedWithError = false; @@ -22,13 +27,17 @@ export async function trackValidatorsPerformanceCron({ postgresClient, beaconchainApi, executionClient, - consensusClient + consensusClient, + dappmanagerApi, + sendNotification }: { brainDb: BrainDataBase; postgresClient: PostgresClient; beaconchainApi: BeaconchainApi; executionClient: ExecutionClient; consensusClient: ConsensusClient; + dappmanagerApi: DappmanagerApi; + sendNotification: boolean; }): Promise { try { // Get finalized epoch from finality endpoint instead of from header endpoint. @@ -50,7 +59,9 @@ export async function trackValidatorsPerformanceCron({ beaconchainApi, executionClient, consensusClient, - currentEpoch + currentEpoch, + dappmanagerApi, + sendNotification }); lastProcessedEpoch = currentEpoch; } @@ -65,7 +76,9 @@ export async function fetchAndInsertPerformanceCron({ beaconchainApi, executionClient, consensusClient, - currentEpoch + currentEpoch, + dappmanagerApi, + sendNotification }: { brainDb: BrainDataBase; postgresClient: PostgresClient; @@ -73,10 +86,12 @@ export async function fetchAndInsertPerformanceCron({ executionClient: ExecutionClient; consensusClient: ConsensusClient; currentEpoch: number; + dappmanagerApi: DappmanagerApi; + sendNotification: boolean; }): Promise { let validatorPerformanceError: ValidatorPerformanceError | undefined; let activeValidatorsIndexes: string[] = []; - let validatorBlockStatusMap = new Map(); + let validatorBlockStatusMap: Map = new Map(); let validatorsAttestationsTotalRewards: TotalRewards[] = []; try { @@ -117,7 +132,9 @@ export async function fetchAndInsertPerformanceCron({ lastEpochProcessedWithError = true; } finally { // Always call storeData in the finally block, regardless of success or failure in try block - await insertPerformanceData({ + await insertPerformanceDataAndSendNotification({ + sendNotification, + dappmanagerApi, postgresClient, activeValidatorsIndexes, currentEpoch, diff --git a/packages/brain/test/unit/modules/apiClients/dappmanagerApi.unit.test.ts b/packages/brain/test/unit/modules/apiClients/dappmanagerApi.unit.test.ts new file mode 100644 index 00000000..884e3d7f --- /dev/null +++ b/packages/brain/test/unit/modules/apiClients/dappmanagerApi.unit.test.ts @@ -0,0 +1,14 @@ +import { Network } from "@stakingbrain/common"; +import { DappmanagerApi } from "../../../../src/modules/apiClients/index.js"; +import { NotificationType } from "../../../../src/modules/apiClients/dappmanager/types.js"; + +describe.skip("Dappmanager API", () => { + const dappmanagerApi = new DappmanagerApi({ baseUrl: "http://my.dappnode" }, Network.Holesky); + + it("should send a notification to the dappmanager", async () => { + const title = "Test title"; + const body = "Test body"; + + await dappmanagerApi.sendDappmanagerNotification({ notificationType: NotificationType.Success, title, body }); + }); +});