From 205cfc4c233b4c6e60f2bf7910362923af5927e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Pr=C3=A9vost?= <998369+prevostc@users.noreply.github.com> Date: Sun, 4 Feb 2024 15:36:33 +0100 Subject: [PATCH] Add eol with reward detector script --- .gitignore | 3 +- src/lib/addressbook.ts | 8 + src/script/eol-with-rewards.ts | 302 +++++++++++++++++++++++++++++++++ 3 files changed, 312 insertions(+), 1 deletion(-) create mode 100644 src/script/eol-with-rewards.ts diff --git a/.gitignore b/.gitignore index d94fd08..938ad7f 100644 --- a/.gitignore +++ b/.gitignore @@ -12,4 +12,5 @@ dist/ typings/ old .DS_Store -compile.json \ No newline at end of file +compile.json +*.store.json \ No newline at end of file diff --git a/src/lib/addressbook.ts b/src/lib/addressbook.ts index 7f69076..9a2c0ac 100644 --- a/src/lib/addressbook.ts +++ b/src/lib/addressbook.ts @@ -2,6 +2,14 @@ import * as addressbook from 'blockchain-addressbook'; import { Chain } from './chain'; import { Hex } from 'viem'; +export function getChainWNativeTokenDecimals(chain: Chain): number { + if (chain === 'linea') { + return 18; + } + const tokens = addressbook.addressBook[chain].tokens; + return tokens.WNATIVE.decimals; +} + export function getChainWNativeTokenSymbol(chain: Chain): string { if (chain === 'linea') { return 'ETH'; diff --git a/src/script/eol-with-rewards.ts b/src/script/eol-with-rewards.ts new file mode 100644 index 0000000..fc223df --- /dev/null +++ b/src/script/eol-with-rewards.ts @@ -0,0 +1,302 @@ +import { Chain, allChainIds } from '../lib/chain'; +import { BeefyVault } from '../lib/vault'; +import { getVaultsToMonitorByChain } from '../lib/vault-list'; +import { runMain } from '../util/process'; +import * as fs from 'fs'; +import { rootLogger } from '../util/logger'; +import yargs from 'yargs'; +import { getReadOnlyRpcClient, getWalletAccount } from '../lib/rpc-client'; +import { RPC_CONFIG } from '../lib/config'; +import { BeefyHarvestLensABI } from '../abi/BeefyHarvestLensABI'; +import { getChainWNativeTokenAddress, getChainWNativeTokenDecimals } from '../lib/addressbook'; +import { AItem, AKey, AVal, reportOnMultipleAsyncCall, serializeReport } from '../lib/reports'; +import { Hex } from 'viem'; +import { Async } from '../util/async'; + +class JsonFileKVStore { + private path: string; + public data: { + [k: string]: T; + }; + + constructor(path: string) { + this.path = path; + this.data = {}; + } + + async load() { + if (!fs.existsSync(this.path)) { + return; + } + const dataIfExists = await fs.promises.readFile(this.path, { encoding: 'utf-8' }); + this.data = JSON.parse(dataIfExists); + } + + async persist() { + await fs.promises.writeFile(this.path, serializeReport(this.data, true), { encoding: 'utf-8' }); + } + + get(key: string) { + return this.data[key]; + } + + set(key: string, data: T) { + this.data[key] = data; + } + + has(key: string) { + return key in this.data; + } +} + +interface EolWithRewardsReportItem { + vault: BeefyVault; + simulation: Async<{ + estimatedCallRewardsWei: bigint; + harvestWillSucceed: boolean; + lastHarvest: Date; + hoursSinceLastHarvest: number; + isLastHarvestRecent: boolean; + paused: boolean; + blockNumber: bigint; + harvestResultData: Hex; + gasUsed: bigint; + }> | null; +} + +const logger = rootLogger.child({ module: 'eol-with-rewards-main' }); + +type CmdOptions = { + chain: Chain[]; + storePath: string; + mode: 'fetch' | 'report-summary'; +}; + +async function main() { + const argv = await yargs.usage('$0 [args]').options({ + chain: { + type: 'array', + choices: [...allChainIds, 'all'], + alias: 'c', + demand: false, + default: 'all', + describe: 'only harest these chains. eol chains will be ignored', + }, + 'store-path': { + type: 'string', + alias: 's', + demand: false, + default: 'eol-with-rewards.store.json', + describe: 'path to store the result and restart from in case of crash', + }, + mode: { + type: 'string', + alias: 'm', + demand: false, + default: 'fetch', + choices: ['fetch', 'report-summary'], + describe: 'fetch the data or report the summary', + }, + }).argv; + + const options: CmdOptions = { + chain: argv.chain.includes('all') ? allChainIds : (argv.chain as Chain[]), + storePath: argv['store-path'] as string, + mode: argv.mode as 'fetch' | 'report-summary', + }; + logger.trace({ msg: 'running with options', data: options }); + + const store = new JsonFileKVStore(options.storePath); + await store.load(); + + if (options.mode === 'fetch') { + const vaultsByChain = await getVaultsToMonitorByChain({ chains: options.chain, strategyAddress: null }); + + const processPromises = Object.entries(vaultsByChain).map(async ([chain, vaults]) => { + const rpcConfig = RPC_CONFIG[chain as Chain]; + if (rpcConfig.eol) { + logger.debug({ msg: 'skipping eol chain', data: { chain } }); + return; + } + if (!rpcConfig.harvest.enabled) { + logger.debug({ msg: 'skipping chain with harvest disabled', data: { chain } }); + return; + } + + logger.debug({ msg: 'processing chain', data: { chain, vaults: vaults.length } }); + + const vaultsToProcess: BeefyVault[] = []; + for (const vault of vaults) { + if (!vault.eol) { + logger.trace({ msg: 'vault is not eol', data: { vaultId: vault.id } }); + continue; + } + if (store.has(vault.id)) { + const report = store.get(vault.id); + if (report.simulation && report.simulation.status === 'fulfilled') { + logger.trace({ msg: 'vault already simulated successfully', data: { vaultId: vault.id } }); + continue; + } + } + vaultsToProcess.push(vault); + } + + logger.debug({ msg: 'processing vaults', data: { chain, vaultsToProcessCount: vaultsToProcess.length } }); + + const result = await fetchLensResult(chain as Chain, vaultsToProcess); + + for (const item of result) { + store.set(item.vault.id, item); + } + + await store.persist(); + }); + + await Promise.allSettled(processPromises); + } else if (options.mode === 'report-summary') { + const summary: { + chain: Chain; + vaultId: string; + status: 'not-simulated' | 'unsuccessful-simulation' | 'no-rewards-found' | 'found-eol-rewards'; + rewards?: string; + }[] = []; + + for (const [vaultId, item] of Object.entries(store.data)) { + logger.trace({ msg: 'reporting summary', data: { vaultId } }); + + if (item.simulation === null) { + summary.push({ chain: item.vault.chain, vaultId, status: 'not-simulated' }); + continue; + } + if (item.simulation.status === 'rejected') { + summary.push({ chain: item.vault.chain, vaultId, status: 'unsuccessful-simulation' }); + continue; + } + + const rewards = BigInt(item.simulation.value.estimatedCallRewardsWei); + if (rewards === 0n) { + summary.push({ chain: item.vault.chain, vaultId, status: 'no-rewards-found' }); + continue; + } + + summary.push({ + chain: item.vault.chain, + vaultId, + status: 'found-eol-rewards', + rewards: rewards.toString(), + }); + } + + //console.log(JSON.stringify(summary, null, 2)); + + const countsByChainAndStatus = summary.reduce( + (acc, cur) => { + if (!acc[cur.chain]) { + acc[cur.chain] = {}; + } + if (!acc[cur.chain][cur.status]) { + acc[cur.chain][cur.status] = 0; + } + acc[cur.chain][cur.status]++; + return acc; + }, + {} as { [k: string]: { [k: string]: number } } + ); + console.log(JSON.stringify(countsByChainAndStatus, null, 2)); + + const vaultSummaryByChain = summary.reduce( + (acc, cur) => { + if (!acc[cur.chain]) { + acc[cur.chain] = []; + } + const rewards = BigInt(cur.rewards || '0'); + const wnativeDecimals = getChainWNativeTokenDecimals(cur.chain); + const divisor = BigInt('1' + '0'.repeat(wnativeDecimals)); + const rewardsEth = + (rewards / divisor).toString() + '.' + rewards.toString().padStart(wnativeDecimals, '0'); + acc[cur.chain].push({ vaultId: cur.vaultId, rewards, rewardsEth }); + return acc; + }, + {} as { [k: string]: { vaultId: string; rewards: BigInt; rewardsEth: string }[] } + ); + + const top3VaultsByRewardsAndChain = Object.entries(vaultSummaryByChain).reduce( + (acc, [chain, vaults]) => { + acc[chain] = vaults.sort((a, b) => (a.rewards > b.rewards ? -1 : 0)).slice(0, 3); + return acc; + }, + {} as { [k: string]: { vaultId: string; rewards: BigInt; rewardsEth: string }[] } + ); + console.log(serializeReport(top3VaultsByRewardsAndChain, true)); + + const totalTvlByChain = Object.entries(store.data).reduce( + (acc, [vaultId, item]) => { + if (!acc[item.vault.chain]) { + acc[item.vault.chain] = 0; + } + acc[item.vault.chain] += item.vault.tvlUsd; + return acc; + }, + {} as { [k: string]: number } + ); + + totalTvlByChain['__all__'] = Object.values(totalTvlByChain).reduce((acc, cur) => acc + cur, 0); + console.log(JSON.stringify(totalTvlByChain, null, 2)); + } +} + +function reportOnMultipleEolRewardsAsyncCall< + TKey extends AKey, + TItem extends AItem, + TVal extends AVal, +>(...args: Parameters>) { + return reportOnMultipleAsyncCall(...args); +} + +async function fetchLensResult(chain: Chain, vaults: BeefyVault[]) { + const wnative = getChainWNativeTokenAddress(chain); + const publicClient = getReadOnlyRpcClient({ chain }); + const walletAccount = getWalletAccount({ chain }); + + // we need the harvest lense + const rpcConfig = RPC_CONFIG[chain]; + if (!rpcConfig.contracts.harvestLens) { + throw new Error(`Missing harvest lens address for chain ${chain}`); + } + const harvestLensContract = { abi: BeefyHarvestLensABI, address: rpcConfig.contracts.harvestLens }; + + const items = vaults.map(vault => ({ vault, report: { vault, simulation: null } as EolWithRewardsReportItem })); + + await reportOnMultipleEolRewardsAsyncCall(items, 'simulation', 'parallel', async item => { + const { + result: { callReward, gasUsed, lastHarvest, paused, success, blockNumber, harvestResult }, + } = await publicClient.simulateContract({ + ...harvestLensContract, + functionName: 'harvest', + args: [item.vault.strategyAddress, wnative] as const, + account: walletAccount, + }); + + const now = new Date(); + const lastHarvestDate = new Date(Number(lastHarvest) * 1000); + const timeSinceLastHarvestMs = now.getTime() - lastHarvestDate.getTime(); + const isLastHarvestRecent = timeSinceLastHarvestMs < rpcConfig.harvest.targetTimeBetweenHarvestsMs; + + //await new Promise(resolve => setTimeout(resolve, 1000)); + return { + estimatedCallRewardsWei: callReward, + harvestWillSucceed: success, + lastHarvest: lastHarvestDate, + hoursSinceLastHarvest: timeSinceLastHarvestMs / 1000 / 60 / 60, + isLastHarvestRecent, + paused, + blockNumber, + gasUsed, + harvestResultData: harvestResult, + }; + }); + + return items.map(i => i.report); +} + +runMain(main);