diff --git a/package.json b/package.json index dbb23acaf4..adfc4b9846 100644 --- a/package.json +++ b/package.json @@ -74,8 +74,6 @@ "pause": "ts-node ./scripts/pausePlatform.ts", "setVaultUrls": "ts-node -r dotenv/config ./scripts/setVaultUrls.ts && prettier --write ./src/config/vault/*.json", "setVaultStatus": "ts-node -r dotenv/config ./scripts/setVaultStatus.ts", - "addClmRewardPools": "ts-node -r dotenv/config ./scripts/addClmRewardPools.ts", - "updateRewardPoolRewards": "ts-node -r dotenv/config ./scripts/updateRewardPoolRewards.ts", "addCurveZap": "ts-node -r dotenv/config ./scripts/addCurveZap.ts", "addBalancerZap": "ts-node -r dotenv/config ./scripts/addBalancerZap.ts", "checkVaultTokenDecimals": "ts-node -r dotenv/config ./scripts/checkVaultTokenDecimals.ts && prettier --write ./src/config/vault/*.json", @@ -115,6 +113,7 @@ "@types/react-dom": "^18.3.0", "@types/react-router-dom": "^5.3.3", "@vitejs/plugin-react": "^3.1.0", + "chalk": "^4.1.2", "cross-env": "^7.0.3", "csv-parser": "^3.0.0", "dotenv": "^16.3.1", diff --git a/scripts/common/abi/BeefyOracleAbi.ts b/scripts/common/abi/BeefyOracleAbi.ts new file mode 100644 index 0000000000..f4da856a2e --- /dev/null +++ b/scripts/common/abi/BeefyOracleAbi.ts @@ -0,0 +1,57 @@ +import { Abi } from 'viem'; + +export const BeefyOracleAbi = [ + { + inputs: [ + { + internalType: 'address', + name: '', + type: 'address', + }, + ], + name: 'subOracle', + outputs: [ + { + internalType: 'address', + name: 'oracle', + type: 'address', + }, + { + internalType: 'bytes', + name: 'data', + type: 'bytes', + }, + ], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [ + { + internalType: 'address', + name: '', + type: 'address', + }, + { + internalType: 'address', + name: '', + type: 'address', + }, + ], + name: 'subOracle', + outputs: [ + { + internalType: 'address', + name: 'oracle', + type: 'address', + }, + { + internalType: 'bytes', + name: 'data', + type: 'bytes', + }, + ], + stateMutability: 'view', + type: 'function', + }, +] as const satisfies Abi; diff --git a/scripts/common/config.ts b/scripts/common/config.ts index 42dd6c2917..5ad5bcf603 100644 --- a/scripts/common/config.ts +++ b/scripts/common/config.ts @@ -22,7 +22,7 @@ export type ChainConfig = (typeof chainConfigs)[keyof typeof chainConfigs]; * Use `yarn makeExcludeConfig chain` to generate the hash * Key must be the addressbook/api chain id, not app chain id (i.e. use one over harmony) * */ -export const excludeChains: Record = { +export const excludeChains: Partial> = { heco: { count: 35, hash: 'ccab3fea9945e6474f803946d72001a04245fb2556f340ebee7a65af61be4773', @@ -57,9 +57,9 @@ export const excludeChains: Record = { }, }; -export const excludedChainIds = Object.keys(excludeChains); -export const allChainIds: string[] = Object.keys(chainConfigs); -export const chainIds: string[] = allChainIds.filter(chainId => !(chainId in excludeChains)); +export const excludedChainIds = Object.keys(excludeChains) as AddressBookChainId[]; +export const allChainIds = Object.keys(chainConfigs) as AddressBookChainId[]; +export const chainIds = allChainIds.filter(chainId => !(chainId in excludeChains)); export const chainRpcs: Record = Object.fromEntries( allChainIds.map(chainId => [ chainId, diff --git a/scripts/common/factory.ts b/scripts/common/factory.ts new file mode 100644 index 0000000000..b486f7ff3d --- /dev/null +++ b/scripts/common/factory.ts @@ -0,0 +1,26 @@ +type FactoryFn = (...props: P[]) => R; + +export function createFactory(factoryFn: FactoryFn): FactoryFn { + let cache: R | undefined; + return (...args: P[]): R => { + if (cache === undefined) { + cache = factoryFn(...args); + } + return cache; + }; +} + +export function createCachedFactory any>( + factoryFn: FN, + keyFn: (...args: Parameters) => string = (...args) => JSON.stringify(args) +) { + const cache: { [index: string]: ReturnType } = {}; + return (...args: Parameters) => { + const index = keyFn(...args); + let value = cache[index]; + if (value === undefined) { + value = cache[index] = factoryFn(...args); + } + return value; + }; +} diff --git a/scripts/common/pconsole.ts b/scripts/common/pconsole.ts new file mode 100644 index 0000000000..6184273d97 --- /dev/null +++ b/scripts/common/pconsole.ts @@ -0,0 +1,78 @@ +import chalk from 'chalk'; +import { formatWithOptions, InspectOptions } from 'node:util'; + +class PrettyConsole { + private successTag: string = '[SUCCESS]'; + private infoTag: string = '[INFO]'; + private warnTag: string = '[WARN]'; + private errorTag: string = '[ERROR]'; + private formatOptions: InspectOptions = { colors: false }; + private supportsColor: boolean = false; + + constructor(color: boolean = true) { + if (color) { + this.enableColor(); + } + } + + enableColor() { + if (chalk.supportsColor) { + this.supportsColor = true; + this.successTag = chalk.green('success'); + this.infoTag = chalk.blue('info'); + this.warnTag = chalk.yellow('warn'); + this.errorTag = chalk.red('error'); + this.formatOptions = { colors: true }; + } + } + + disableColor() { + if (this.supportsColor) { + this.supportsColor = false; + this.successTag = '[SUCCESS]'; + this.infoTag = '[INFO]'; + this.warnTag = '[WARN]'; + this.errorTag = '[ERROR]'; + this.formatOptions = { colors: false }; + } + } + + private get columns() { + return process.stdout.columns || 80; + } + + success(...args: any[]) { + console.log(this.successTag, ...args); + } + + info(...args: any[]) { + console.info(this.infoTag, ...args); + } + + warn(...args: any[]) { + console.warn(this.warnTag, ...args); + } + + error(...args: any[]) { + console.error(this.errorTag, ...args); + } + + log(...args: any[]) { + console.log(...args); + } + + dim(...args: any[]) { + if (this.supportsColor) { + console.log(chalk.dim(this.format(...args))); + } else { + console.log(...args); + } + } + + private format(...args: any[]) { + // should be very similar to how console.log formats to string + return formatWithOptions(this.formatOptions, ...args); + } +} + +export const pconsole = new PrettyConsole(); diff --git a/scripts/common/utils.ts b/scripts/common/utils.ts index 5288cd906a..70e8a0fe20 100644 --- a/scripts/common/utils.ts +++ b/scripts/common/utils.ts @@ -1,5 +1,6 @@ import { Address, getAddress } from 'viem'; +export const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000'; const trimReg = /(^\s*)|(\s*$)/g; export function isValidChecksumAddress(address: unknown): address is Address { @@ -115,3 +116,35 @@ export async function mapValuesAsync( } return result; } + +export type RetryOptions = { + retries: number; + delay: number; + onRetry?: (error: Error, attempt: number) => void; +}; + +const defaultRetryOptions: RetryOptions = { + retries: 5, + delay: 1000, +}; + +export async function withRetries( + fn: () => Promise, + options: RetryOptions = defaultRetryOptions +): Promise { + for (let i = 0; i < options.retries; i++) { + try { + return await fn(); + } catch (e) { + if (options.onRetry) { + options.onRetry(e, i); + } + await sleep(options.delay); + } + } + return fn(); +} + +export function isDefined(value: T | undefined | null): value is Exclude { + return value !== undefined && value !== null; +} diff --git a/scripts/pausePlatform.ts b/scripts/pausePlatform.ts index 3938201360..b72422bcd1 100644 --- a/scripts/pausePlatform.ts +++ b/scripts/pausePlatform.ts @@ -6,7 +6,7 @@ import { saveJson } from './common/files'; const vaultsDir = './src/config/vault/'; async function pause() { - const timestamp = Math.floor(Date.now() / 1000); + const timestamp = Math.trunc(Date.now() / 1000); const platformId = process.argv[2]; for (const chain of chains) { const vaultsFile = vaultsDir + chain + '.json'; @@ -14,6 +14,7 @@ async function pause() { vaults.forEach(v => { if (v.platformId === platformId && v.status === 'active') { v.status = 'paused'; + v.pausedAt = timestamp; } }); await saveJson(vaultsFile, vaults, 'prettier'); diff --git a/scripts/updateRewardPoolRewards.ts b/scripts/updateRewardPoolRewards.ts deleted file mode 100644 index 39ccaaaea3..0000000000 --- a/scripts/updateRewardPoolRewards.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { ArgumentConfig, parse } from 'ts-command-line-args'; -import { appToAddressBookId, chainRpcs, getChain, getVaultsForChain } from './common/config'; -import { sortVaultKeys } from './common/vault-fields'; -import { VaultConfig } from '../src/features/data/apis/config-types'; -import Web3 from 'web3'; -import { addressBook } from 'blockchain-addressbook'; -import chunk from 'lodash/chunk'; -import { BeefyV2AppMulticallAbi } from '../src/config/abi/BeefyV2AppMulticallAbi'; -import type { GovVaultMultiContractDataResponse } from '../src/features/data/apis/contract-data/contract-data-types'; -import type { AsWeb3Result } from '../src/features/data/utils/types-utils'; -import BigNumber from 'bignumber.js'; -import type { AbiItem } from 'web3-utils'; -import { saveJson } from './common/files'; - -type RunArgs = { - help?: boolean; - chain: string; -}; - -const runArgsConfig: ArgumentConfig = { - help: { - type: Boolean, - alias: 'h', - description: 'Display this usage guide.', - optional: true, - }, - chain: { - type: String, - alias: 'c', - description: 'Chain json file to process', - }, -}; - -function getRunArgs() { - return parse(runArgsConfig, { - helpArg: 'help', - headerContentSections: [ - { - header: 'yarn updateRewardPoolRewards', - content: `Set `, - }, - ], - }); -} - -type PoolData = { vault: string; rewardPool: string }; - -function isDefined(value: T): value is Exclude { - return value !== undefined && value !== null; -} - -async function fetchActiveRewards(appChainId: string, configs: VaultConfig[]) { - const now = Math.floor(Date.now() / 1000); - const chain = getChain(appChainId); - const abChainId = appToAddressBookId(appChainId); - const addresses = configs.map(c => c.earnContractAddress); - const web3 = new Web3(chainRpcs[abChainId]); - const mc = new web3.eth.Contract( - BeefyV2AppMulticallAbi as unknown as AbiItem[], - chain.appMulticallContractAddress - ); - const results = ( - await Promise.all( - chunk(addresses, 200).map(chunk => mc.methods.getGovVaultMultiInfo(chunk).call()) - ) - ).flat() as AsWeb3Result[]; - - return results.map((result, i) => { - const config = configs[i]; - return result.rewards - .filter(([address, rate, finish]) => { - const token = addressBook[abChainId].tokenAddressMap[address]; - if (!token) { - console.warn(config.id, `${address} not found in ${abChainId} address book`); - return false; - } - - const periodFinish = parseInt(finish); - if (periodFinish <= now) { - console.warn( - config.id, - `${token.symbol} periodFinish ${periodFinish} (${new Date( - periodFinish * 1000 - )}) has past` - ); - return false; - } - - const rewardRate = new BigNumber(rate); - if (rewardRate.lte(0)) { - console.warn(config.id, `${token.symbol} rewardRate is zero`); - return false; - } - - return true; - }) - .map(([address]) => address); - }); -} - -async function main() { - const args = getRunArgs(); - if (args.help) { - console.log(args); - return; - } - - const allVaults = await getVaultsForChain(args.chain); - const govVaults = allVaults.filter(v => v.type === 'gov' && (v.version || 1) >= 2); - const activeRewards = await fetchActiveRewards(args.chain, govVaults); - const isGov = new Map(govVaults.map((v, i) => [v.id, activeRewards[i]])); - const idsModified = new Set(); - - const modified = allVaults.map(pool => { - const rewards = isGov.get(pool.id); - if (!rewards) { - return sortVaultKeys(pool); - } - - const existing = pool.earnedTokenAddresses || []; - if (existing.length === rewards.length && rewards.every(r => existing.includes(r))) { - return sortVaultKeys(pool); - } - - idsModified.add(pool.id); - return sortVaultKeys({ - ...pool, - earnedTokenAddresses: rewards, - }); - }); - - if (idsModified.size > 0) { - await saveJson(`./src/config/vault/${args.chain}.json`, modified, 'prettier'); - console.log(`[INFO] ${idsModified.size}/${isGov.size} v2 gov vaults updated`); - } -} - -main().catch(e => { - console.error(e); - process.exit(1); -}); diff --git a/scripts/validate/chain-types.ts b/scripts/validate/chain-types.ts new file mode 100644 index 0000000000..d208c1503a --- /dev/null +++ b/scripts/validate/chain-types.ts @@ -0,0 +1,15 @@ +import { AnyVaultWithData } from './vault/data-types'; + +type SummaryCounts = { + active: number; + eol: number; + paused: number; + total: number; +}; + +export type ChainValidateResult = { + success: boolean; + summary: { + [K in AnyVaultWithData['type']]: SummaryCounts; + } & { all: SummaryCounts }; +}; diff --git a/scripts/validate/options-types.ts b/scripts/validate/options-types.ts new file mode 100644 index 0000000000..5244977fa8 --- /dev/null +++ b/scripts/validate/options-types.ts @@ -0,0 +1,122 @@ +import { AddressBookChainId } from '../common/config'; +import { vaultValidators } from './vault/validators'; +import { AnyVaultWithData } from './vault/data-types'; +import { PointProviderConfig } from './point-provider/config-types'; +import { BeefyFinance } from 'blockchain-addressbook/build/types/beefyfinance'; +import { KeysOfType } from '../../src/features/data/utils/types-utils'; +import { VaultConfig } from '../../src/features/data/apis/config-types'; + +type VaultValidators = typeof vaultValidators; + +type SkipValidatorOptions = { + [K in keyof VaultValidators]?: { + [P in keyof VaultValidators[K]]?: { + chains?: Set; + }; + }; +}; + +type ExpectVaultMap = { + [vaultId: string]: { value: T; reason: string }; +}; + +type ExceptionsValidatorOptions = { + isFeeConfigCorrect?: ExpectVaultMap; + isHarvestOnDepositCorrect?: ExpectVaultMap; + isKeeperCorrect?: ExpectVaultMap; + isStrategyOwnerCorrect?: ExpectVaultMap; + isVaultOwnerCorrect?: ExpectVaultMap; + isRewardPoolOwnerCorrect?: ExpectVaultMap; + isFeeRecipientCorrect?: ExpectVaultMap; +}; + +type ChainAddressMapWithDefault = { + [chainId in AddressBookChainId]: { + all: Set; + default: BeefyFinance[TDefault]; + }; +}; + +type VaultConfigFilter = { + [K in keyof VaultConfig]?: Array; +}; + +type RequiredVaultConfig = { + [K in keyof VaultConfig]?: Array<{ + value: VaultConfig[K]; + matching: VaultConfigFilter; + }>; +}; + +export type VaultValidationOptions = { + /** Vault fields */ + fields: { + /** Require a specific field value on vaults matching the supplied filter */ + required?: RequiredVaultConfig; + /** What fields are no longer required (and why) */ + legacy: Record; + /** What fields are addresses that should be check summed */ + checksum: (keyof AnyVaultWithData)[]; + }; + /** All valid vault owners by chain */ + vaultOwners: ChainAddressMapWithDefault<'vaultOwner'>; + /** All valid strategy owners by chain */ + strategyOwners: ChainAddressMapWithDefault<'strategyOwner'>; + /** All valid reward pool/gov owners by chain */ + rewardPoolOwners: ChainAddressMapWithDefault<'devMultisig'>; + /** All valid strategy keepers by chain */ + strategyKeepers: ChainAddressMapWithDefault<'keeper'>; + /** assets[] validation options */ + assets?: { + missingAllowedForEolCreatedBefore?: number; + /** e.g. for gmx-arb-doge-usdc, DOGE does not exist on arbitrum so can not be added to address book */ + syntheticsNotInAddressBook?: { + [chainId in AddressBookChainId]?: Set; + }; + }; + /** Skip validators for specific chains/groups */ + skip?: SkipValidatorOptions; + /** Custom expected results for specific vaults / validators */ + exceptions?: ExceptionsValidatorOptions; +}; + +export type PointValidatorOptions = { + providerById: Map; +}; + +export type ValidationOptions = { + /** Options for vaults */ + vaults: VaultValidationOptions; + /** Options for Point Providers (points.json) */ + points: PointValidatorOptions; +}; + +export type ChainAddressSetOptionKeys = KeysOfType< + VaultValidationOptions, + ChainAddressMapWithDefault +>; + +export type BeefyRequiredAddressKeys = KeysOfType; + +export type OptionalChainBeefyAddressKeyMap = Partial< + Record[]> +>; + +export type VaultValidationOptionsBuilderInput = Omit< + VaultValidationOptions, + 'vaultOwners' | 'strategyOwners' | 'rewardPoolOwners' | 'strategyKeepers' +> & { + /** Use `exceptions` for addresses not in beefy platform in address book */ + additionalVaultOwners?: OptionalChainBeefyAddressKeyMap<'vaultOwner'>; + /** Use `exceptions` for addresses not in beefy platform in address book */ + additionalRewardPoolOwners?: OptionalChainBeefyAddressKeyMap<'devMultisig'>; + /** Use `exceptions` for addresses not in beefy platform in address book */ + additionalStrategyOwners?: OptionalChainBeefyAddressKeyMap<'strategyOwner'>; + /** Use `exceptions` for addresses not in beefy platform in address book */ + additionalKeepers?: OptionalChainBeefyAddressKeyMap<'keeper'>; +}; + +export type ValidationOptionsBuilderInput = Omit & { + vaults: VaultValidationOptionsBuilderInput; + // points: auto-generated +}; diff --git a/scripts/validate/options.ts b/scripts/validate/options.ts new file mode 100644 index 0000000000..e2029561f2 --- /dev/null +++ b/scripts/validate/options.ts @@ -0,0 +1,82 @@ +import { mapValues, omit } from 'lodash'; +import { addressBook } from 'blockchain-addressbook'; +import { + BeefyRequiredAddressKeys, + ChainAddressSetOptionKeys, + OptionalChainBeefyAddressKeyMap, + ValidationOptions, + ValidationOptionsBuilderInput, +} from './options-types'; +import pointProviders from '../../src/config/points.json'; + +export function buildOptions(input: ValidationOptionsBuilderInput): ValidationOptions { + return { + ...omit(input, ['vaults']), + vaults: { + ...omit(input.vaults, [ + 'vaultOwners', + 'rewardPoolOwners', + 'strategyOwners', + 'strategyKeepers', + ]), + // omit returns all keys as optional, so we need to re-add required keys + fields: input.vaults.fields, + // build owners from default + any additional + vaultOwners: buildBeefyAddresses( + 'vaultOwners', + 'vaultOwner', + input.vaults.additionalVaultOwners + ), + rewardPoolOwners: buildBeefyAddresses( + 'rewardPoolOwners', + 'devMultisig', + input.vaults.additionalRewardPoolOwners + ), + strategyOwners: buildBeefyAddresses( + 'strategyOwners', + 'strategyOwner', + input.vaults.additionalStrategyOwners + ), + strategyKeepers: buildBeefyAddresses( + 'strategyKeepers', + 'keeper', + input.vaults.additionalKeepers + ), + }, + points: { + providerById: buildPointProviders(), + }, + }; +} + +function buildBeefyAddresses< + TOption extends ChainAddressSetOptionKeys, + TDefault extends BeefyRequiredAddressKeys +>( + _option: TOption, + defaultKey: TDefault, + additional?: OptionalChainBeefyAddressKeyMap +): ValidationOptions['vaults'][TOption] { + const addresses = mapValues(addressBook, chain => ({ + all: new Set([chain.platforms.beefyfinance[defaultKey]]), + default: chain.platforms.beefyfinance[defaultKey], + })); + + if (!additional) { + return addresses; + } + + for (const [chainId, keys] of Object.entries(additional)) { + if (keys) { + for (const key of keys) { + addresses[chainId].all.add(addressBook[chainId].platforms.beefyfinance[key]); + } + } + } + + return addresses; +} + +function buildPointProviders(): ValidationOptions['points']['providerById'] { + return new Map(pointProviders.map(pointProvider => [pointProvider.id, pointProvider])); +} diff --git a/scripts/validate/platform/validators.ts b/scripts/validate/platform/validators.ts new file mode 100644 index 0000000000..6d2eb17858 --- /dev/null +++ b/scripts/validate/platform/validators.ts @@ -0,0 +1,85 @@ +import platforms from '../../../src/config/platforms.json'; +import { createFactory } from '../../common/factory'; +import type { PlatformType } from '../../../src/features/data/apis/config-types'; +import i18keys from '../../../src/locales/en/main.json'; +import { fileExists } from '../../common/files'; +import { pconsole } from '../../common/pconsole'; + +const getPlatformIds = createFactory(() => { + return new Set(platforms.map(platform => platform.id)); +}); + +export function doesPlatformIdExist(platformId: string) { + const lookup = getPlatformIds(); + return lookup.has(platformId); +} + +export async function isPlatformConfigValid(): Promise { + let success = true; + + // @dev hack to make sure all the platform types in PlatformType are present in the set + const validTypes = new Set( + Object.keys({ + amm: true, + alm: true, + bridge: true, + 'money-market': true, + perps: true, + 'yield-boost': true, + farm: true, + } satisfies Record) + ); + + // Check if valid types have i18n keys + for (const type of validTypes.keys()) { + const requiredKeys = [ + `Details-Platform-Type-Description-${type}`, + `Details-Platform-Type-${type}`, + ]; + for (const key of requiredKeys) { + if (!i18keys[key]) { + pconsole.error(`Missing i18n key "${key}" for platform type "${type}"`); + success = false; + } + } + } + + const platformsWithType = platforms.filter( + ( + platform + ): platform is Extract< + (typeof platforms)[number], + { + type: string; + } + > => !!platform.type + ); + await Promise.all( + platformsWithType.map(async platform => { + // Check type is valid + if (!validTypes.has(platform.type)) { + pconsole.error(`Platform ${platform.id}: Invalid type "${platform.type}"`); + success = false; + } + + // Platform image must exist if platform has a type + const possiblePaths = [ + `./src/images/platforms/${platform.id}.svg`, + `./src/images/platforms/${platform.id}.png`, + ]; + let found = false; + for (const path of possiblePaths) { + if (await fileExists(path)) { + found = true; + break; + } + } + if (!found) { + pconsole.error(`Platform ${platform.id}: Missing image: "${possiblePaths[0]}"`); + success = false; + } + }) + ); + + return success; +} diff --git a/scripts/validate/point-provider/config-types.ts b/scripts/validate/point-provider/config-types.ts new file mode 100644 index 0000000000..cd319c4687 --- /dev/null +++ b/scripts/validate/point-provider/config-types.ts @@ -0,0 +1,48 @@ +type TokenHoldingEligibility = { + type: 'token-holding'; + tokens: string[]; +}; + +type VaultWhitelistEligibility = { + type: 'vault-whitelist'; +}; + +type TokenByProviderEligibility = { + type: 'token-by-provider'; + tokenProviderId: string; + tokens: string[]; +}; + +type EarnedTokenNameRegexEligibility = { + type: 'earned-token-name-regex'; + regex: string; +}; + +type OnChainLpEligibility = { + type: 'on-chain-lp'; + chain: string; +}; + +type TokenOnPlatformEligibility = { + type: 'token-on-platform'; + platformId: string; + tokens: string[]; +}; + +type AnyPointProviderEligibility = + | TokenHoldingEligibility + | VaultWhitelistEligibility + | TokenByProviderEligibility + | EarnedTokenNameRegexEligibility + | OnChainLpEligibility + | TokenOnPlatformEligibility; + +type ToJsonEligibilityArray = T extends any ? (Omit & { type: string })[] : never; + +export type PointProviderConfig = { + id: string; + docs: string; + points: Array<{ id: string; name: string }>; + eligibility: ToJsonEligibilityArray; + accounting?: Array<{ id: string; role: string; url?: string; type?: string }>; +}; diff --git a/scripts/validate/required-updates-types.ts b/scripts/validate/required-updates-types.ts new file mode 100644 index 0000000000..6bc00735f2 --- /dev/null +++ b/scripts/validate/required-updates-types.ts @@ -0,0 +1,136 @@ +import { AddressBookChainId } from '../common/config'; +import { VaultGroups } from './vault/data-types'; +import { RequiredUpdates } from './required-updates'; +import { ValidationOptions, VaultValidationOptions } from './options-types'; +import { VaultConfig } from '../../src/features/data/apis/config-types'; + +type MakeRequiredUpdate = { + type: TType; + vaultId: string; + chainId: AddressBookChainId; + context: TContext; +}; + +type RemoveEmptyVaultUpdate = MakeRequiredUpdate< + 'remove-empty-vault', + { earnContractAddress: string } +>; +type EnableHarvestOnDepositUpdate = MakeRequiredUpdate< + 'enable-harvest-on-deposit', + { from: boolean | undefined } +>; +type FixFeeConfigAddressUpdate = MakeRequiredUpdate< + 'fix-fee-config-address', + { + from: string | undefined; + to: string | undefined; + strategyAddress: string; + strategyOwner: string | undefined; + } +>; +type FixFeeRecipientAddressUpdate = MakeRequiredUpdate< + 'fix-fee-recipient-address', + { + from: string | undefined; + to: string | undefined; + strategyAddress: string; + strategyOwner: string | undefined; + } +>; +type FixRewardPoolOwner = MakeRequiredUpdate< + 'fix-reward-pool-owner', + { + earnContractAddress: string; + from: string | undefined; + to: string; + } +>; +type FixVaultOwner = MakeRequiredUpdate< + 'fix-vault-owner', + { + earnContractAddress: string; + from: string | undefined; + to: string; + } +>; +type FixStrategyOwner = MakeRequiredUpdate< + 'fix-strategy-owner', + { + from: string | undefined; + to: string | undefined; + strategyAddress: string; + } +>; +type FixStrategyKeeper = MakeRequiredUpdate< + 'fix-strategy-keeper', + { + from: string | undefined; + to: string | undefined; + strategyAddress: string; + strategyOwner: string | undefined; + } +>; + +type FixVaultField = MakeRequiredUpdate< + 'fix-vault-field', + { + field: keyof VaultConfig; + from: any; + to: VaultConfig[keyof VaultConfig]; + } +>; + +export type AnyRequiredUpdate = + | RemoveEmptyVaultUpdate + | EnableHarvestOnDepositUpdate + | FixFeeConfigAddressUpdate + | FixFeeRecipientAddressUpdate + | FixRewardPoolOwner + | FixVaultOwner + | FixStrategyOwner + | FixStrategyKeeper + | FixVaultField; + +export type UpdateTypeToContext = Extract< + AnyRequiredUpdate, + { + type: TType; + } +>['context']; + +export type UpdatesByType = { + [K in AnyRequiredUpdate['type']]?: { + __type: K; + updates: Extract[]; + }; +}; + +export type AddRequiredUpdateFn = ( + vaultId: string, + type: TType, + context: UpdateTypeToContext +) => false; + +export type GlobalValidateContext = { + verbose: boolean; + options: ValidationOptions; + seenVaultIds: Set; + requiredUpdates: RequiredUpdates; +}; + +export type VaultValidateContext = { + globalContext: GlobalValidateContext; + chainId: AddressBookChainId; + vaults: VaultGroups; + addRequiredUpdate: AddRequiredUpdateFn; + seenEarnedTokens: Set; + seenEarnedTokenAddresses: Set; + vaultOwners: VaultValidationOptions['vaultOwners'][AddressBookChainId]; + rewardPoolOwners: VaultValidationOptions['rewardPoolOwners'][AddressBookChainId]; + strategyOwners: VaultValidationOptions['strategyOwners'][AddressBookChainId]; + strategyKeepers: VaultValidationOptions['strategyKeepers'][AddressBookChainId]; + /** The current fee recipient address that should be set */ + feeRecipient: string; + /** The current fee config address that should be set */ + feeConfig: string | undefined; // undefined for heco, fuse, one +}; diff --git a/scripts/validate/required-updates.ts b/scripts/validate/required-updates.ts new file mode 100644 index 0000000000..cc573df864 --- /dev/null +++ b/scripts/validate/required-updates.ts @@ -0,0 +1,186 @@ +import { AddressBookChainId } from '../common/config'; +import { get } from 'lodash'; +import { AnyRequiredUpdate, UpdatesByType, UpdateTypeToContext } from './required-updates-types'; +import chalk from 'chalk'; +import { pconsole } from '../common/pconsole'; + +export class RequiredUpdates { + protected readonly updates: Map = new Map(); + + public add( + chainId: AddressBookChainId, + vaultId: string, + type: TType, + context: UpdateTypeToContext + ) { + const update = { type, vaultId, chainId, context } as AnyRequiredUpdate; + const chainUpdates = this.updates.get(chainId); + if (chainUpdates) { + chainUpdates.push(update); + } else { + this.updates.set(chainId, [update]); + } + } + + public hasAny() { + return this.updates.size > 0; + } + + public hasChain(chainId: AddressBookChainId) { + return this.updates.has(chainId); + } + + public makeChainAddFunction(chainId: AddressBookChainId) { + return ( + vaultId: string, + type: TType, + context: UpdateTypeToContext + ) => { + this.add(chainId, vaultId, type, context); + return false as const; + }; + } + + public prettyPrint() { + if (!this.hasAny()) { + return; + } + + for (const [chainId, updates] of this.updates) { + this.printChainHeader(chainId); + const updatesByType = updates.reduce((acc, update) => { + acc[update.type] ??= { + __type: update.type, + updates: [], + }; + acc[update.type].updates.push(update); + return acc; + }, {} as { __type: AnyRequiredUpdate['type']; updates: AnyRequiredUpdate[] }) as UpdatesByType; + + for (const byType of Object.values(updatesByType)) { + if (!byType || byType.updates.length === 0) { + continue; + } + + switch (byType.__type) { + case 'remove-empty-vault': { + this.printTypeHeader('Remove Empty Vaults', byType.updates.length); + this.printTable(byType.updates, ['vaultId', 'context.earnContractAddress']); + break; + } + case 'enable-harvest-on-deposit': { + this.printTypeHeader('Enable Harvest on Deposit', byType.updates.length); + this.printTable(byType.updates, ['vaultId', 'context.from']); + break; + } + case 'fix-fee-config-address': { + this.printTypeHeader('Fix Fee Config Address', byType.updates.length); + this.printTable(byType.updates, [ + 'vaultId', + 'context.strategyOwner', + 'context.strategyAddress', + 'context.from', + 'context.to', + ]); + // byType.updates.forEach(u => + // console.log(`'${u.vaultId}': { value: '${u.context.from}', reason: '' },`) + // ); + break; + } + case 'fix-fee-recipient-address': { + this.printTypeHeader('Fix Fee Recipient Address', byType.updates.length); + this.printTable(byType.updates, [ + 'vaultId', + 'context.strategyOwner', + 'context.strategyAddress', + 'context.from', + 'context.to', + ]); + break; + } + case 'fix-reward-pool-owner': { + this.printTypeHeader('Fix Reward Pool Owner', byType.updates.length); + this.printTable(byType.updates, [ + 'vaultId', + 'context.earnContractAddress', + 'context.from', + 'context.to', + ]); + break; + } + case 'fix-vault-owner': { + this.printTypeHeader('Fix Vault Owner', byType.updates.length); + this.printTable(byType.updates, [ + 'vaultId', + 'context.earnContractAddress', + 'context.from', + 'context.to', + ]); + byType.updates.forEach(u => + console.log(`'${u.vaultId}': { value: '${u.context.from}', reason: '' },`) + ); + break; + } + case 'fix-strategy-owner': { + this.printTypeHeader('Fix Strategy Owner', byType.updates.length); + this.printTable(byType.updates, [ + 'vaultId', + 'context.strategyAddress', + 'context.from', + 'context.to', + ]); + byType.updates.forEach(u => + console.log(`'${u.vaultId}': { value: '${u.context.from}', reason: '' },`) + ); + break; + } + case 'fix-strategy-keeper': { + this.printTypeHeader('Fix Strategy Keeper', byType.updates.length); + this.printTable(byType.updates, [ + 'vaultId', + 'context.strategyOwner', + 'context.strategyAddress', + 'context.from', + 'context.to', + ]); + break; + } + case 'fix-vault-field': { + this.printTypeHeader('Fix Vault Field', byType.updates.length); + this.printTable(byType.updates, [ + 'vaultId', + 'context.field', + 'context.from', + 'context.to', + ]); + break; + } + default: { + // @ts-expect-error -- switch should be exhaustive + throw new Error(`Unknown update type: ${byType.__type}`); + } + } + } + } + } + + protected printChainHeader(chainId: AddressBookChainId) { + console.log(''); + console.log(chalk.bold(chainId)); + } + + protected printTypeHeader(title: string, count: number) { + console.log(`${chalk.dim(`${count} x`)} ${title}`); + } + + protected printTable>(data: T[], paths: string[]) { + const picked = data.map(row => + Object.fromEntries(paths.map(path => [this.pathToKey(path), get(row, path)])) + ); + console.table(picked); + } + + protected pathToKey(path: string): string { + return path.replace(/^context\./, ''); + } +} diff --git a/scripts/validate/strategy-type/validators.ts b/scripts/validate/strategy-type/validators.ts new file mode 100644 index 0000000000..bcb5453394 --- /dev/null +++ b/scripts/validate/strategy-type/validators.ts @@ -0,0 +1,37 @@ +import riskStrings from '../../../src/locales/en/risks.json'; +import { createFactory } from '../../common/factory'; + +const regex = + /^StrategyDescription-(?:(?gov|cowcentrated|standard)-)?(?:(?gov|cowcentrated|standard)-)?(?.+)$/; + +type VaultType = 'standard' | 'gov' | 'cowcentrated'; + +const getVaultTypeToStrategyTypeIds = createFactory(() => { + return Object.keys(riskStrings).reduce( + (acc, key) => { + const match = key.match(regex); + if (!match) { + return acc; + } + + const id = match.groups?.id; + if (!id) { + return acc; + } + + const type = (match.groups?.subtype || match.groups?.type || 'standard') as VaultType; + acc[type].add(id); + return acc; + }, + { + standard: new Set(), + gov: new Set(), + cowcentrated: new Set(), + } + ); +}); + +export function doesStrategyTypeIdExistForVaultType(strategyId: string, vaultType: VaultType) { + const lookup = getVaultTypeToStrategyTypeIds(); + return lookup[vaultType].has(strategyId); +} diff --git a/scripts/validate/vault/config-types.ts b/scripts/validate/vault/config-types.ts new file mode 100644 index 0000000000..bffd8d02b3 --- /dev/null +++ b/scripts/validate/vault/config-types.ts @@ -0,0 +1,11 @@ +import type { VaultConfig } from '../../../src/features/data/apis/config-types'; + +export type VaultStandardConfig = Omit & { type: 'standard' }; +export type VaultGovConfig = Omit & { type: 'gov' }; +export type VaultCowcentratedConfig = Omit & { type: 'cowcentrated' }; + +export type VaultConfigsByType = { + standard?: VaultStandardConfig[]; + gov?: VaultGovConfig[]; + cowcentrated?: VaultCowcentratedConfig[]; +}; diff --git a/scripts/validate/vault/data-types.ts b/scripts/validate/vault/data-types.ts new file mode 100644 index 0000000000..343dd31fa5 --- /dev/null +++ b/scripts/validate/vault/data-types.ts @@ -0,0 +1,55 @@ +import { VaultCowcentratedConfig, VaultGovConfig, VaultStandardConfig } from './config-types'; + +export type VaultData = { + vaultWant: string; + strategy: string; + vaultOwner: string; + totalSupply: string; +}; + +export type StrategyData = { + keeper: string | undefined; + feeRecipient: string | undefined; + beefyFeeConfig: string | undefined; + strategyOwner: string | undefined; + harvestOnDeposit: boolean | undefined; +}; + +export type StandardWithData = VaultStandardConfig & VaultData & StrategyData; + +export type RewardPoolData = { + rewardPoolOwner: string | undefined; + totalSupply: string | undefined; +}; + +export type GovWithData = VaultGovConfig & RewardPoolData; + +export type CowcentratedVaultData = VaultData & { + vaultWant: string; + wants: [string, string]; +}; + +export type CowcentratedOracleData = { + subOracle0: string | undefined; + subOracle1: string | undefined; + subOracle0FromZero: string | undefined; + subOracle1FromZero: string | undefined; +}; + +export type CowcentratedWithData = VaultCowcentratedConfig & + CowcentratedVaultData & + StrategyData & + CowcentratedOracleData; + +export type AnyVaultWithData = StandardWithData | GovWithData | CowcentratedWithData; + +export type VaultGroups = { + all: AnyVaultWithData[]; + allStandard: StandardWithData[]; + allGov: GovWithData[]; + allCowcentrated: CowcentratedWithData[]; + baseStandard: StandardWithData[]; + baseGov: GovWithData[]; + cowcentratedStandard: StandardWithData[]; + cowcentratedGov: GovWithData[]; +}; diff --git a/scripts/validate/vault/data.ts b/scripts/validate/vault/data.ts new file mode 100644 index 0000000000..7c45e97904 --- /dev/null +++ b/scripts/validate/vault/data.ts @@ -0,0 +1,267 @@ +import { AddressBookChainId, chainRpcs, getVaultsForChain } from '../../common/config'; +import partition from 'lodash/partition'; +import Web3 from 'web3'; +import { MultiCall } from 'eth-multicall'; +import { addressBook } from 'blockchain-addressbook'; +import { StandardVaultAbi } from '../../../src/config/abi/StandardVaultAbi'; +import type { AbiItem } from 'web3-utils'; +import { withRetries } from '../../common/utils'; +import strategyABI from '../../../src/config/abi/strategy.json'; +import { createCachedFactory } from '../../common/factory'; +import { groupBy } from 'lodash'; +import { BeefyCowcentratedLiquidityVaultAbi } from '../../../src/config/abi/BeefyCowcentratedLiquidityVaultAbi'; +import { ZERO_ADDRESS } from '../../../src/helpers/addresses'; +import { BeefyOracleAbi } from '../../common/abi/BeefyOracleAbi'; +import { + CowcentratedOracleData, + CowcentratedVaultData, + CowcentratedWithData, + GovWithData, + RewardPoolData, + StandardWithData, + StrategyData, + VaultData, + VaultGroups, +} from './data-types'; +import { + VaultConfigsByType, + VaultCowcentratedConfig, + VaultGovConfig, + VaultStandardConfig, +} from './config-types'; +import { pconsole } from '../../common/pconsole'; + +function merge(a: A, b: B): A & B { + return { ...a, ...b }; +} + +const getWeb3 = createCachedFactory( + (chainId: AddressBookChainId) => new Web3(chainRpcs[chainId]), + chainId => chainId +); + +const getMultiCall = createCachedFactory( + (chainId: AddressBookChainId) => + new MultiCall(getWeb3(chainId), addressBook[chainId].platforms.beefyfinance.multicall), + chainId => chainId +); + +export async function fetchVaults(chainId: AddressBookChainId): Promise { + const configs = await getVaultsForChain(chainId); + const byType = groupBy(configs, vault => vault.type || 'standard') as VaultConfigsByType; + const [allStandard, allGov, allCowcentrated] = await Promise.all([ + fetchStandard(chainId, byType.standard || []), + fetchGovs(chainId, byType.gov || []), + fetchCowcentrated(chainId, byType.cowcentrated || []), + ]); + const clmAddresses = new Set(allCowcentrated.map(vault => vault.earnContractAddress)); + const [cowcentratedStandard, baseStandard] = partition( + allStandard, + pool => pool.tokenAddress && clmAddresses.has(pool.tokenAddress) + ); + const [cowcentratedGov, baseGov] = partition( + allGov, + pool => pool.tokenAddress && clmAddresses.has(pool.tokenAddress) + ); + + const vaults: VaultGroups = { + all: [...allStandard, ...allGov, ...allCowcentrated], + allStandard, + allGov, + allCowcentrated, + cowcentratedGov, + baseGov, + cowcentratedStandard, + baseStandard, + }; + + return vaults; +} + +async function fetchGovs( + chainId: AddressBookChainId, + configs: VaultGovConfig[] +): Promise { + if (configs.length === 0) { + return []; + } + const datas = await fetchRewardPoolData( + chainId, + configs.map(config => config.earnContractAddress) + ); + return configs.map((config, i) => merge(config, datas[i])); +} + +const fetchRewardPoolData = makeDataFetcher( + 'reward pool', + StandardVaultAbi as unknown as AbiItem[], + { + rewardPoolOwner: { method: 'owner' }, + totalSupply: { method: 'totalSupply' }, + } +); + +async function fetchStandard( + chainId: AddressBookChainId, + configs: VaultStandardConfig[] +): Promise { + if (configs.length === 0) { + return []; + } + const vaultDatas = await fetchStandardVaultData( + chainId, + configs.map(config => config.earnContractAddress) + ); + const strategyDatas = await fetchStrategyData( + chainId, + vaultDatas.map(vault => vault.strategy) + ); + return configs.map((config, i) => merge(merge(config, vaultDatas[i]), strategyDatas[i])); +} + +const fetchStandardVaultData = makeDataFetcher( + 'vault', + StandardVaultAbi as unknown as AbiItem[], + { + strategy: { method: 'strategy' }, + vaultOwner: { method: 'owner' }, + totalSupply: { method: 'totalSupply' }, + vaultWant: { method: 'want' }, + } +); + +const fetchStrategyData = makeDataFetcher('strategy', strategyABI as AbiItem[], { + keeper: { method: 'keeper' }, + feeRecipient: { method: 'beefyFeeRecipient' }, + beefyFeeConfig: { method: 'beefyFeeConfig' }, + strategyOwner: { method: 'owner' }, + harvestOnDeposit: { method: 'harvestOnDeposit' }, +}); + +async function fetchCowcentrated( + chainId: AddressBookChainId, + configs: VaultCowcentratedConfig[] +): Promise { + if (configs.length === 0) { + return []; + } + const beefyOracleAddress = addressBook[chainId].platforms.beefyfinance.beefyOracle; + if (!beefyOracleAddress) { + throw new Error(`Missing Beefy Oracle address for chain ${chainId}`); + } + const vaultDatas = await fetchCowcentratedVaultData( + chainId, + configs.map(config => config.earnContractAddress) + ); + const [strategyDatas, cowcentratedDatas] = await Promise.all([ + fetchStrategyData( + chainId, + vaultDatas.map(vault => vault.strategy) + ), + fetchCowcentratedOracleData( + chainId, + vaultDatas.map(vaultData => ({ + address: beefyOracleAddress, + wants: vaultData.wants, + })) + ), + ]); + + return configs.map((config, i) => + merge(merge(merge(config, vaultDatas[i]), strategyDatas[i]), cowcentratedDatas[i]) + ); +} + +const fetchCowcentratedVaultData = makeDataFetcher( + 'cowcentrated vault', + BeefyCowcentratedLiquidityVaultAbi as unknown as AbiItem[], + { + strategy: { method: 'strategy' }, + vaultOwner: { method: 'owner' }, + totalSupply: { method: 'totalSupply' }, + wants: { method: 'wants' }, + vaultWant: { method: 'want' }, + } +); + +type FetchCowcentratedOracleDataInput = DataInputBaseObject & { wants: [string, string] }; +const fetchCowcentratedOracleData = makeDataFetcher< + CowcentratedOracleData, + FetchCowcentratedOracleDataInput +>('cowcentrated oracle', BeefyOracleAbi as unknown as AbiItem[], { + subOracle0: { method: 'subOracle', args: input => [input.wants[0]] }, + subOracle1: { method: 'subOracle', args: input => [input.wants[1]] }, + subOracle0FromZero: { method: 'subOracle', args: input => [ZERO_ADDRESS, input.wants[0]] }, + subOracle1FromZero: { method: 'subOracle', args: input => [ZERO_ADDRESS, input.wants[1]] }, +}); + +type DataInputBaseObject = { + address: string; +}; +type DataInput = string | DataInputBaseObject; +type DataArgsFn = (input: TInput) => any[]; +type DataArgs = DataArgsFn | any[]; +type DataCalls, TInput extends DataInput = string> = { + [K in keyof TData]: { method: string; args?: DataArgs }; +}; + +function makeDataFetcher, TInput extends DataInput = string>( + name: string, + abi: AbiItem[], + dataCalls: DataCalls +) { + const keys = Object.keys(dataCalls); + + return async function ( + chainId: AddressBookChainId, + inputs: TInput[], + retries = 5 + ): Promise { + try { + const web3 = getWeb3(chainId); + const multicall = getMultiCall(chainId); + + const calls = inputs.map(input => { + const contract = new web3.eth.Contract( + abi, + typeof input === 'string' ? input : input.address + ); + return Object.fromEntries( + keys.map(key => { + const call = dataCalls[key]; + const args = typeof call.args === 'function' ? call.args(input) : call.args || []; + if (!contract.methods[call.method]) { + throw new Error(`Method ${call.method} not found in abi for ${name}`); + } + return [key, contract.methods[call.method](...args)]; + }) + ); + }); + + return withRetries( + async () => { + const [results] = await multicall.all([calls]); + + return inputs.map((_, i) => { + return Object.fromEntries( + keys.map(key => { + return [key, results[i][key]]; + }) + ) as TData; + }); + }, + { + delay: 1_000, + retries, + onRetry: (e, attempt) => { + pconsole.warn( + `Retrying ${chainId} ${name} data fetch (${attempt + 1}/${retries}), ${e.message}` + ); + }, + } + ); + } catch (e) { + throw new Error(`Failed to fetch ${name} data for ${chainId}`, { cause: e }); + } + }; +} diff --git a/scripts/validate/vault/validators-types.ts b/scripts/validate/vault/validators-types.ts new file mode 100644 index 0000000000..11a8d1fe60 --- /dev/null +++ b/scripts/validate/vault/validators-types.ts @@ -0,0 +1,11 @@ +import { VaultValidateContext } from '../required-updates-types'; +import { AnyVaultWithData, VaultGroups } from './data-types'; + +export type VaultValidatorFn = ( + vault: T, + context: VaultValidateContext +) => boolean; + +export type VaultValidatorsCheck = { + [K in keyof VaultGroups]?: Record>; +}; diff --git a/scripts/validate/vault/validators.ts b/scripts/validate/vault/validators.ts new file mode 100644 index 0000000000..bbaa00dfb0 --- /dev/null +++ b/scripts/validate/vault/validators.ts @@ -0,0 +1,853 @@ +import { addressBook } from 'blockchain-addressbook'; +import BigNumber from 'bignumber.js'; +import { VaultValidateContext } from '../required-updates-types'; +import { isDefined, isValidChecksumAddress, ZERO_ADDRESS } from '../../common/utils'; +import { addressBookToAppId } from '../../common/config'; +import { VaultValidatorsCheck } from './validators-types'; +import { + AnyVaultWithData, + CowcentratedWithData, + GovWithData, + StandardWithData, +} from './data-types'; +import { doesStrategyTypeIdExistForVaultType } from '../strategy-type/validators'; +import { doesPlatformIdExist } from '../platform/validators'; +import { uniq } from 'lodash'; +import { pconsole } from '../../common/pconsole'; +import { VaultConfig } from '../../../src/features/data/apis/config-types'; + +export const vaultValidators = { + all: { + isIdValid, + isTypeValid, + isEarnedTokenValid, + isStrategyTypeIdValid, + isPlatformIdValid, + isOracleValid, + isOracleIdValid, + isTokenProviderIdValid, + isCreatedAtValid, + isUpdatedAtValid, + isRetiredAtValid, + isPausedAtValid, + isNetworkValid, + areAssetsValid, + doesConfigHaveNoOldFields, + areAddressesValid, + arePointsStructureIdsValid, + isRequiredVaultConfigSatisfied, + }, + allCowcentrated: { + isVaultEarnedTokenAddressValid, + isEarnedTokenContractAddressSame, + isNotZeroSupply, + isVaultOwnerCorrect, + isStrategyOwnerCorrect, + isStrategyKeeperCorrect, + isFeeRecipientCorrect, + isFeeConfigCorrect, + doesCowcentratedHavePool, + doesBeefyOracleHaveEntriesForWants, + isPlatformIdBeefy, + }, + allStandard: { + isVaultEarnedTokenAddressValid, + isEarnedTokenContractAddressSame, + isNotZeroSupply, + isVaultOwnerCorrect, + isStrategyOwnerCorrect, + isStrategyKeeperCorrect, + isFeeRecipientCorrect, + isFeeConfigCorrect, + isHarvestOnDepositCorrect, + }, + allGov: { + isRewardPoolOwnerCorrect, + }, + baseGov: { + isGovEarnedTokenAddressValid, + }, + cowcentratedGov: { + isPlatformIdSameAsCowcentratedTokenProviderId, + isTokenProviderIdSameAsCowcentratedTokenProviderId, + isStrategyTypeIdSameAsCowcentratedStrategyTypeId, + }, + cowcentratedStandard: { + isPlatformIdSameAsCowcentratedTokenProviderId, + isTokenProviderIdSameAsCowcentratedTokenProviderId, + isStrategyTypeIdSameAsCowcentratedStrategyTypeId, + }, +} as const satisfies VaultValidatorsCheck; + +const validVaultTypes = new Set(['standard', 'gov', 'cowcentrated']); + +function isTypeValid(vault: AnyVaultWithData) { + if (!validVaultTypes.has(vault.type)) { + pconsole.error(`${vault.id}: Invalid type "${vault.type}"`); + return false; + } + + return true; +} + +function isStrategyKeeperCorrect( + vault: StandardWithData | CowcentratedWithData, + { addRequiredUpdate, strategyKeepers, globalContext }: VaultValidateContext +) { + if (vault.status === 'eol') { + return true; + } + + const isExpected = vault.keeper && strategyKeepers.all.has(vault.keeper); + const exception = globalContext.options?.vaults.exceptions?.isKeeperCorrect?.[vault.id]; + const isException = exception && vault.keeper === exception.value; + + if (isExpected && exception && vault.keeper !== exception.value) { + pconsole.warn(`Vault ${vault.id}: Has an unneeded exception for isKeeperCorrect`); + } + + if (!isExpected && !isException) { + pconsole.error( + `Vault ${vault.id}: Should update keeper. From: ${vault.keeper} To: ${strategyKeepers.default}` + ); + + return addRequiredUpdate(vault.id, 'fix-strategy-keeper', { + strategyAddress: vault.strategy, + strategyOwner: vault.strategyOwner, + from: vault.keeper, + to: strategyKeepers.default, + }); + } + + return true; +} + +function isStrategyOwnerCorrect( + vault: StandardWithData | CowcentratedWithData, + { addRequiredUpdate, strategyOwners, globalContext }: VaultValidateContext +) { + const isExpected = vault.strategyOwner && strategyOwners.all.has(vault.strategyOwner); + const exception = globalContext.options?.vaults.exceptions?.isStrategyOwnerCorrect?.[vault.id]; + const isException = exception && vault.strategyOwner === exception.value; + + if (isExpected && exception && vault.strategyOwner !== exception.value) { + pconsole.warn(`Vault ${vault.id}: Has an unneeded exception for isStrategyOwnerCorrect`); + } + + if (!isExpected && !isException) { + pconsole.error( + `Vault ${vault.id}: Should update strategyOwner owner. From: ${vault.strategyOwner} To: ${strategyOwners.default}` + ); + + return addRequiredUpdate(vault.id, 'fix-strategy-owner', { + strategyAddress: vault.strategy, + from: vault.strategyOwner, + to: strategyOwners.default, + }); + } + + return true; +} + +function isVaultOwnerCorrect( + vault: StandardWithData | CowcentratedWithData, + { addRequiredUpdate, vaultOwners, globalContext }: VaultValidateContext +) { + const isExpected = vault.vaultOwner && vaultOwners.all.has(vault.vaultOwner); + const exception = globalContext.options?.vaults.exceptions?.isVaultOwnerCorrect?.[vault.id]; + const isException = exception && vault.vaultOwner === exception.value; + + if (isExpected && exception && vault.vaultOwner !== exception.value) { + pconsole.warn(`Vault ${vault.id}: has an unneeded exception for isVaultOwnerCorrect`); + } + + if (!isExpected && !isException) { + pconsole.error( + `Vault ${vault.id}: should update vault owner. From: ${vault.vaultOwner} To: ${vaultOwners.default}` + ); + + return addRequiredUpdate(vault.id, 'fix-vault-owner', { + earnContractAddress: vault.earnContractAddress, + from: vault.vaultOwner, + to: vaultOwners.default, + }); + } + + return true; +} + +function isRewardPoolOwnerCorrect( + vault: GovWithData, + { addRequiredUpdate, rewardPoolOwners, globalContext }: VaultValidateContext +) { + const isExpected = vault.rewardPoolOwner && rewardPoolOwners.all.has(vault.rewardPoolOwner); + const exception = globalContext.options?.vaults.exceptions?.isRewardPoolOwnerCorrect?.[vault.id]; + const isException = exception && vault.rewardPoolOwner === exception.value; + + if (isExpected && exception && vault.rewardPoolOwner !== exception.value) { + pconsole.warn(`Vault ${vault.id}: has an unneeded exception for isRewardPoolOwnerCorrect`); + } + + if (!isExpected && !isException) { + pconsole.error( + `Reward Vault ${vault.id}: should update owner. From: ${vault.rewardPoolOwner} To: ${rewardPoolOwners.default}` + ); + + return addRequiredUpdate(vault.id, 'fix-reward-pool-owner', { + earnContractAddress: vault.earnContractAddress, + from: vault.rewardPoolOwner, + to: rewardPoolOwners.default, + }); + } + + return true; +} + +function isFeeRecipientCorrect( + vault: StandardWithData | CowcentratedWithData, + { addRequiredUpdate, feeRecipient, globalContext }: VaultValidateContext +) { + if (vault.status === 'eol') { + return true; + } + + const isExpected = vault.feeRecipient && vault.feeRecipient === feeRecipient; + const exception = globalContext.options?.vaults.exceptions?.isFeeRecipientCorrect?.[vault.id]; + const isException = exception && vault.feeRecipient === exception.value; + + if (isExpected && exception && vault.feeRecipient !== exception.value) { + pconsole.warn(`Vault ${vault.id}: has an unneeded exception for isFeeRecipientCorrect`); + } + + if (!isExpected && !isException) { + pconsole.error( + `Vault ${vault.id}: should update beefy fee recipient. From: ${vault.feeRecipient} To: ${feeRecipient}` + ); + + return addRequiredUpdate(vault.id, 'fix-fee-recipient-address', { + from: vault.feeRecipient, + to: feeRecipient, + strategyAddress: vault.strategy, + strategyOwner: vault.strategyOwner, + }); + } + + return true; +} + +function isFeeConfigCorrect( + vault: StandardWithData | CowcentratedWithData, + { addRequiredUpdate, feeConfig, globalContext }: VaultValidateContext +) { + if (vault.status === 'eol') { + return true; + } + + const isExpected = vault.beefyFeeConfig === feeConfig; + const exception = globalContext.options?.vaults.exceptions?.isFeeConfigCorrect?.[vault.id]; + const isException = exception && vault.beefyFeeConfig === exception.value; + + if (isExpected && exception && vault.beefyFeeConfig !== exception.value) { + pconsole.warn(`Vault ${vault.id}: has an unneeded exception for isFeeConfigCorrect`); + } + + if (!isExpected && !isException) { + pconsole.error( + `Vault ${vault.id}: should update beefy fee config. From: ${vault.beefyFeeConfig} To: ${feeConfig}` + ); + + return addRequiredUpdate(vault.id, 'fix-fee-config-address', { + from: vault.beefyFeeConfig, + to: feeConfig, + strategyAddress: vault.strategy, + strategyOwner: vault.strategyOwner, + }); + } + + return true; +} + +function isHarvestOnDepositCorrect( + vault: StandardWithData | CowcentratedWithData, + { addRequiredUpdate, globalContext }: VaultValidateContext +) { + if (vault.status === 'eol') { + return true; + } + + const isExpected = vault.harvestOnDeposit === true; + const exception = globalContext.options?.vaults.exceptions?.isHarvestOnDepositCorrect?.[vault.id]; + const isException = exception && vault.harvestOnDeposit === exception.value; + + if (isExpected && exception && vault.harvestOnDeposit !== exception.value) { + pconsole.warn(`Vault ${vault.id}: has an unneeded exception for isHarvestOnDepositCorrect`); + } + + if (!isExpected && !isException) { + pconsole.error( + `Vault ${vault.id}: should update to harvest on deposit. From: ${vault.harvestOnDeposit} To: true` + ); + + return addRequiredUpdate(vault.id, 'enable-harvest-on-deposit', { + from: vault.harvestOnDeposit, + }); + } + + return true; +} + +function doesBeefyOracleHaveEntriesForWants(vault: CowcentratedWithData) { + let success = true; + + const { 0: token0, 1: token1 } = vault.wants; + const token0HasOracle = + (vault.subOracle0 && vault.subOracle0[0] && vault.subOracle0[0] !== ZERO_ADDRESS) || + (vault.subOracle0FromZero && + vault.subOracle0FromZero[0] && + vault.subOracle0FromZero[0] !== ZERO_ADDRESS); + const token1HasOracle = + (vault.subOracle1 && vault.subOracle1[0] && vault.subOracle1[0] !== ZERO_ADDRESS) || + (vault.subOracle1FromZero && + vault.subOracle1FromZero[0] && + vault.subOracle1FromZero[0] !== ZERO_ADDRESS); + + if (!token0HasOracle) { + pconsole.error(`${vault.id}: Beefy oracle has no subOracle entry for token0 ${token0}`); + success = false; + } + if (!token1HasOracle) { + pconsole.error(`${vault.id}: Beefy oracle has no subOracle entry for token1 ${token1}`); + success = false; + } + + return success; +} + +function isIdValid(vault: AnyVaultWithData, { globalContext }: VaultValidateContext) { + if (!vault.id) { + pconsole.error(`[ERROR]: Vault id missing`); + return false; + } + + if (globalContext.seenVaultIds.has(vault.id)) { + pconsole.error(`${vault.id}: Duplicate vault id`); + return false; + } + + globalContext.seenVaultIds.add(vault.id); + + // TODO: make stricter add an exceptions for old vaults + if (!vault.id.match(/^[a-zA-Z0-9-.+\[\]_=]+$/)) { + pconsole.error( + `${vault.id}: Vault id invalid, should only contain letters, numbers, hyphens, plus, underscore, square brackets and periods.` + ); + return false; + } + + return true; +} + +function isEarnedTokenValid(vault: AnyVaultWithData, { seenEarnedTokens }: VaultValidateContext) { + if (!vault.earnedToken) { + pconsole.error(`${vault.id}: Missing earnedToken`); + return false; + } + + if (seenEarnedTokens.has(vault.earnedToken)) { + pconsole.error(`${vault.id}: Duplicate earnedToken "${vault.earnedToken}"`); + return false; + } + + seenEarnedTokens.add(vault.id); + + return true; +} + +function isVaultEarnedTokenAddressValid( + vault: StandardWithData | CowcentratedWithData, + { seenEarnedTokenAddresses }: VaultValidateContext +) { + if (!vault.earnedTokenAddress) { + pconsole.error(`${vault.id}: Missing earnedTokenAddress`); + return false; + } + + if (seenEarnedTokenAddresses.has(vault.earnedTokenAddress)) { + pconsole.error(`${vault.id}: Duplicate earnedTokenAddress "${vault.earnedTokenAddress}"`); + return false; + } + + seenEarnedTokenAddresses.add(vault.id); + + return true; +} + +function isGovEarnedTokenAddressValid(vault: GovWithData) { + const version = vault.version || 1; + + if (version === 1) { + if (!vault.earnedTokenAddress) { + pconsole.error(`${vault.id}: ${vault.type} version ${version} missing earnedTokenAddress`); + return false; + } + } else { + if (!vault.earnedTokenAddress && !vault.earnedTokenAddresses) { + pconsole.error( + `${vault.id}: ${vault.type} version ${version} missing earnedTokenAddress or earnedTokenAddresses` + ); + return false; + } + } + + return true; +} + +function isEarnedTokenContractAddressSame(vault: AnyVaultWithData) { + if (vault.earnedTokenAddress !== vault.earnContractAddress) { + pconsole.error( + `${vault.id}: earnedTokenAddress not same as earnContractAddress: ${vault.earnedTokenAddress} != ${vault.earnContractAddress}` + ); + return false; + } + + return true; +} + +function isStrategyTypeIdValid(vault: AnyVaultWithData) { + if (!vault.strategyTypeId) { + pconsole.error(`${vault.id}: strategyTypeId missing ${vault.type} strategy type`); + return false; + } else if (!doesStrategyTypeIdExistForVaultType(vault.strategyTypeId, vault.type)) { + pconsole.error( + `${vault.id}: strategyTypeId invalid, "StrategyDescription-${vault.type}-${vault.strategyTypeId}" not present in locales/en/risks.json` + ); + return false; + } + + return true; +} + +function isPlatformIdValid(vault: AnyVaultWithData) { + if (!vault.platformId) { + pconsole.error(`${vault.id}: platformId missing ${vault.type} platform; see platforms.json`); + return false; + } else if (!doesPlatformIdExist(vault.platformId)) { + pconsole.error(`${vault.id}: platformId "${vault.platformId}" not present in platforms.json`); + return false; + } + return true; +} + +function isPlatformIdBeefy(vault: AnyVaultWithData) { + if (vault.platformId !== 'beefy') { + pconsole.error(`${vault.id}: platformId should be "beefy" not "${vault.platformId}"`); + return false; + } + return true; +} + +function isPlatformIdSameAsCowcentratedTokenProviderId( + vault: StandardWithData | GovWithData, + { vaults }: VaultValidateContext +) { + const clm = vaults.allCowcentrated.find(clm => clm.earnContractAddress === vault.tokenAddress); + if (!clm) { + pconsole.error( + `${vault.id}: CLM ${vault.type === 'gov' ? 'Pool' : 'Vault'} missing underlying CLM` + ); + return false; + } + + if (vault.platformId !== clm.tokenProviderId) { + pconsole.error( + `${vault.id}: platformId should be "${clm.tokenProviderId}" (tokenProviderId of ${clm.id}) not "${vault.platformId}"` + ); + return false; + } + + return true; +} + +function isTokenProviderIdSameAsCowcentratedTokenProviderId( + vault: StandardWithData | GovWithData, + { vaults }: VaultValidateContext +) { + const clm = vaults.allCowcentrated.find(clm => clm.earnContractAddress === vault.tokenAddress); + if (!clm) { + pconsole.error( + `${vault.id}: CLM ${vault.type === 'gov' ? 'Pool' : 'Vault'} missing underlying CLM` + ); + return false; + } + + if (vault.tokenProviderId !== clm.tokenProviderId) { + pconsole.error( + `${vault.id}: tokenProviderId should be "${clm.tokenProviderId}" (tokenProviderId of ${clm.id}) not "${vault.tokenProviderId}"` + ); + return false; + } + + return true; +} + +function isStrategyTypeIdSameAsCowcentratedStrategyTypeId( + vault: StandardWithData | GovWithData, + { vaults }: VaultValidateContext +) { + const clm = vaults.allCowcentrated.find(clm => clm.earnContractAddress === vault.tokenAddress); + if (!clm) { + pconsole.error( + `${vault.id}: CLM ${vault.type === 'gov' ? 'Pool' : 'Vault'} missing underlying CLM` + ); + return false; + } + + if (vault.strategyTypeId !== clm.strategyTypeId) { + pconsole.error( + `${vault.id}: strategyTypeId should be "${clm.strategyTypeId}" (strategyTypeId of ${clm.id}) not "${vault.strategyTypeId}"` + ); + return false; + } + + return true; +} + +function isOracleValid(vault: AnyVaultWithData) { + if (vault.oracle !== 'lps' && vault.oracle !== 'tokens') { + pconsole.error(`${vault.id}: oracle "${vault.oracle}" not valid`); + return false; + } + return true; +} + +function isOracleIdValid(_vault: AnyVaultWithData) { + // TODO implement + return true; +} + +function isTokenProviderIdValid(vault: AnyVaultWithData) { + if (vault.oracle !== 'lps') { + // only required for LPs, not single asset tokens + return true; + } + + if (!vault.tokenProviderId) { + pconsole.error(`${vault.id}: tokenProviderId missing LP provider platform; see platforms.json`); + return false; + } else if (!doesPlatformIdExist(vault.tokenProviderId)) { + pconsole.error( + `${vault.id}: tokenProviderId "${vault.tokenProviderId}" not present in platforms.json` + ); + return false; + } + + return true; +} + +function isTimestampValid( + vault: AnyVaultWithData, + field: keyof AnyVaultWithData, + required: boolean = true +) { + const value = vault[field]; + if (required && !value) { + pconsole.error(`${vault.id}: Vault ${field} timestamp missing`); + return false; + } else if (value && (typeof value !== 'number' || isNaN(value) || !isFinite(value))) { + pconsole.error(`${vault.id}: Vault ${field} timestamp wrong type, should be a number`); + return false; + } + return true; +} + +function isCreatedAtValid(vault: AnyVaultWithData) { + return isTimestampValid(vault, 'createdAt'); +} + +function isRetiredAtValid(vault: AnyVaultWithData) { + if (vault.status !== 'eol') { + // only required for EOL vaults + return true; + } + + return isTimestampValid(vault, 'retiredAt'); +} + +function isPausedAtValid(vault: AnyVaultWithData) { + if (vault.status !== 'paused') { + // only required for paused vaults + return true; + } + + return isTimestampValid(vault, 'pausedAt'); +} + +function isUpdatedAtValid(vault: AnyVaultWithData) { + return isTimestampValid(vault, 'updatedAt', false); +} + +function isNetworkValid(vault: AnyVaultWithData, { chainId }: VaultValidateContext) { + const expectedNetwork = addressBookToAppId(chainId); + + if (!vault.network) { + pconsole.error(`${vault.id}: Missing network, expected "${expectedNetwork}"`); + return false; + } else if (vault.network !== expectedNetwork) { + pconsole.error( + `${vault.id}: Network mismatch "${vault.network}", expected "${expectedNetwork}"` + ); + return false; + } + return true; +} + +function areAssetsValid(vault: AnyVaultWithData, { chainId, globalContext }: VaultValidateContext) { + if (!vault.assets || !Array.isArray(vault.assets) || !vault.assets.length) { + pconsole.error(`${vault.id}: Missing assets array`); + return false; + } + + let anyMissing = false; + for (const assetId of vault.assets) { + if ( + !(assetId in addressBook[chainId].tokens) && + !globalContext.options.vaults.assets?.syntheticsNotInAddressBook?.[chainId]?.has(assetId) + ) { + if ( + vault.status === 'eol' && + globalContext.options.vaults.assets?.missingAllowedForEolCreatedBefore && + vault.createdAt <= globalContext.options.vaults.assets?.missingAllowedForEolCreatedBefore + ) { + // pconsole.warn(`${vault.id}: Asset "${assetId}" not in addressbook on ${chainId}`); + continue; + } + + pconsole.error(`${vault.id}: Asset "${assetId}" not in addressbook on ${chainId}`); + anyMissing = true; + } + } + + return !anyMissing; +} + +function doesCowcentratedHavePool(clm: CowcentratedWithData, { vaults }: VaultValidateContext) { + // Skip EOL pools + if (clm.status === 'eol') { + return true; + } + + const hasPool = vaults.cowcentratedGov.find( + pool => pool.tokenAddress === clm.earnContractAddress + ); + if (!hasPool) { + pconsole.error(`Error: ${clm.id} : CLM missing CLM Pool`); + return false; + } + + return true; +} + +function doesConfigHaveNoOldFields( + vault: AnyVaultWithData, + { globalContext }: VaultValidateContext +) { + const fieldsToDelete = Object.keys(globalContext.options.vaults.fields.legacy).filter( + field => field in vault + ); + if (fieldsToDelete.length) { + pconsole.error(`${vault.id}: These fields are no longer needed: ${fieldsToDelete.join(', ')}`); + return false; + } + return true; +} + +function areAddressesValid(vault: AnyVaultWithData, { globalContext }: VaultValidateContext) { + const fieldsToChecksum = globalContext.options.vaults.fields.checksum + .flatMap(field => { + const address = vault[field]; + if (address === undefined) { + return undefined; + } + if (Array.isArray(address)) { + return address.map((subAddress, i) => + isValidChecksumAddress(subAddress) ? undefined : `${field}[${i}]` + ); + } + return isValidChecksumAddress(address) ? undefined : field; + }) + .filter(isDefined); + + if (fieldsToChecksum.length) { + pconsole.error( + `${vault.id}: Invalid/non-checksummed addresses - ${fieldsToChecksum.join(', ')}` + ); + } + + return true; +} + +function isNotZeroSupply( + vault: StandardWithData | CowcentratedWithData, + { addRequiredUpdate }: VaultValidateContext +) { + const totalSupply = new BigNumber(vault.totalSupply || '0'); + if (totalSupply.isZero() || totalSupply.isNaN() || !totalSupply.isFinite()) { + if (vault.status !== 'eol') { + pconsole.error(`${vault.id}: ${vault.status} pool is empty`); + addRequiredUpdate(vault.id, 'remove-empty-vault', { + earnContractAddress: vault.earnContractAddress, + }); + return false; + } else { + pconsole.warn(`${vault.id}: eol pool is empty`); + } + } + + return true; +} + +function isRequiredVaultConfigSatisfied( + vault: AnyVaultWithData, + { globalContext, addRequiredUpdate }: VaultValidateContext +) { + const requiredConfigByField = globalContext.options.vaults.fields.required; + if (!requiredConfigByField) { + return true; + } + + const resultsPerField = Object.entries(requiredConfigByField).map(([field, fieldConfig]) => { + const matching = uniq( + fieldConfig + .filter(entry => + Object.entries(entry.matching).every(([key, values]) => + values.some((value: unknown) => vault[key] === value) + ) + ) + .map(entry => entry.value) + ); + + if (matching.length === 0) { + return true; + } + + if (matching.length > 1) { + pconsole.error( + `${vault.id}: Multiple requiredVaultConfig match for ${field}: ${matching.join(', ')}` + ); + return false; + } + + const required = matching[0]; + const actual = vault[field]; + if (actual !== required) { + pconsole.error( + `${vault.id}: ${field} should be "${required}" not "${actual}" via requiredVaultConfig` + ); + return addRequiredUpdate(vault.id, 'fix-vault-field', { + field: field as keyof VaultConfig, + from: actual, + to: required, + }); + } + + return true; + }); + + // only success if all fields do + return resultsPerField.every(result => result); +} + +function arePointsStructureIdsValid( + vault: AnyVaultWithData, + { globalContext }: VaultValidateContext +) { + let success = true; + + if (vault.pointStructureIds && vault.pointStructureIds.length > 0) { + const invalidPointStructureIds = vault.pointStructureIds!.filter( + p => !globalContext.options.points.providerById.has(p) + ); + if (invalidPointStructureIds.length > 0) { + pconsole.error( + `${vault.id}: pointStructureIds ${invalidPointStructureIds} not present in points.json` + ); + success = false; + } + } + + // check for the provider eligibility + for (const pointProvider of globalContext.options.points.providerById.values()) { + const hasProvider = vault.pointStructureIds?.includes(pointProvider.id) ?? false; + + const shouldHaveProviderArr: boolean[] = []; + for (const eligibility of pointProvider.eligibility) { + if (eligibility.type === 'token-by-provider') { + if (!('tokens' in eligibility)) { + throw new Error(`Error: ${pointProvider.id} : eligibility.tokens missing`); + } + if (!('tokenProviderId' in eligibility)) { + throw new Error(`Error: ${pointProvider.id} : eligibility.tokenProviderId missing`); + } + + shouldHaveProviderArr.push( + (vault.tokenProviderId === eligibility.tokenProviderId && + vault.assets?.some(a => eligibility.tokens?.includes(a))) ?? + false + ); + } else if (eligibility.type === 'token-on-platform') { + if (!('tokens' in eligibility)) { + throw new Error(`Error: ${pointProvider.id} : eligibility.tokens missing`); + } + if (!('platformId' in eligibility)) { + throw new Error(`Error: ${pointProvider.id} : eligibility.platformId missing`); + } + + shouldHaveProviderArr.push( + (eligibility.platformId === vault.platformId && + vault.assets?.some(a => eligibility.tokens.includes(a))) ?? + false + ); + } else if (eligibility.type === 'token-holding') { + if (!('tokens' in eligibility)) { + throw new Error(`Error: ${pointProvider.id} : eligibility.tokens missing`); + } + + shouldHaveProviderArr.push( + vault.assets?.some(a => eligibility?.tokens?.includes(a)) ?? false + ); + } else if (eligibility.type === 'on-chain-lp') { + if (!('chain' in eligibility)) { + throw new Error(`Error: ${pointProvider.id} : eligibility.chain missing`); + } + + shouldHaveProviderArr.push(vault.network === eligibility.chain); + } else if (eligibility.type === 'earned-token-name-regex') { + if (!('regex' in eligibility)) { + throw new Error(`Error: ${pointProvider.id} : eligibility.regex missing`); + } + const earnedToken = vault.earnedToken; + const regex = new RegExp(eligibility.regex as string); + shouldHaveProviderArr.push(regex.test(earnedToken)); + } else if (eligibility.type === 'vault-whitelist') { + shouldHaveProviderArr.push(hasProvider); + } + } + + // bool or + const shouldHaveProvider = shouldHaveProviderArr.some(Boolean); + + if (shouldHaveProvider && !hasProvider) { + pconsole.error( + `${vault.id}: pointStructureId ${pointProvider.id} should be present in pointStructureIds` + ); + success = false; + } else if (!shouldHaveProvider && hasProvider) { + pconsole.error( + `${vault.id}: pointStructureId ${pointProvider.id} should NOT be present in pointStructureIds` + ); + success = false; + } + } + + return success; +} diff --git a/scripts/validatePools.ts b/scripts/validatePools.ts index c5df5ce003..b78d9bb006 100644 --- a/scripts/validatePools.ts +++ b/scripts/validatePools.ts @@ -1,152 +1,394 @@ -import { MultiCall } from 'eth-multicall'; import { addressBook } from 'blockchain-addressbook'; -import Web3 from 'web3'; -import BigNumber from 'bignumber.js'; -import { isEmpty, isValidChecksumAddress, maybeChecksumAddress, sleep } from './common/utils'; import { getVaultsIntegrity } from './common/exclude'; import { - addressBookToAppId, + AddressBookChainId, chainIds, - chainRpcs, excludeChains, excludedChainIds, getBoostsForChain, - getVaultsForChain, } from './common/config'; -import { getStrategyIds } from './common/strategies'; -import strategyABI from '../src/config/abi/strategy.json'; -import { StandardVaultAbi } from '../src/config/abi/StandardVaultAbi'; -import platforms from '../src/config/platforms.json'; import partners from '../src/config/boost/partners.json'; import campaigns from '../src/config/boost/campaigns.json'; -import pointProviders from '../src/config/points.json'; -import type { PlatformType, VaultConfig } from '../src/features/data/apis/config-types'; -import partition from 'lodash/partition'; -import type { AbiItem } from 'web3-utils'; -import i18keys from '../src/locales/en/main.json'; -import { fileExists } from './common/files'; +import { fetchVaults } from './validate/vault/data'; +import { GlobalValidateContext, VaultValidateContext } from './validate/required-updates-types'; +import { ChainValidateResult } from './validate/chain-types'; +import { RequiredUpdates } from './validate/required-updates'; +import { ValidationOptions } from './validate/options-types'; +import { vaultValidators } from './validate/vault/validators'; +import { VaultValidatorsCheck } from './validate/vault/validators-types'; +import { VaultGroups } from './validate/vault/data-types'; +import { buildOptions } from './validate/options'; +import { isPlatformConfigValid } from './validate/platform/validators'; +import { pconsole } from './common/pconsole'; + +/** + * Everything should be configurable from this object + * It is a bit more verbose as we: + * - don't skip on `undefined` values + * - nor, allow owners on one exceptional contract to be valid for all contracts on the same chain + * - and, add reasons for exceptions (please maintain these) + * The list of validators run for each vault type is specified in vault/validators.ts + * @see vaultValidators + */ +const options: ValidationOptions = buildOptions({ + vaults: { + // Additional owners valid for all vaults on a chain + additionalVaultOwners: { + fantom: ['devMultisig'], + polygon: ['devMultisig'], + arbitrum: ['devMultisig'], + }, + // Can skip validators for specific chains/groups + skip: { + allStandard: { + isHarvestOnDepositCorrect: { + chains: new Set(['ethereum', 'avax', 'rootstock']), + }, + }, + }, + // Custom expected results for specific vaults / validators + exceptions: { + isHarvestOnDepositCorrect: { + 'bifi-vault': { value: false, reason: 'Please add a reason' }, + 'swapbased-usd+-usdbc': { value: false, reason: 'Please add a reason' }, + 'swapbased-dai+-usd+': { value: false, reason: 'Please add a reason' }, + 'equilibria-arb-silo-usdc.e': { value: false, reason: 'Please add a reason' }, + 'silo-eth-pendle-weeth': { value: false, reason: 'Please add a reason' }, + 'pancake-cow-arb-usdt+-usd+-vault': { value: false, reason: 'Please add a reason' }, + 'compound-op-eth': { value: false, reason: 'Please add a reason' }, + 'nuri-cow-scroll-usdc-scr-vault': { value: false, reason: 'Please add a reason' }, + 'aero-cow-eurc-usdc-vault': { value: false, reason: 'Please add a reason' }, + 'venus-bnb': { value: false, reason: 'Please add a reason' }, + 'aero-cow-eurc-cbbtc-vault': { value: false, reason: 'BTC decimals' }, + 'pendle-eqb-arb-dwbtc-26jun25': { value: false, reason: 'BTC decimals' }, + 'pendle-arb-dwbtc-26jun25': { value: false, reason: 'BTC decimals' }, + 'tokan-wbtc-weth': { value: false, reason: 'BTC decimals' }, + 'aero-cow-usdz-cbbtc-vault': { value: false, reason: 'BTC decimals' }, + 'aero-cow-weth-cbbtc-vault': { value: false, reason: 'BTC decimals' }, + 'aero-cow-usdc-cbbtc-vault': { value: false, reason: 'BTC decimals' }, + 'silo-op-tbtc-tbtc': { value: false, reason: 'BTC decimals' }, + 'sushi-cow-arb-wbtc-tbtc-vault': { value: false, reason: 'BTC decimals' }, + 'png-wbtc.e-usdc': { value: false, reason: 'BTC decimals' }, + }, + isStrategyOwnerCorrect: { + 'bifi-maxi-eol': { + value: '0x77BA75A9a95b5aB756749fF5519aC40Ed4AAb486', + reason: 'Please add a reason', + }, + 'bunny-bunny-eol': { + value: '0x0000000000000000000000000000000000000000', + reason: 'BSC no owner', + }, + 'cake-syrup-twt': { + value: undefined, + reason: 'BSC old strategy with no privileged methods', + }, + 'fortube-btcb': { value: undefined, reason: 'BSC old strategy with no privileged methods' }, + 'fortube-busd': { value: undefined, reason: 'BSC old strategy with no privileged methods' }, + 'fortube-dot': { value: undefined, reason: 'BSC old strategy with no privileged methods' }, + 'fortube-fil': { value: undefined, reason: 'BSC old strategy with no privileged methods' }, + 'fortube-usdt': { value: undefined, reason: 'BSC old strategy with no privileged methods' }, + 'fry-burger-v1': { + value: undefined, + reason: 'BSC old strategy with no privileged methods', + }, + 'fry-burger-v2': { + value: undefined, + reason: 'BSC old strategy with no privileged methods', + }, + }, + isVaultOwnerCorrect: { + 'cake-busd-bnb': { value: undefined, reason: 'BSC old vault with no privileged methods' }, + 'cake-cake-bnb-eol': { + value: undefined, + reason: 'BSC old vault with no privileged methods', + }, + 'cake-cake-eol': { value: undefined, reason: 'BSC old vault with no privileged methods' }, + 'cake-hard': { value: undefined, reason: 'BSC old vault with no privileged methods' }, + 'cake-syrup-twt': { value: undefined, reason: 'BSC old vault with no privileged methods' }, + 'cake-twt': { value: undefined, reason: 'BSC old vault with no privileged methods' }, + 'cake-usdt-busd': { value: undefined, reason: 'BSC old vault with no privileged methods' }, + 'fortube-btcb': { value: undefined, reason: 'BSC old vault with no privileged methods' }, + 'fortube-busd': { value: undefined, reason: 'BSC old vault with no privileged methods' }, + 'fortube-dot': { value: undefined, reason: 'BSC old vault with no privileged methods' }, + 'fortube-fil': { value: undefined, reason: 'BSC old vault with no privileged methods' }, + 'fortube-usdt': { value: undefined, reason: 'BSC old vault with no privileged methods' }, + 'fry-burger-v1': { value: undefined, reason: 'BSC old vault with no privileged methods' }, + 'fry-burger-v2': { value: undefined, reason: 'BSC old vault with no privileged methods' }, + 'beltv2-4belt': { + value: '0x654AC60246c9B7E35f0F51f116D67EbC0a956d09', + reason: 'BSC Moonpot deployer', + }, + }, + isRewardPoolOwnerCorrect: { + 'cronos-bifi-gov': { + value: '0xF9eBb381dC153D0966B2BaEe776de2F400405755', + reason: 'Cronos BeefyFeeBatchV3', + }, + 'fantom-bifi-gov': { + value: '0x35F43b181957824f2b5C0EF9856F85c90fECb3c8', + reason: 'Fantom BeefyFeeBatchV3', + }, + 'metis-bifi-gov': { + value: '0x2cC364255206A7e14bF59ADB1fc5770DbA48CB3f', + reason: 'Metis BeefyFeeBatchV3', + }, + 'avax-bifi-gov': { + value: '0x48beD04cBC52B5676C04fa94be5786Cdc9f266f5', + reason: 'Avax BeefyFeeBatchV3', + }, + 'beefy-beJoe-earnings': { + value: '0xc1464638B11b9BAac9525cf7bF2B4A52Ccbde885', + reason: 'Avax JoeBatch', + }, + 'moonbeam-bifi-gov': { + value: '0x00AeC34489A7ADE91A0507B6b9dBb0a50938B7c0', + reason: 'Moonbeam BeefyFeeBatchV3', + }, + 'beefy-beqi-earnings': { + value: '0x97bfa4b212A153E15dCafb799e733bc7d1b70E72', + reason: 'Polygon BeefyQI', + }, + 'polygon-bifi-gov': { + value: '0x7313533ed72D2678bFD9393480D0A30f9AC45c1f', + reason: 'Polygon BeefyFeeBatchV3', + }, + 'arbi-bifi-gov': { + value: '0xFEd99885fE647dD44bEA2B375Bd8A81490bF6E0f', + reason: 'Arbitrum BeefyFeeBatchV3', + }, + 'bifi-pool': { + value: addressBook.ethereum.platforms.beefyfinance.strategyOwner, + reason: 'Ethereum strategyOwner', + }, + 'ethereum-bifi-gov': { + value: '0x8237f3992526036787E8178Def36291Ab94638CD', + reason: 'Ethereum BeefyFeeBatchV3UniV3', + }, + 'bifi-gov': { + value: '0xAb4e8665E7b0E6D83B65b8FF6521E347ca93E4F8', + reason: 'BSC BeefyFeeBatchV3', + }, + 'bifi-gov-eol': { + value: '0x0000000000000000000000000000000000000000', + reason: 'BSC no owner', + }, + 'beefy-beopx-earnings': { + value: '0xEDFBeC807304951785b581dB401fDf76b4bAd1b0', + reason: 'Optimism BeefyOPX', + }, + 'optimism-bifi-gov': { + value: '0x3Cd5Ae887Ddf78c58c9C1a063EB343F942DbbcE8', + reason: 'Optimism BeefyFeeBatchV3SolidlyRouter', + }, + 'kava-bifi-gov': { + value: '0xF0d26842c3935A618e6980C53fDa3A2D10A02eb7', + reason: 'Kava ???', + }, + }, + isFeeConfigCorrect: { + 'boo-boo-ftm': { value: undefined, reason: 'Fantom strategy predates feeConfig' }, + 'boo-mim-ftm': { value: undefined, reason: 'Fantom strategy predates feeConfig' }, + 'boo-wftm-beets': { value: undefined, reason: 'Fantom strategy predates feeConfig' }, + 'boo-wftm-brush': { value: undefined, reason: 'Fantom strategy predates feeConfig' }, + 'boo-wftm-spell': { value: undefined, reason: 'Fantom strategy predates feeConfig' }, + 'wigo-wigo': { value: undefined, reason: 'Fantom strategy predates feeConfig' }, + 'wigo-wigo-ftm': { value: undefined, reason: 'Fantom strategy predates feeConfig' }, + 'netswap-m.usdt-m.usdc': { value: undefined, reason: 'Metis strategy predates feeConfig' }, + 'netswap-metis-m.usdc': { value: undefined, reason: 'Metis strategy predates feeConfig' }, + 'netswap-nett-metis': { value: undefined, reason: 'Metis strategy predates feeConfig' }, + 'netswap-weth-metis': { value: undefined, reason: 'Metis strategy predates feeConfig' }, + 'vvs-cro-atom': { value: undefined, reason: 'Cronos strategy predates feeConfig' }, + 'vvs-cro-btc': { value: undefined, reason: 'Cronos strategy predates feeConfig' }, + 'vvs-cro-doge': { value: undefined, reason: 'Cronos strategy predates feeConfig' }, + 'vvs-cro-eth': { value: undefined, reason: 'Cronos strategy predates feeConfig' }, + 'vvs-cro-shib': { value: undefined, reason: 'Cronos strategy predates feeConfig' }, + 'vvs-cro-usdc': { value: undefined, reason: 'Cronos strategy predates feeConfig' }, + 'vvs-cro-usdt': { value: undefined, reason: 'Cronos strategy predates feeConfig' }, + 'vvs-tonic-cro': { value: undefined, reason: 'Cronos strategy predates feeConfig' }, + 'vvs-usdt-usdc': { value: undefined, reason: 'Cronos strategy predates feeConfig' }, + 'vvs-vvs': { value: undefined, reason: 'Cronos strategy predates feeConfig' }, + 'vvs-vvs-cro': { value: undefined, reason: 'Cronos strategy predates feeConfig' }, + 'vvs-vvs-usdc': { value: undefined, reason: 'Cronos strategy predates feeConfig' }, + 'vvs-vvs-usdt': { value: undefined, reason: 'Cronos strategy predates feeConfig' }, + 'joe-joe': { value: undefined, reason: 'Avax strategy predates feeConfig' }, + 'stellaswap-well-wglmr': { + value: undefined, + reason: 'Moonbeam strategy predates feeConfig', + }, + 'curve-op-f-susd': { value: undefined, reason: 'Optimism strategy predates feeConfig' }, + 'velodrome-usdc-dola': { value: undefined, reason: 'Optimism strategy predates feeConfig' }, + 'velodrome-velo-op': { value: undefined, reason: 'Optimism strategy predates feeConfig' }, + }, + isFeeRecipientCorrect: { + 'ethereum-vault': { + value: '0x8237f3992526036787E8178Def36291Ab94638CD', + reason: 'Ethereum BeefyFeeBatchV3UniV3', + }, + 'bifi-vault': { + value: '0x8237f3992526036787E8178Def36291Ab94638CD', + reason: 'Ethereum BeefyFeeBatchV3UniV3', + }, + }, + }, + assets: { + missingAllowedForEolCreatedBefore: 1675694667, // 2023-06-02T14:44:27+00:00 + syntheticsNotInAddressBook: { + arbitrum: new Set(['NEAR', 'ATOM', 'BNB', 'LTC', 'XRP', 'DOGE']), + }, + }, + fields: { + required: { + // Ensure CLM strategies are correct; additional check ensure that CLM Pool/Vault match the base CLM + strategyTypeId: [ + { + value: 'compounds', + matching: { + type: ['cowcentrated'], + tokenProviderId: [ + 'uniswap', + 'sushi', + 'thena', + 'camelot', + 'stellaswap', + 'baseswap', + 'oku', + 'kim', + 'dragon', + 'ramses', // compounds and also sends to reward pool + 'pharaoh', // compounds and also sends to reward pool + 'nile', // compounds and also sends to reward pool + 'pancakeswap', // compounds and also sends to reward pool + 'nuri', // compounds and also sends to reward pool + ], + }, + }, + { + value: 'pool', + matching: { + type: ['cowcentrated'], + tokenProviderId: ['velodrome', 'aerodrome'], + }, + }, + ], + }, + legacy: { + tokenDescription: 'Use addressbook', + tokenDescriptionUrl: 'Use addressbook', + pricePerFullShare: 'Not required', + tvl: 'Not required', + oraclePrice: 'Not required', + platform: 'Use platformId', + stratType: 'Use strategyTypeId', + logo: 'Not required', + depositsPaused: 'Use status: paused', + withdrawalFee: 'Not required (use api)', + updatedFees: 'Not required', + mintTokenUrl: 'Use minters config', + callFee: 'Not required (use api)', + tokenAmmId: 'Use zap: VaultZapConfig if needed', + isGovVault: 'Use type: gov', + }, + checksum: [ + 'tokenAddress', + 'earnedTokenAddress', + 'earnContractAddress', + 'depositTokenAddresses', + ], + }, + }, +}); -const overrides = { - 'bunny-bunny-eol': { keeper: undefined, stratOwner: undefined }, - 'bifi-maxi': { stratOwner: undefined }, // harvester 0xDe30 - 'beltv2-4belt': { vaultOwner: undefined }, // moonpot deployer - 'baseswap-axlwbtc-usdbc': { harvestOnDeposit: undefined }, - 'kinetix-klp': { harvestOnDeposit: undefined }, - 'bifi-vault': { beefyFeeRecipient: undefined }, // TODO: remove - 'png-wbtc.e-usdc': { harvestOnDeposit: undefined }, - 'gmx-arb-glp': { harvestOnDeposit: undefined }, - 'gmx-arb-gmx': { harvestOnDeposit: undefined }, - 'swapbased-usd+-usdbc': { harvestOnDeposit: undefined }, - 'swapbased-dai+-usd+': { harvestOnDeposit: undefined }, - 'aero-cow-eurc-cbbtc-vault': { harvestOnDeposit: undefined }, - 'pendle-eqb-arb-dwbtc-26jun25': { harvestOnDeposit: undefined }, - 'pendle-arb-dwbtc-26jun25': { harvestOnDeposit: undefined }, +type CliOptions = { + verbose: boolean; + noColor: boolean; }; -const oldValidOwners = [ - addressBook.fantom.platforms.beefyfinance.devMultisig, - addressBook.polygon.platforms.beefyfinance.devMultisig, - addressBook.arbitrum.platforms.beefyfinance.devMultisig, -]; +async function validateEverything({ + verbose = false, + noColor = false, +}: CliOptions): Promise { + const globalContext: GlobalValidateContext = { + options, + seenVaultIds: new Set(), + requiredUpdates: new RequiredUpdates(), + verbose, + }; + + if (noColor) { + pconsole.disableColor(); + } -const oldValidFeeRecipients = { - canto: '0xF09d213EE8a8B159C884b276b86E08E26B3bfF75', - kava: '0x07F29FE11FbC17876D9376E3CD6F2112e81feA6F', - moonriver: '0x617f12E04097F16e73934e84f35175a1B8196551', - moonbeam: [ - '0x00aec34489a7ade91a0507b6b9dbb0a50938b7c0', - '0x3E7F60B442CEAE0FE5e48e07EB85Cfb1Ed60e81A', - ], -}; + let exitCode = ( + await Promise.all([ + // Vaults + Boosts + validatePools(globalContext), + // Platform config + validatePlatformConfig(globalContext), + ]) + ).reduce((prev, curr) => Math.max(prev, curr), 0); + + if (globalContext.requiredUpdates.hasAny()) { + exitCode = exitCode === 0 ? 1 : exitCode; + globalContext.requiredUpdates.prettyPrint(); + } -const oldValidRewardPoolOwners = { - polygon: [ - '0x7313533ed72D2678bFD9393480D0A30f9AC45c1f', - '0x97bfa4b212A153E15dCafb799e733bc7d1b70E72', - ], - kava: '0xF0d26842c3935A618e6980C53fDa3A2D10A02eb7', - metis: '0x2cC364255206A7e14bF59ADB1fc5770DbA48CB3f', - cronos: '0xF9eBb381dC153D0966B2BaEe776de2F400405755', - celo: '0x32C82EE8Fca98ce5114D2060c5715AEc714152FB', - canto: '0xeD7b88EDd899d578581DCcfce80F43D1F395b93f', - moonriver: '0xD5e8D34dE3B1A6fd54e87B5d4a857CBB762d0C8A', - moonbeam: '0x00AeC34489A7ADE91A0507B6b9dBb0a50938B7c0', - aurora: '0x9dA9f3C6c45F1160b53D395b0A982aEEE1D212fE', - ethereum: [ - '0x1c9270ac5C42E51611d7b97b1004313D52c80293', - '0x8237f3992526036787E8178Def36291Ab94638CD', - ], - avax: [ - '0x48beD04cBC52B5676C04fa94be5786Cdc9f266f5', - '0xc1464638B11b9BAac9525cf7bF2B4A52Ccbde885', - ], - arbitrum: '0xFEd99885fE647dD44bEA2B375Bd8A81490bF6E0f', - bsc: ['0xAb4e8665E7b0E6D83B65b8FF6521E347ca93E4F8', '0x0000000000000000000000000000000000000000'], - fantom: '0x35F43b181957824f2b5C0EF9856F85c90fECb3c8', - optimism: [ - '0xEDFBeC807304951785b581dB401fDf76b4bAd1b0', - '0x3Cd5Ae887Ddf78c58c9C1a063EB343F942DbbcE8', - addressBook.optimism.platforms.beefyfinance.strategyOwner, - ], -}; + return exitCode; +} -const nonHarvestOnDepositChains = ['ethereum', 'avax', 'rootstock']; -const nonHarvestOnDepositPools = [ - 'venus-bnb', - 'equilibria-arb-silo-usdc.e', - 'silo-eth-pendle-weeth', - 'silo-op-tbtc-tbtc', - 'sushi-cow-arb-wbtc-tbtc-vault', - 'pancake-cow-arb-usdt+-usd+-vault', - 'aero-cow-weth-cbbtc-vault', - 'aero-cow-usdc-cbbtc-vault', - 'compound-op-usdt', - 'compound-op-usdc', - 'compound-op-eth', - 'nuri-cow-scroll-usdc-scr-vault', - 'tokan-wbtc-weth', - 'aero-cow-usdz-cbbtc-vault', - 'aero-cow-eurc-usdc-vault', -]; -const excludedAbPools = [ - 'gmx-arb-near-usdc', - 'gmx-arb-atom-usdc', - 'gmx-arb-bnb-usdc', - 'gmx-arb-ltc-usdc', - 'gmx-arb-xrp-usdc', - 'gmx-arb-doge-usdc', -]; -const addressFields = ['tokenAddress', 'earnedTokenAddress', 'earnContractAddress']; +// Vaults + Boosts +async function validatePools(globalContext: GlobalValidateContext): Promise { + let exitCode: number = 0; -const validPlatformIds = platforms.map(platform => platform.id); -const validStrategyIds = getStrategyIds(); -const validPointProviderIds = pointProviders.map(pointProvider => pointProvider.id); + if (!(await areExcludedChainsUnchanged())) { + return 1; + } -const oldFields = { - tokenDescription: 'Use addressbook', - tokenDescriptionUrl: 'Use addressbook', - pricePerFullShare: 'Not required', - tvl: 'Not required', - oraclePrice: 'Not required', - platform: 'Use platformId', - stratType: 'Use strategyTypeId', - logo: 'Not required', - depositsPaused: 'Use status: paused', - withdrawalFee: 'Not required (use api)', - updatedFees: 'Not required', - mintTokenUrl: 'Use minters config', - callFee: 'Not required (use api)', - tokenAmmId: 'Use zap: VaultZapConfig if needed', - isGovVault: 'Use type: gov', -}; + const chainResults = await Promise.allSettled( + chainIds.map(chainId => validateChainPools(chainId, globalContext)) + ); + let invalidChains = 0; + for (let i = 0; i < chainResults.length; ++i) { + const chainResult = chainResults[i]; + const chainId = chainIds[i]; + + if (chainResult.status === 'rejected') { + invalidChains++; + pconsole.error(`Error: ${chainId} threw while attempting to validate:`, chainResult.reason); + exitCode = 1; + continue; + } + + const result = chainResult.value; + if (!result.success) { + invalidChains++; + pconsole.error(`Error: ${chainId} failed to validate.`); + exitCode = 1; + } + } -const validatePools = async () => { - let exitCode = 0; - let updates = {}; - const uniquePoolId = new Set(); + if (invalidChains > 0) { + pconsole.error(`${invalidChains}/${chainIds.length} chains failed validation.`); + } if (excludedChainIds.length > 0) { - console.warn(`*** Excluded chains: ${excludedChainIds.join(', ')} ***`); + pconsole.log(`*** Excluded chains: ${excludedChainIds.join(', ')} ***`); + } + + if (exitCode === 0) { + pconsole.success('Validated successfully.'); + } else { + pconsole.error('Validation failed.'); + } + + return exitCode; +} + +async function areExcludedChainsUnchanged() { + let isUnchanged = true; + + if (excludedChainIds.length > 0) { + pconsole.log(`*** Excluded chains: ${excludedChainIds.join(', ')} ***`); const integrities = await Promise.all( excludedChainIds.map(chainId => getVaultsIntegrity(chainId)) ); @@ -155,342 +397,188 @@ const validatePools = async () => { const integrityThen = excludeChains[chainId]; if (!integrityThen) { - console.error(`Missing integrity data for excluded chain ${chainId}`); - exitCode = 1; + pconsole.error(`Missing integrity data for excluded chain ${chainId}`); + isUnchanged = false; return; } if (!integrityNow) { - console.error(`Failed to perform integrity check for excluded chain ${chainId}`); - exitCode = 1; + pconsole.error(`Failed to perform integrity check for excluded chain ${chainId}`); + isUnchanged = false; return; } if (integrityNow.count !== integrityThen.count) { - console.error( + pconsole.error( `Vault count changed for excluded chain ${chainId}: ${integrityThen.count} -> ${integrityNow.count}` ); - exitCode = 1; + isUnchanged = false; return; } if (integrityNow.hash !== integrityThen.hash) { - console.error( + pconsole.error( `Vault hash changed for excluded chain ${chainId}: ${integrityThen.hash} -> ${integrityNow.hash}` ); - exitCode = 1; + isUnchanged = false; return; } - console.log(`Excluded chain ${chainId} integrity check passed`); + pconsole.success(`Excluded chain ${chainId} integrity check passed`); }); - if (exitCode != 0) { - console.error('*** Excluded chain integrity check failed ***'); - console.error('If you removed a vault, update excludeChains in scripts/common/config.ts'); - return exitCode; + if (!isUnchanged) { + pconsole.error('*** Excluded chain integrity check failed ***'); + pconsole.error('If you removed a vault, update excludeChains in scripts/common/config.ts'); + return isUnchanged; } } - const platformExitCode = await validatePlatformTypes(); - if (platformExitCode !== 0) { - exitCode = platformExitCode; - } - - let promises = chainIds.map(chainId => validateSingleChain(chainId, uniquePoolId)); - let results = await Promise.all(promises); + return isUnchanged; +} - exitCode = results.reduce((acum, cur) => (acum + cur.exitCode > 0 ? 1 : 0), exitCode); - results.forEach(res => { - if (!isEmpty(res.updates)) { - updates[res.chainId] = res.updates; +async function validateChainPools( + chainId: AddressBookChainId, + globalContext: GlobalValidateContext +): Promise { + let success = true; + const [vaults, boostConfigs] = await Promise.all([ + fetchVaults(chainId), + getBoostsForChain(chainId), + ]); + + // + // Vaults + // + const { vaultIds, summary } = vaults.all.reduce( + (prev, vault) => { + prev.vaultIds.add(vault.id); + prev.summary.all.total += 1; + prev.summary[vault.type].total += 1; + prev.summary.all[vault.status] += 1; + prev.summary[vault.type][vault.status] += 1; + + return prev; + }, + { + vaultIds: new Set(), + summary: { + standard: { active: 0, eol: 0, paused: 0, total: 0 }, + gov: { active: 0, eol: 0, paused: 0, total: 0 }, + cowcentrated: { active: 0, eol: 0, paused: 0, total: 0 }, + all: { active: 0, eol: 0, paused: 0, total: 0 }, + }, } - }); - // Helpful data structures to correct addresses. - console.log('Required updates.', JSON.stringify(updates)); - - if (excludedChainIds.length > 0) { - console.warn(`*** Excluded chains: ${excludedChainIds.join(', ')} ***`); - } - - return exitCode; -}; - -const validateSingleChain = async (chainId, uniquePoolId) => { - let [pools, boosts] = await Promise.all([getVaultsForChain(chainId), getBoostsForChain(chainId)]); - - console.log(`Validating ${pools.length} pools in ${chainId}...`); - - let updates: Record> = {}; - let exitCode = 0; - - //Governance pools should be separately verified - const [govPools, vaultPools] = partition(pools, pool => pool.type === 'gov'); - pools = vaultPools; - - const poolIds = new Set(pools.map(pool => pool.id)); - const uniqueEarnedToken = new Set(); - const uniqueEarnedTokenAddress = new Set(); - const uniqueOracleId = new Set(); - const govPoolsByDepositAddress = new Map(govPools.map(pool => [pool.tokenAddress, pool])); - let activePools = 0; - - // Populate some extra data. - const web3 = new Web3(chainRpcs[chainId]); - const poolsWithGovData = await populateGovData(chainId, govPools, web3); - const poolsWithVaultData = await populateVaultsData(chainId, pools, web3); - const poolsWithStrategyData = override( - await populateStrategyData(chainId, poolsWithVaultData, web3) ); - const clmsWithData = await populateCowcentratedData(chainId, pools, web3); - poolsWithStrategyData.forEach(pool => { - // Errors, should not proceed with build - if (uniquePoolId.has(pool.id)) { - console.error(`Error: ${pool.id} : Pool id duplicated: ${pool.id}`); - exitCode = 1; - } - - if (uniqueEarnedToken.has(pool.earnedToken)) { - console.error(`Error: ${pool.id} : Pool earnedToken duplicated: ${pool.earnedToken}`); - exitCode = 1; - } - - if (uniqueEarnedTokenAddress.has(pool.earnedTokenAddress)) { - console.error( - `Error: ${pool.id} : Pool earnedTokenAddress duplicated: ${pool.earnedTokenAddress}` - ); - exitCode = 1; - } - - if (pool.earnedTokenAddress !== pool.earnContractAddress) { - console.error( - `Error: ${pool.id} : Pool earnedTokenAddress not same as earnContractAddress: ${pool.earnedTokenAddress} != ${pool.earnContractAddress}` - ); - exitCode = 1; - } - - if (!pool.strategyTypeId) { - console.error(`Error: ${pool.id} : strategyTypeId missing vault strategy type`); - exitCode = 1; - } else if (!validStrategyIds[pool.type].has(pool.strategyTypeId)) { - console.error( - `Error: ${pool.id} : strategyTypeId invalid, "StrategyDescription-${pool.type}-${pool.strategyTypeId}" not present in locales/en/risks.json` - ); - exitCode = 1; - } - - if (!pool.platformId) { - console.error(`Error: ${pool.id} : platformId missing vault platform; see platforms.json`); - exitCode = 1; - } else if (!validPlatformIds.includes(pool.platformId)) { - console.error( - `Error: ${pool.id} : platformId ${pool.platformId} not present in platforms.json` - ); - exitCode = 1; - } - - if (pool.oracle === 'lps') { - if (!pool.tokenProviderId) { - console.error( - `Error: ${pool.id} : tokenProviderId missing LP provider platform; see platforms.json` - ); - exitCode = 1; - } else if (!validPlatformIds.includes(pool.tokenProviderId)) { - console.error( - `Error: ${pool.id} : tokenProviderId ${pool.tokenProviderId} not present in platforms.json` - ); - exitCode = 1; - } - } - - if (!pool.createdAt) { - console.error( - `Error: ${pool.id} : Pool createdAt timestamp missing - required for UI: vault sorting` - ); - exitCode = 1; - } else if (isNaN(pool.createdAt)) { - console.error(`Error: ${pool.id} : Pool createdAt timestamp wrong type, should be a number`); - exitCode = 1; - } - - if (pool.status === 'eol') { - if (!pool.retiredAt) { - console.error(`Error: ${pool.id} : Pool retiredAt timestamp missing`); - exitCode = 1; - } else if ( - typeof pool.retiredAt !== 'number' || - isNaN(pool.retiredAt) || - !isFinite(pool.retiredAt) - ) { - console.error( - `Error: ${pool.id} : Pool retiredAt timestamp wrong type, should be a number` - ); - exitCode = 1; + const { beefyFeeRecipient, beefyFeeConfig } = addressBook[chainId].platforms.beefyfinance; + + const context: VaultValidateContext = { + globalContext: globalContext, + seenEarnedTokens: new Set(), + seenEarnedTokenAddresses: new Set(), + addRequiredUpdate: globalContext.requiredUpdates.makeChainAddFunction(chainId), + chainId, + vaults, + vaultOwners: globalContext.options.vaults.vaultOwners[chainId], + rewardPoolOwners: globalContext.options.vaults.rewardPoolOwners[chainId], + strategyOwners: globalContext.options.vaults.strategyOwners[chainId], + strategyKeepers: globalContext.options.vaults.strategyKeepers[chainId], + feeRecipient: beefyFeeRecipient, + feeConfig: beefyFeeConfig, + }; + + const genericVaultValidators = vaultValidators as VaultValidatorsCheck; + for (const group of Object.keys(genericVaultValidators)) { + const validateFunctions = genericVaultValidators[group as keyof VaultValidatorsCheck]; + if (!validateFunctions) { + continue; + } + const vaultsToValidate = vaults[group as keyof VaultGroups]; + if (!vaultsToValidate.length) { + continue; + } + + for (const [validateName, validateFn] of Object.entries(validateFunctions)) { + if (globalContext.options.vaults.skip?.[group]?.[validateName]?.chains?.has(chainId)) { + pconsole.info(`Skipping validator ${validateName} for ${group} on ${chainId}`); + continue; } - } - if (pool.status === 'paused') { - if (!pool.pausedAt) { - console.error(`Error: ${pool.id} : Pool pausedAt timestamp missing`); - exitCode = 1; - } else if ( - typeof pool.pausedAt !== 'number' || - isNaN(pool.pausedAt) || - !isFinite(pool.pausedAt) - ) { - console.error(`Error: ${pool.id} : Pool pausedAt timestamp wrong type, should be a number`); - exitCode = 1; - } - } - - if (!pool.network) { - console.error(`Error: ${pool.id} : Missing network`); - exitCode = 1; - } else if (pool.network !== addressBookToAppId(chainId)) { - console.error( - `Error: ${pool.id} : Network mismatch ${pool.network} != ${addressBookToAppId(chainId)}` - ); - exitCode = 1; - } - - // Assets - if (!pool.assets || !Array.isArray(pool.assets) || !pool.assets.length) { - console.error(`Error: ${pool.id} : Missing assets array`); - exitCode = 1; - } else if (pool.status !== 'eol') { - for (const assetId of pool.assets) { - if (!(assetId in addressBook[chainId].tokens)) { - if (excludedAbPools.includes(pool.id)) continue; - // just warn for now - console.warn(`Warning: ${pool.id} : Asset ${assetId} not in addressbook on ${chainId}`); - // exitCode = 1; + for (const vault of vaultsToValidate) { + const isValid = validateFn(vault, context); + if (!isValid) { + success = false; + } + if (globalContext.verbose) { + pconsole.dim(`${chainId} ${group} ${vault.id} ${validateName}: ${isValid ? '✔️' : '❌'}`); } } } + } - // Cowcentrated should have RP - if (pool.type === 'cowcentrated' && pool.status !== 'eol') { - const govPool = govPoolsByDepositAddress.get(pool.earnContractAddress); - if (!govPool) { - console.error(`Error: ${pool.id} : CLM missing CLM pool`); - exitCode = 1; - } - } - - // Old fields we no longer need - const fieldsToDelete = Object.keys(oldFields).filter(field => field in pool); - if (fieldsToDelete.length) { - console.error( - `Error: ${pool.id} : These fields are no longer needed: ${fieldsToDelete.join(', ')}` - ); - fieldsToDelete.forEach(field => console.log(`\t${field}: '${oldFields[field]}',`)); - exitCode = 1; - } - - addressFields.forEach(field => { - if (pool.hasOwnProperty(field) && !isValidChecksumAddress(pool[field])) { - const maybeValid = maybeChecksumAddress(pool[field]); - console.error( - `Error: ${pool.id} : ${field} requires checksum - ${ - maybeValid ? `\n\t${field}: '${maybeValid}',` : 'it is invalid' - }` - ); - exitCode = 1; - } - }); - - if (pool.status === 'active') { - activePools++; - } - - if (new BigNumber(pool.totalSupply || '0').isZero()) { - if (pool.status !== 'eol') { - console.error(`Error: ${pool.id} : Pool is empty`); - exitCode = 1; - if (!('emptyVault' in updates)) updates['emptyVault'] = {}; - updates.emptyVault[pool.id] = pool.earnContractAddress; - } else { - console.warn(`${pool.id} : eol pool is empty`); - } - } - if (checkPointsStructureIds(pool) > 0) { - exitCode = 1; - } - - uniquePoolId.add(pool.id); - uniqueEarnedToken.add(pool.earnedToken); - uniqueEarnedTokenAddress.add(pool.earnedTokenAddress); - uniqueOracleId.add(pool.oracleId); - - const { keeper, strategyOwner, vaultOwner, beefyFeeRecipient, beefyFeeConfig } = - addressBook[chainId].platforms.beefyfinance; - - updates = isKeeperCorrect(pool, chainId, keeper, updates); - updates = isStratOwnerCorrect(pool, chainId, strategyOwner, updates); - updates = isVaultOwnerCorrect(pool, chainId, vaultOwner, updates); - updates = isBeefyFeeRecipientCorrect(pool, chainId, beefyFeeRecipient, updates); - updates = isBeefyFeeConfigCorrect(pool, chainId, beefyFeeConfig, updates); - updates = isHarvestOnDepositCorrect(pool, chainId, updates); - }); - + // // Boosts + // TODO refactor similar to vaults + // const seenBoostIds = new Set(); - boosts.forEach(boost => { + boostConfigs.forEach(boost => { if (seenBoostIds.has(boost.id)) { - console.error(`Error: Boost ${boost.id}: Boost id duplicated: ${boost.id}`); - exitCode = 1; + pconsole.error(`Error: Boost ${boost.id}: Boost id duplicated: ${boost.id}`); + success = false; } seenBoostIds.add(boost.id); - if (!poolIds.has(boost.poolId)) { - console.error(`Error: Boost ${boost.id}: Boost has non-existent pool id ${boost.poolId}.`); - exitCode = 1; + if (!vaultIds.has(boost.poolId)) { + pconsole.error(`Error: Boost ${boost.id}: Boost has non-existent pool id ${boost.poolId}.`); + success = false; return; } if ((boost.partners || []).length === 0 && !boost.campaign) { - console.error(`Error: Boost ${boost.id}: Boost has no partners or campaign.`); - exitCode = 1; + pconsole.error(`Error: Boost ${boost.id}: Boost has no partners or campaign.`); + success = false; return; } if (boost.partners && boost.partners.length) { const invalidPartners = boost.partners.filter(partner => !(partner in partners)); if (invalidPartners.length) { - console.error(`Error: Boost ${boost.id}: Missing partners: ${invalidPartners.join(', ')}`); - exitCode = 1; + pconsole.error(`Error: Boost ${boost.id}: Missing partners: ${invalidPartners.join(', ')}`); + success = false; return; } } if (boost.campaign && !(boost.campaign in campaigns)) { - console.error(`Error: Boost ${boost.id}: Missing campaign: ${boost.campaign}`); - exitCode = 1; + pconsole.error(`Error: Boost ${boost.id}: Missing campaign: ${boost.campaign}`); + success = false; return; } if (boost.assets && boost.assets.length) { for (const assetId of boost.assets) { if (!assetId?.trim().length) { - console.error(`Error: Boost ${boost.id}: Asset id is empty`); - exitCode = 1; + pconsole.error(`Error: Boost ${boost.id}: Asset id is empty`); + success = false; } // TODO need to tidy up old boosts before we can enable this // if (!(assetId in addressBook[chainId].tokens)) { - // console.error(`Error: Boost ${boost.id}: Asset "${assetId}" not in addressbook on ${chainId}`); - // exitCode = 1; + // pconsole.error(`Error: Boost ${boost.id}: Asset "${assetId}" not in addressbook on ${chainId}`); + // success = false; // } } } - const earnedVault = pools.find(pool => pool.earnContractAddress === boost.earnedTokenAddress); + const earnedVault = vaults.all.find( + pool => pool.earnContractAddress === boost.earnedTokenAddress + ); if (earnedVault) { if (boost.earnedTokenDecimals !== 18) { - console.error( + pconsole.error( `Error: Boost ${boost.id}: Earned token decimals mismatch ${boost.earnedTokenDecimals} != 18` ); - exitCode = 1; + success = false; return; } // TODO oracle etc @@ -498,733 +586,61 @@ const validateSingleChain = async (chainId, uniquePoolId) => { const earnedToken = addressBook[chainId].tokens[boost.earnedToken]; if (!earnedToken) { // TODO need to tidy up old boosts before we can enable this - // console.error(`Error: Boost ${boost.id}: Earned token ${boost.earnedToken} not in addressbook`); - // exitCode = 1; + // pconsole.error(`Error: Boost ${boost.id}: Earned token ${boost.earnedToken} not in addressbook`); + // success = false; return; } if (earnedToken.address !== boost.earnedTokenAddress) { - console.error( + pconsole.error( `Error: Boost ${boost.id}: Earned token address mismatch ${boost.earnedTokenAddress} != ${earnedToken.address}` ); - exitCode = 1; + success = false; return; } if (earnedToken.decimals !== boost.earnedTokenDecimals) { - console.error( + pconsole.error( `Error: Boost ${boost.id}: Earned token decimals mismatch ${boost.earnedTokenDecimals} != ${earnedToken.decimals}` ); - exitCode = 1; + success = false; return; } if (earnedToken.oracleId !== boost.earnedOracleId) { - console.error( + pconsole.error( `Error: Boost ${boost.id}: Earned token oracle id mismatch ${boost.earnedOracleId} != ${earnedToken.oracleId}` ); - exitCode = 1; + success = false; return; } } }); - // Gov Pools - poolsWithGovData.forEach(pool => { - if (!pool.strategyTypeId) { - console.error(`Error: ${pool.id} : strategyTypeId missing gov strategy type`); - exitCode = 1; - } else if (!validStrategyIds.gov.has(pool.strategyTypeId)) { - console.error( - `Error: ${pool.id} : strategyTypeId invalid, "StrategyDescription-${pool.type}-${pool.strategyTypeId}" not present in locales/en/risks.json` - ); - exitCode = 1; - } - - if (checkPointsStructureIds(pool) > 0) { - exitCode = 1; - } - - const { devMultisig } = addressBook[chainId].platforms.beefyfinance; - updates = isRewardPoolOwnerCorrect(pool, chainId, devMultisig, updates); - }); - - // CLMs - clmsWithData.forEach(clm => { - if (!clm.oracleForToken0) { - console.error( - `Error: ${clm.id} : Beefy oracle has no subOracle entry for token0 ${clm.token0}` - ); - exitCode = 1; - } - if (!clm.oracleForToken1) { - console.error( - `Error: ${clm.id} : Beefy oracle has no subOracle entry for token1 ${clm.token1}` - ); - exitCode = 1; - } - - if (checkPointsStructureIds(clm) > 0) { - exitCode = 1; - } - }); - - if (!isEmpty(updates)) { - exitCode = 1; + if (globalContext.requiredUpdates.hasChain(chainId)) { + success = false; } - console.log(`${chainId} active pools: ${activePools}/${pools.length}\n`); - - return { chainId, exitCode, updates }; -}; - -// Validation helpers. These only log for now, could throw error if desired. -const isKeeperCorrect = (pool, chain, chainKeeper, updates) => { - if (pool.status !== 'eol' && pool.keeper !== undefined && pool.keeper !== chainKeeper) { - console.log(`Pool ${pool.id} should update keeper. From: ${pool.keeper} To: ${chainKeeper}`); - - if (!('keeper' in updates)) updates['keeper'] = {}; - if (!(chain in updates.keeper)) updates.keeper[chain] = {}; + pconsole.dim(`${chainId} active pools: ${summary.all.active}/${vaults.all.length}`); - if (pool.keeper in updates.keeper[chain]) { - updates.keeper[chain][pool.keeper].push(pool.strategy); - } else { - updates.keeper[chain][pool.keeper] = [pool.strategy]; - } - } - - return updates; -}; - -const isStratOwnerCorrect = (pool, chain, owner, updates) => { - const validOwners = [...oldValidOwners, owner]; - if (pool.stratOwner !== undefined && !validOwners.includes(pool.stratOwner)) { - console.log(`Pool ${pool.id} should update strat owner. From: ${pool.stratOwner} To: ${owner}`); - - if (!('stratOwner' in updates)) updates['stratOwner'] = {}; - if (!(chain in updates.stratOwner)) updates.stratOwner[chain] = {}; - - if (pool.stratOwner in updates.stratOwner[chain]) { - updates.stratOwner[chain][pool.stratOwner].push(pool.strategy); - } else { - updates.stratOwner[chain][pool.stratOwner] = [pool.strategy]; - } - } - - return updates; -}; - -const isVaultOwnerCorrect = (pool, chain, owner, updates) => { - const validOwners = [...oldValidOwners, owner]; - if (pool.vaultOwner !== undefined && !validOwners.includes(pool.vaultOwner)) { - console.log(`Pool ${pool.id} should update vault owner. From: ${pool.vaultOwner} To: ${owner}`); - - if (!('vaultOwner' in updates)) updates['vaultOwner'] = {}; - if (!(chain in updates.vaultOwner)) updates.vaultOwner[chain] = {}; - - if (pool.vaultOwner in updates.vaultOwner[chain]) { - updates.vaultOwner[chain][pool.vaultOwner].push(pool.earnContractAddress); - } else { - updates.vaultOwner[chain][pool.vaultOwner] = [pool.earnContractAddress]; - } - } - - return updates; -}; - -const isRewardPoolOwnerCorrect = (pool, chain, owner, updates) => { - const validOwners: string[] = oldValidRewardPoolOwners[chain] || []; - if ( - pool.rewardPoolOwner !== undefined && - pool.rewardPoolOwner !== owner && - !validOwners.includes(pool.rewardPoolOwner) - ) { - console.log( - `Reward Pool ${pool.id} should update owner. From: ${pool.rewardPoolOwner} To: ${owner}` - ); - - if (!('rewardPoolOwner' in updates)) updates['rewardPoolOwner'] = {}; - if (!(chain in updates.rewardPoolOwner)) updates.rewardPoolOwner[chain] = {}; - - if (pool.rewardPoolOwner in updates.rewardPoolOwner[chain]) { - updates.rewardPoolOwner[chain][pool.rewardPoolOwner].push(pool.earnContractAddress); - } else { - updates.rewardPoolOwner[chain][pool.rewardPoolOwner] = [pool.earnContractAddress]; - } - } - - return updates; -}; - -const isBeefyFeeRecipientCorrect = (pool, chain, recipient, updates) => { - const validRecipients = oldValidFeeRecipients[chain] || []; - if ( - pool.status === 'active' && - pool.beefyFeeRecipient !== undefined && - pool.beefyFeeRecipient !== recipient && - !validRecipients.includes(pool.beefyFeeRecipient) - ) { - console.log( - `Pool ${pool.id} should update beefy fee recipient. From: ${pool.beefyFeeRecipient} To: ${recipient}` - ); - - if (!('beefyFeeRecipient' in updates)) updates['beefyFeeRecipient'] = {}; - if (!(chain in updates.beefyFeeRecipient)) updates.beefyFeeRecipient[chain] = {}; - - if (pool.stratOwner in updates.beefyFeeRecipient[chain]) { - updates.beefyFeeRecipient[chain][pool.stratOwner].push(pool.strategy); - } else { - updates.beefyFeeRecipient[chain][pool.stratOwner] = [pool.strategy]; - } - } - - return updates; -}; - -const isBeefyFeeConfigCorrect = (pool, chain, feeConfig, updates) => { - if ( - pool.status === 'active' && - pool.beefyFeeConfig !== undefined && - pool.beefyFeeConfig !== feeConfig - ) { - console.log( - `Pool ${pool.id} should update beefy fee config. From: ${pool.beefyFeeConfig} To: ${feeConfig}` - ); - - if (!('beefyFeeConfig' in updates)) updates['beefyFeeConfig'] = {}; - if (!(chain in updates.beefyFeeConfig)) updates.beefyFeeConfig[chain] = {}; - - if (pool.stratOwner in updates.beefyFeeConfig[chain]) { - updates.beefyFeeConfig[chain][pool.stratOwner].push(pool.strategy); - } else { - updates.beefyFeeConfig[chain][pool.stratOwner] = [pool.strategy]; - } - } - - return updates; -}; - -const isHarvestOnDepositCorrect = (pool, chain, updates) => { - if ( - pool.status === 'active' && - pool.harvestOnDeposit !== undefined && - !nonHarvestOnDepositChains.includes(chain) && - !nonHarvestOnDepositPools.includes(pool.id) && - pool.harvestOnDeposit !== true - ) { - console.log( - `Pool ${pool.id} should update to harvest on deposit. From: ${pool.harvestOnDeposit} To: true` - ); - - if (!('harvestOnDeposit' in updates)) updates['harvestOnDeposit'] = {}; - if (!(chain in updates.harvestOnDeposit)) updates.harvestOnDeposit[chain] = {}; - - if (pool.harvestOnDeposit in updates.harvestOnDeposit[chain]) { - updates.harvestOnDeposit[chain][pool.harvestOnDeposit].push(pool.harvestOnDeposit); - } else { - updates.harvestOnDeposit[chain][pool.harvestOnDeposit] = [pool.harvestOnDeposit]; - } - } - - return updates; -}; - -const checkPointsStructureIds = pool => { - let exitCode = 0; - - if (pool.pointStructureIds && pool.pointStructureIds.length > 0) { - const invalidPointStructureIds = pool.pointStructureIds!.filter( - p => !validPointProviderIds.includes(p) - ); - if (invalidPointStructureIds.length > 0) { - console.error( - `Error: ${pool.id} : pointStructureIds ${invalidPointStructureIds} not present in points.json` - ); - exitCode = 1; - } - } - - // check for the provider eligibility - for (const pointProvider of pointProviders) { - const hasProvider = pool.pointStructureIds?.includes(pointProvider.id) ?? false; - - const shouldHaveProviderArr: boolean[] = []; - for (const eligibility of pointProvider.eligibility) { - if (eligibility.type === 'token-by-provider') { - if (!('tokens' in eligibility)) { - throw new Error(`Error: ${pointProvider.id} : eligibility.tokens missing`); - } - if (!('tokenProviderId' in eligibility)) { - throw new Error(`Error: ${pointProvider.id} : eligibility.tokenProviderId missing`); - } - - shouldHaveProviderArr.push( - (pool.tokenProviderId === eligibility.tokenProviderId && - pool.assets?.some(a => eligibility.tokens?.includes(a))) ?? - false - ); - } else if (eligibility.type === 'token-on-platform') { - if (!('tokens' in eligibility)) { - throw new Error(`Error: ${pointProvider.id} : eligibility.tokens missing`); - } - if (!('platformId' in eligibility)) { - throw new Error(`Error: ${pointProvider.id} : eligibility.platformId missing`); - } - - shouldHaveProviderArr.push( - (eligibility.platformId === pool.platformId && - pool.assets?.some(a => eligibility.tokens.includes(a))) ?? - false - ); - } else if (eligibility.type === 'token-holding') { - if (!('tokens' in eligibility)) { - throw new Error(`Error: ${pointProvider.id} : eligibility.tokens missing`); - } - - shouldHaveProviderArr.push( - pool.assets?.some(a => eligibility?.tokens?.includes(a)) ?? false - ); - } else if (eligibility.type === 'on-chain-lp') { - if (!('chain' in eligibility)) { - throw new Error(`Error: ${pointProvider.id} : eligibility.chain missing`); - } - - shouldHaveProviderArr.push(pool.network === eligibility.chain); - } else if (eligibility.type === 'earned-token-name-regex') { - if (!('regex' in eligibility)) { - throw new Error(`Error: ${pointProvider.id} : eligibility.regex missing`); - } - const earnedToken = pool.earnedToken; - const regex = new RegExp(eligibility.regex as string); - shouldHaveProviderArr.push(regex.test(earnedToken)); - } else if (eligibility.type === 'vault-whitelist') { - shouldHaveProviderArr.push(hasProvider); - } - } - - // bool or - const shouldHaveProvider = shouldHaveProviderArr.some(Boolean); - - if (shouldHaveProvider && !hasProvider) { - console.error( - `Error: ${pool.id} : pointStructureId ${pointProvider.id} should be present in pointStructureIds` - ); - exitCode = 1; - } else if (!shouldHaveProvider && hasProvider) { - console.error( - `Error: ${pool.id} : pointStructureId ${pointProvider.id} should NOT be present in pointStructureIds` - ); - exitCode = 1; - } - } - - return exitCode; -}; - -// Helpers to populate required addresses. - -type VaultConfigWithGovData = Omit & { - type: NonNullable; - rewardPoolOwner: string | undefined; -}; -const populateGovData = async ( - chain, - pools: VaultConfig[], - web3, - retries = 5 -): Promise => { - try { - const multicall = new MultiCall(web3, addressBook[chain].platforms.beefyfinance.multicall); - - const calls = pools.map(pool => { - const vaultContract = new web3.eth.Contract( - StandardVaultAbi as unknown as AbiItem[], - pool.earnContractAddress - ); - return { - owner: vaultContract.methods.owner(), - }; - }); - - try { - const [results] = await multicall.all([calls]); - return pools.map((pool, i) => { - return { - ...pool, - type: pool.type || 'gov', - rewardPoolOwner: results[i].owner, - }; - }); - } catch (e) { - if (retries > 0) { - console.warn(`retrying populateGovData ${e.message}`); - await sleep(1_000); - return populateGovData(chain, pools, web3, retries - 1); - } - throw e; - } - } catch (e) { - throw new Error(`Failed to populate gov data for ${chain}`, { cause: e }); - } -}; - -type ClmVaultConfig = Omit & { type: 'cowcentrated' }; -type VaultConfigWithCowcentratedData = ClmVaultConfig & { - token0: string; - token1: string; - oracleForToken0: boolean; - oracleForToken1: boolean; -}; - -const populateCowcentratedData = async ( - chain: keyof typeof addressBook, - pools: VaultConfig[], - web3: Web3, - retries = 5 -): Promise => { - try { - const clms = pools.filter((p): p is ClmVaultConfig => p.type === 'cowcentrated'); - if (clms.length === 0) { - return []; - } - - const { multicall: multicallAddress, beefyOracle: beefyOracleAddress } = - addressBook[chain].platforms.beefyfinance; - if (!multicallAddress || !beefyOracleAddress) { - throw new Error('Missing multicall or beefyOracle address'); - } - - const multicall = new MultiCall(web3, multicallAddress); - const beefyOracle = new web3.eth.Contract( - [ - { - inputs: [ - { - internalType: 'address', - name: '', - type: 'address', - }, - ], - name: 'subOracle', - outputs: [ - { - internalType: 'address', - name: 'oracle', - type: 'address', - }, - { - internalType: 'bytes', - name: 'data', - type: 'bytes', - }, - ], - stateMutability: 'view', - type: 'function', - }, - { - inputs: [ - { - internalType: 'address', - name: '', - type: 'address', - }, - { - internalType: 'address', - name: '', - type: 'address', - }, - ], - name: 'subOracle', - outputs: [ - { - internalType: 'address', - name: 'oracle', - type: 'address', - }, - { - internalType: 'bytes', - name: 'data', - type: 'bytes', - }, - ], - stateMutability: 'view', - type: 'function', - }, - ], - beefyOracleAddress - ); - const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000'; - - try { - const tokenResults = ( - await multicall.all([ - clms.map(clm => { - const vaultContract = new web3.eth.Contract( - [ - { - inputs: [], - name: 'wants', - outputs: [ - { - internalType: 'address', - name: 'token0', - type: 'address', - }, - { - internalType: 'address', - name: 'token1', - type: 'address', - }, - ], - stateMutability: 'view', - type: 'function', - }, - ], - clm.earnContractAddress - ); - return { - wants: vaultContract.methods.wants(), - }; - }), - ]) - ) - .flat() - .map(result => ({ - token0: result.wants[0], - token1: result.wants[1], - })); - - const oracleResults = ( - await multicall.all([ - tokenResults.map(result => ({ - token0: beefyOracle.methods.subOracle(result.token0), - token1: beefyOracle.methods.subOracle(result.token1), - token0For0xZero: beefyOracle.methods.subOracle(ZERO_ADDRESS, result.token0), - token1For0xZero: beefyOracle.methods.subOracle(ZERO_ADDRESS, result.token1), - })), - ]) - ) - .flat() - .map(result => ({ - oracleForToken0: - (result.token0 && result.token0[0] && result.token0[0] !== ZERO_ADDRESS) || - (result.token0For0xZero && - result.token0For0xZero[0] && - result.token0For0xZero[0] !== ZERO_ADDRESS), - oracleForToken1: - (result.token1 && result.token1[0] && result.token1[0] !== ZERO_ADDRESS) || - (result.token1For0xZero && - result.token1For0xZero[0] && - result.token1For0xZero[0] !== ZERO_ADDRESS), - })); - - return clms.map((clm, i) => ({ - ...clm, - token0: tokenResults[i].token0, - token1: tokenResults[i].token1, - oracleForToken0: oracleResults[i].oracleForToken0, - oracleForToken1: oracleResults[i].oracleForToken1, - })); - } catch (e) { - if (retries > 0) { - console.warn(`retrying populateCowcentratedData ${e.message}`); - await sleep(1_000); - return populateCowcentratedData(chain, pools, web3, retries - 1); - } - throw e; - } - } catch (e) { - throw new Error(`Failed to populate cowcentrated data for ${chain}`, { cause: e }); - } -}; - -type VaultConfigWithVaultData = Omit & { - type: NonNullable; - strategy: string | undefined; - vaultOwner: string | undefined; - totalSupply: string | undefined; -}; -const populateVaultsData = async ( - chain, - pools: VaultConfig[], - web3, - retries = 5 -): Promise => { - try { - const multicall = new MultiCall(web3, addressBook[chain].platforms.beefyfinance.multicall); - - const calls = pools.map(pool => { - const vaultContract = new web3.eth.Contract( - StandardVaultAbi as unknown as AbiItem[], - pool.earnContractAddress - ); - return { - strategy: vaultContract.methods.strategy(), - owner: vaultContract.methods.owner(), - totalSupply: vaultContract.methods.totalSupply(), - }; - }); - - try { - const [results] = await multicall.all([calls]); - - return pools.map((pool, i) => { - return { - ...pool, - type: pool.type || 'standard', - strategy: results[i].strategy, - vaultOwner: results[i].owner, - totalSupply: results[i].totalSupply, - }; - }); - } catch (e) { - if (retries > 0) { - console.warn(`retrying populateVaultsData ${e.message}`); - await sleep(1_000); - return populateVaultsData(chain, pools, web3, retries - 1); - } - throw e; - } - } catch (e) { - throw new Error(`Failed to populate vault data for ${chain}`, { cause: e }); - } -}; - -type VaultConfigWithStrategyData = VaultConfigWithVaultData & { - keeper: string | undefined; - beefyFeeRecipient: string | undefined; - beefyFeeConfig: string | undefined; - stratOwner: string | undefined; - harvestOnDeposit: boolean | undefined; -}; -const populateStrategyData = async ( - chain, - pools: VaultConfigWithVaultData[], - web3, - retries = 5 -): Promise => { - const multicall = new MultiCall(web3, addressBook[chain].platforms.beefyfinance.multicall); - - const calls = pools.map(pool => { - const stratContract = new web3.eth.Contract(strategyABI, pool.strategy); - return { - keeper: stratContract.methods.keeper(), - beefyFeeRecipient: stratContract.methods.beefyFeeRecipient(), - beefyFeeConfig: stratContract.methods.beefyFeeConfig(), - owner: stratContract.methods.owner(), - harvestOnDeposit: stratContract.methods.harvestOnDeposit(), - }; - }); - - try { - const [results] = await multicall.all([calls]); - - return pools.map((pool, i) => { - return { - ...pool, - keeper: results[i].keeper, - beefyFeeRecipient: results[i].beefyFeeRecipient, - beefyFeeConfig: results[i].beefyFeeConfig, - stratOwner: results[i].owner, - harvestOnDeposit: results[i].harvestOnDeposit, - }; - }); - } catch (e) { - if (retries > 0) { - console.warn(`retrying populateStrategyData ${e.message}`); - await sleep(1_000); - return populateStrategyData(chain, pools, web3, retries - 1); - } - throw e; - } -}; - -async function validatePlatformTypes(): Promise { - let exitCode = 0; - // hack to make sure all the platform types in PlatformType are present in the set - const validTypes = new Set( - Object.keys({ - amm: true, - alm: true, - bridge: true, - 'money-market': true, - perps: true, - 'yield-boost': true, - farm: true, - } satisfies Record) - ); + return { success, summary }; +} - // Check if valid types have i18n keys - for (const type of validTypes.keys()) { - const requiredKeys = [ - `Details-Platform-Type-Description-${type}`, - `Details-Platform-Type-${type}`, - ]; - for (const key of requiredKeys) { - if (!i18keys[key]) { - console.error(`Missing i18n key "${key}" for platform type "${type}"`); - exitCode = 1; - } - } +// Platform config +async function validatePlatformConfig(_globalContext: GlobalValidateContext): Promise { + if (!(await isPlatformConfigValid())) { + return 1; } - const platformsWithType = platforms.filter( - ( - platform - ): platform is Extract< - (typeof platforms)[number], - { - type: string; - } - > => !!platform.type - ); - await Promise.all( - platformsWithType.map(async platform => { - // Check type is valid - if (!validTypes.has(platform.type)) { - console.error(`Platform ${platform.id}: Invalid type "${platform.type}"`); - exitCode = 1; - } - - // Platform image must exist if platform has a type - const possiblePaths = [ - `./src/images/platforms/${platform.id}.svg`, - `./src/images/platforms/${platform.id}.png`, - ]; - let found = false; - for (const path of possiblePaths) { - if (await fileExists(path)) { - found = true; - break; - } - } - if (!found) { - console.error(`Platform ${platform.id}: Missing image: "${possiblePaths[0]}"`); - exitCode = 1; - } - }) - ); - - return exitCode; + return 0; } -const override = (pools: VaultConfigWithVaultData[]): VaultConfigWithVaultData[] => { - Object.keys(overrides).forEach(id => { - pools - .filter(p => p.id.includes(id)) - .forEach(pool => { - const override = overrides[id]; - Object.keys(override).forEach(key => { - pool[key] = override[key]; - }); - }); - }); - return pools; -}; - -validatePools() +validateEverything({ + verbose: process.argv.includes('--verbose'), + noColor: process.argv.includes('--no-color'), +}) .then(exitCode => process.exit(exitCode)) .catch(err => { - console.error(err); + pconsole.error(err); process.exit(-1); }); diff --git a/src/config/abi/StandardVaultAbi.ts b/src/config/abi/StandardVaultAbi.ts index 5ec5e88434..36194988b5 100644 --- a/src/config/abi/StandardVaultAbi.ts +++ b/src/config/abi/StandardVaultAbi.ts @@ -621,6 +621,19 @@ export const StandardVaultAbi = [ stateMutability: 'view', type: 'function', }, + { + inputs: [], + name: 'want', + outputs: [ + { + internalType: 'address', + name: '', + type: 'address', + }, + ], + stateMutability: 'view', + type: 'function', + }, { constant: true, inputs: [], diff --git a/yarn.lock b/yarn.lock index 37ae6181d7..7880441bc4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3122,7 +3122,7 @@ chalk@^1.0.0: strip-ansi "^3.0.0" supports-color "^2.0.0" -chalk@^4.0.0, chalk@^4.1.0, chalk@^4.1.1: +chalk@^4.0.0, chalk@^4.1.0, chalk@^4.1.1, chalk@^4.1.2: version "4.1.2" resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01" integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==