-
Notifications
You must be signed in to change notification settings - Fork 9
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(relay): Add script that msgs a discord channel if a contract is …
…not updating
- Loading branch information
Showing
1 changed file
with
128 additions
and
180 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
})(); |