Skip to content

Commit

Permalink
feat(relay): Add script that msgs a discord channel if a contract is …
Browse files Browse the repository at this point in the history
…not updating
  • Loading branch information
kkirkov committed Sep 12, 2024
1 parent 77b0bff commit a585b4d
Showing 1 changed file with 128 additions and 180 deletions.
308 changes: 128 additions & 180 deletions relay/utils/discord_monitor.ts
Original file line number Diff line number Diff line change
@@ -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<number> {
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<DiscordMonitor> {
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);
})();

0 comments on commit a585b4d

Please sign in to comment.