diff --git a/relay/utils/discord_monitor.ts b/relay/utils/discord_monitor.ts index ddc3a94a7..29b6b41f6 100644 --- a/relay/utils/discord_monitor.ts +++ b/relay/utils/discord_monitor.ts @@ -1,212 +1,160 @@ -import { SolidityContract } from '@/implementations/solidity-contract'; -import { getBeaconApi, BeaconApi } from '@/implementations/beacon-api'; import { ethers } from 'ethers'; -import { sleep } from '@dendreth/utils/ts-utils/common-utils'; -import { GatewayIntentBits, Events, Partials } from 'discord.js'; import * as Discord from 'discord.js'; -import lc_abi_json from '@dendreth/solidity/artifacts/BeaconLightClient.json'; - -const env = process.env; +import contractAbi from '../../beacon-light-client/solidity/tasks/hashi_abi.json'; +import { getEnvString } from '@dendreth/utils/ts-utils/common-utils'; + +async function getLastEventTime( + contract: ethers.Contract, + network: string, +): Promise { + const latestBlock = await contract.provider.getBlockNumber(); + const filter = contract.filters.HashStored(); + + const events = await contract.queryFilter( + filter, + latestBlock - 10000, + latestBlock, + ); + if (events.length === 0) { + console.log(`No events found for network '${network}'.`); + throw new Error('No events found.'); + } -interface ContractData { - RPC: string; - Address: string; - SolidityContract?: SolidityContract; + return (await events[events.length - 1].getBlock()).timestamp * 1000; } -type SolidityDictionary = { - [name: string]: ContractData; -}; - -class DiscordMonitor { - private readonly contracts: SolidityDictionary = {}; - - constructor( - private readonly client: Discord.Client, - private readonly beaconApi: BeaconApi, - private readonly alert_threshold: number, - ) { - if (env.GOERLI_RPC && env.LC_GOERLI) { - this.contracts['Goerli'] = { - RPC: env.GOERLI_RPC, - Address: env.LC_GOERLI, - }; - } - if (env.OPTIMISTIC_GOERLI_RPC && env.LC_OPTIMISTIC_GOERLI) { - this.contracts['OptimisticGoerli'] = { - RPC: env.OPTIMISTIC_GOERLI_RPC, - Address: env.LC_OPTIMISTIC_GOERLI, - }; - } - if (env.BASE_GOERLI_RPC && env.LC_BASE_GOERLI) { - this.contracts['BaseGoerli'] = { - RPC: env.BASE_GOERLI_RPC, - Address: env.LC_BASE_GOERLI, - }; - } +// Monitor function that checks for contract updates and dispatches Discord alerts +async function checkContractUpdate( + providerUrl: string, + contractAddress: string, + network: string, + alertThresholdMinutes: number, + discordClient: Discord.Client, +) { + try { + const provider = new ethers.providers.JsonRpcProvider(providerUrl); + const contract = new ethers.Contract( + contractAddress, + contractAbi, + provider, + ); - if (env.ARBITRUM_GOERLI_RPC && env.LC_ARBITRUM_GOERLI) { - this.contracts['ArbitrumGoerli'] = { - RPC: env.ARBITRUM_GOERLI_RPC, - Address: env.LC_ARBITRUM_GOERLI, - }; - } - if (env.SEPOLIA_RPC && env.LC_SEPOLIA) { - this.contracts['Sepolia'] = { - RPC: env.SEPOLIA_RPC, - Address: env.LC_SEPOLIA, - }; - } - if (env.MUMBAI_RPC && env.LC_MUMBAI) { - this.contracts['Mumbai'] = { - RPC: env.MUMBAI_RPC, - Address: env.LC_MUMBAI, - }; - } - if (env.FANTOM_RPC && env.LC_FANTOM) { - this.contracts['Fantom'] = { - RPC: env.FANTOM_RPC, - Address: env.LC_FANTOM, - }; - } - if (env.CHIADO_RPC && env.LC_CHIADO) { - this.contracts['Chiado'] = { - RPC: env.CHIADO_RPC, - Address: env.LC_CHIADO, - }; - } - if (env.GNOSIS_RPC && env.LC_GNOSIS) { - this.contracts['Gnosis'] = { - RPC: env.GNOSIS_RPC, - Address: env.LC_GNOSIS, - }; - } - if (env.BSC_RPC && env.LC_BSC) { - this.contracts['BSC'] = { RPC: env.BSC_RPC, Address: env.LC_BSC }; + const lastEventTime = await getLastEventTime(contract, network); + const delayInMinutes = (Date.now() - lastEventTime) / 1000 / 60; + + if (!discordClient.isReady()) { + console.error('Discord client is not ready.'); + return; } - if (env.AURORA_RPC && env.LC_AURORA) { - this.contracts['Aurora'] = { - RPC: env.AURORA_RPC, - Address: env.LC_AURORA, - }; + + const channel = discordClient.channels.cache.get( + getEnvString('CHANNEL_ID'), + ) as Discord.TextChannel; + if (!channel) { + console.error('Discord channel not found.'); + return; } - // Instantiate SolidityContracts from .env - for (let endpoint in this.contracts) { - let curLightClient = new ethers.Contract( - this.contracts[endpoint].Address, - lc_abi_json.abi, - new ethers.providers.JsonRpcProvider(this.contracts[endpoint].RPC), // Provider + if (delayInMinutes >= alertThresholdMinutes) { + const message = `⚠️ Alert: Contract on **${network}** hasn't been updated in ${delayInMinutes.toFixed( + 2, + )} minutes.`; + await channel.send(message); + } else { + console.log( + `Contract on ${network} is up to date. Last update was before: ${delayInMinutes.toFixed( + 2, + )} minutes`, ); - - let curSolidityContract = new SolidityContract( - curLightClient, - this.contracts[endpoint].RPC, + } + } catch (error) { + if (error instanceof Error) { + console.error( + `Error checking contract on network '${network}':`, + error.message, ); - this.contracts[endpoint].SolidityContract = curSolidityContract; + } else { + console.error(`Unknown error occurred on network '${network}':`, error); } } +} - public static async initializeDiscordMonitor( - alert_threshold: number, - ): Promise { - const beaconApi = await getBeaconApi([ - 'http://unstable.prater.beacon-api.nimbus.team/', - ]); - - const client = new Discord.Client({ - intents: [ - GatewayIntentBits.Guilds, - GatewayIntentBits.GuildMessages, - GatewayIntentBits.GuildMembers, - GatewayIntentBits.DirectMessages, - ], - partials: [Partials.Channel, Partials.Message, Partials.Reaction], - }); - - const result = await client.login(env.token); - - await client.on(Events.ClientReady, async interaction => { - console.log('Client Ready!'); - console.log(`Logged in as ${client.user?.tag}!`); - }); - - return new DiscordMonitor(client, beaconApi, alert_threshold); - } +async function monitorContracts(networksAndAddresses: string[]) { + const discordClient = new Discord.Client({ + intents: [ + Discord.GatewayIntentBits.Guilds, + Discord.GatewayIntentBits.GuildMessages, + ], + }); - private async getSlotDelay(contract: SolidityContract) { - return ( - (await this.beaconApi.getCurrentHeadSlot()) - - (await contract.optimisticHeaderSlot()) - ); - } + discordClient.once(Discord.Events.ClientReady, () => { + console.log(`Logged in as ${discordClient.user?.tag}!`); + }); - private async respondToMessage() { - //TODO: Implement responsive commands - this.client.on(Events.MessageCreate, message => { - if (message.author.bot) return; + await discordClient.login(getEnvString('DISCORD_TOKEN')); - // Nice to have, responsive bot - console.log( - `Message from ${message.author.username}: ${message.content}`, - ); - if (message.content === '') console.log('Empty message'); //TODO: Bot can't read user messages - }); - } + const alertThresholdMinutes = parseInt( + getEnvString('ALERT_THRESHOLD_MINUTES'), + 10, + ); - public async dispatchMessage(messageToSend) { - let channel = this.client.channels.cache.get( - env.channel_id!, - ) as Discord.TextChannel; - if (!channel) { - channel = (await this.client.channels.fetch( - env.channel_id!, - )) as Discord.TextChannel; - } + const networks: string[] = []; - await channel.send(messageToSend); - } + for (let i = 0; i < networksAndAddresses.length; i += 2) { + const network = networksAndAddresses[i]; + const contractAddress = networksAndAddresses[i + 1]; + + try { + const rpcUrl = getEnvString(`${network.toUpperCase()}_RPC`); + + networks.push(network); - public async monitor_delay() { - for (let contract of Object.keys(this.contracts)) { - let name = contract; - let delay = await this.getSlotDelay( - this.contracts[contract].SolidityContract!, + // Immediately check the contract update, then set interval + checkContractUpdate( + rpcUrl, + contractAddress, + network, + alertThresholdMinutes, + discordClient, ); - // Dispatch - const minutes_delay = (delay * 1) / 5; - if (minutes_delay >= this.alert_threshold || delay < 0) { - let message = `Contract: ${name} is behind Beacon Head with ${minutes_delay} minutes`; - this.dispatchMessage(message); + setInterval( + () => + checkContractUpdate( + rpcUrl, + contractAddress, + network, + alertThresholdMinutes, + discordClient, + ), + 5 * 60 * 1000, + ); + } catch (error) { + if (error instanceof Error) { + console.warn(`Skipping network '${network}' due to:`, error.message); + } else { + console.warn( + `Skipping network '${network}' due to unknown error:`, + error, + ); } } } -} -(async () => { - let monitor = await DiscordMonitor.initializeDiscordMonitor( - Number(env.ping_threshold), + console.log( + `Monitoring the following networks: [ '${networks.join("', '")}' ]`, ); +} - monitor.dispatchMessage('Relayer bot starting!'); - - let retry_counter = 0; - while (true) { - if (retry_counter >= 10) { - throw new Error( - `Failed connection to Discord after ${retry_counter} retries`, - ); - } +(async () => { + const args = process.argv.slice(2); - const msTimeout = 10_000; - let waitPromise = new Promise<'timeout'>(resolve => - setTimeout(() => resolve('timeout'), msTimeout), + if (args.length === 0 || args.length % 2 !== 0) { + console.error( + 'Please specify the network and contract address pairs (e.g., sepolia 0x1234 chiado 0x5678).', ); - let response = await Promise.race([monitor.monitor_delay(), waitPromise]); - - retry_counter += response == 'timeout' ? 1 : 0; - - await sleep(env.ping_timeout); + process.exit(1); } + + await monitorContracts(args); })();