diff --git a/packages/contracts/.openzeppelin/bsc.json b/packages/contracts/.openzeppelin/bsc.json index fdd3adc2..e20a7fb5 100644 --- a/packages/contracts/.openzeppelin/bsc.json +++ b/packages/contracts/.openzeppelin/bsc.json @@ -203,6 +203,26 @@ "address": "0x010c821d388F0dC941e8D0cc0b82DfD22dd4F6A2", "txHash": "0xe160b16d99bf2d4c1f248b59f2605d3109e812f5a8cfbbaf69a93bedf7b5f60d", "kind": "uups" + }, + { + "address": "0xe0b01300125E44eaFC09E0Ed56270209e05d3cd3", + "txHash": "0xc61a9940399b5341de92979cb9857e7d722a2c446653ade95370125057641941", + "kind": "uups" + }, + { + "address": "0x1eeE96dD36A72AB53dD8F3d617A907a24Dec1678", + "txHash": "0x87fe8260c5a41c080ab27d0f75df092f939462f1cbb6c8fdbcd7f3fa3a7c2a46", + "kind": "uups" + }, + { + "address": "0x7E25E4F4b695c63947ce033611299004d6089212", + "txHash": "0xeb204e06f7cf828e162cc120c6893bc8513f4df655e364105d9ded4cda5267ba", + "kind": "uups" + }, + { + "address": "0x343Ed658945E523896B058aFb4d0764217726147", + "txHash": "0x5c7d2206ec091c9a1f0a26f609f93ba78edddc0a169f59165959d982f6c77685", + "kind": "uups" } ], "impls": { @@ -7528,6 +7548,375 @@ }, "namespaces": {} } + }, + "97074c53ceeec85c10afb7952f3da2f49d56350ca5be6f2b732c494f150f86f5": { + "address": "0x22F57e14eB2c61650a13D83820434F196cf9D7A8", + "txHash": "0xb0d5860435fe7633a3f61087144748b6c5a46930dbe447c7fe8c29bf5a53c0a1", + "layout": { + "solcVersion": "0.8.26", + "storage": [ + { + "label": "_initialized", + "offset": 0, + "slot": "0", + "type": "t_uint8", + "contract": "Initializable", + "src": "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol:63", + "retypedFrom": "bool" + }, + { + "label": "_initializing", + "offset": 1, + "slot": "0", + "type": "t_bool", + "contract": "Initializable", + "src": "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol:68" + }, + { + "label": "__gap", + "offset": 0, + "slot": "1", + "type": "t_array(t_uint256)50_storage", + "contract": "ERC1967UpgradeUpgradeable", + "src": "@openzeppelin/contracts-upgradeable/proxy/ERC1967/ERC1967UpgradeUpgradeable.sol:169" + }, + { + "label": "__gap", + "offset": 0, + "slot": "51", + "type": "t_array(t_uint256)50_storage", + "contract": "UUPSUpgradeable", + "src": "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol:111" + }, + { + "label": "__gap", + "offset": 0, + "slot": "101", + "type": "t_array(t_uint256)50_storage", + "contract": "SafeInitializable", + "src": "src/upgradeable/SafeInitializable.sol:15" + }, + { + "label": "__gap", + "offset": 0, + "slot": "151", + "type": "t_array(t_uint256)50_storage", + "contract": "ContextUpgradeable", + "src": "@openzeppelin/contracts-upgradeable/utils/ContextUpgradeable.sol:40" + }, + { + "label": "_status", + "offset": 0, + "slot": "201", + "type": "t_uint256", + "contract": "ReentrancyGuardUpgradeable", + "src": "@openzeppelin/contracts-upgradeable/security/ReentrancyGuardUpgradeable.sol:38" + }, + { + "label": "__gap", + "offset": 0, + "slot": "202", + "type": "t_array(t_uint256)49_storage", + "contract": "ReentrancyGuardUpgradeable", + "src": "@openzeppelin/contracts-upgradeable/security/ReentrancyGuardUpgradeable.sol:88" + }, + { + "label": "lastWorkTime", + "offset": 0, + "slot": "251", + "type": "t_uint256", + "contract": "Job", + "src": "src/automation/Job.sol:34" + }, + { + "label": "minimumBetweenExecutions", + "offset": 0, + "slot": "252", + "type": "t_uint256", + "contract": "Job", + "src": "src/automation/Job.sol:39" + }, + { + "label": "__gap", + "offset": 0, + "slot": "253", + "type": "t_array(t_uint256)50_storage", + "contract": "Job", + "src": "src/automation/Job.sol:46" + }, + { + "label": "ops", + "offset": 0, + "slot": "303", + "type": "t_contract(IOps)10756", + "contract": "OpsReady", + "src": "src/automation/gelato/OpsReady.sol:28" + }, + { + "label": "gelato", + "offset": 0, + "slot": "304", + "type": "t_address_payable", + "contract": "OpsReady", + "src": "src/automation/gelato/OpsReady.sol:29" + }, + { + "label": "__gap", + "offset": 0, + "slot": "305", + "type": "t_array(t_uint256)50_storage", + "contract": "OpsReady", + "src": "src/automation/gelato/OpsReady.sol:41" + }, + { + "label": "isPrepaid", + "offset": 0, + "slot": "355", + "type": "t_bool", + "contract": "GelatoJobAdapter", + "src": "src/automation/GelatoJobAdapter.sol:16" + }, + { + "label": "__gap", + "offset": 0, + "slot": "356", + "type": "t_array(t_uint256)50_storage", + "contract": "GelatoJobAdapter", + "src": "src/automation/GelatoJobAdapter.sol:23" + }, + { + "label": "_owner", + "offset": 0, + "slot": "406", + "type": "t_address", + "contract": "OwnableUpgradeable", + "src": "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol:22" + }, + { + "label": "__gap", + "offset": 0, + "slot": "407", + "type": "t_array(t_uint256)49_storage", + "contract": "OwnableUpgradeable", + "src": "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol:94" + }, + { + "label": "__gap", + "offset": 0, + "slot": "456", + "type": "t_array(t_uint256)50_storage", + "contract": "SafeUUPSUpgradeable", + "src": "src/upgradeable/SafeUUPSUpgradeable.sol:19" + }, + { + "label": "healthCheck", + "offset": 0, + "slot": "506", + "type": "t_contract(IHealthCheck)11341", + "contract": "HealthChecker", + "src": "src/healthcheck/HealthChecker.sol:19" + }, + { + "label": "healthCheckEnabled", + "offset": 20, + "slot": "506", + "type": "t_bool", + "contract": "HealthChecker", + "src": "src/healthcheck/HealthChecker.sol:20" + }, + { + "label": "__gap", + "offset": 0, + "slot": "507", + "type": "t_array(t_uint256)50_storage", + "contract": "HealthChecker", + "src": "src/healthcheck/HealthChecker.sol:27" + }, + { + "label": "_paused", + "offset": 0, + "slot": "557", + "type": "t_bool", + "contract": "PausableUpgradeable", + "src": "@openzeppelin/contracts-upgradeable/security/PausableUpgradeable.sol:29" + }, + { + "label": "__gap", + "offset": 0, + "slot": "558", + "type": "t_array(t_uint256)49_storage", + "contract": "PausableUpgradeable", + "src": "@openzeppelin/contracts-upgradeable/security/PausableUpgradeable.sol:116" + }, + { + "label": "lender", + "offset": 0, + "slot": "607", + "type": "t_contract(IStrategiesLender)11604", + "contract": "BaseStrategy", + "src": "src/strategies/BaseStrategy.sol:32" + }, + { + "label": "asset", + "offset": 0, + "slot": "608", + "type": "t_contract(IERC20Upgradeable)1579", + "contract": "BaseStrategy", + "src": "src/strategies/BaseStrategy.sol:33" + }, + { + "label": "debtThreshold", + "offset": 0, + "slot": "609", + "type": "t_uint256", + "contract": "BaseStrategy", + "src": "src/strategies/BaseStrategy.sol:36" + }, + { + "label": "estimatedWorkGas", + "offset": 0, + "slot": "610", + "type": "t_uint256", + "contract": "BaseStrategy", + "src": "src/strategies/BaseStrategy.sol:39" + }, + { + "label": "profitFactor", + "offset": 0, + "slot": "611", + "type": "t_uint256", + "contract": "BaseStrategy", + "src": "src/strategies/BaseStrategy.sol:42" + }, + { + "label": "_nativeTokenPriceFeed", + "offset": 0, + "slot": "612", + "type": "t_contract(AggregatorV3Interface)45", + "contract": "BaseStrategy", + "src": "src/strategies/BaseStrategy.sol:45" + }, + { + "label": "_assetPriceFeed", + "offset": 0, + "slot": "613", + "type": "t_contract(AggregatorV3Interface)45", + "contract": "BaseStrategy", + "src": "src/strategies/BaseStrategy.sol:48" + }, + { + "label": "_assetDecimals", + "offset": 0, + "slot": "614", + "type": "t_uint256", + "contract": "BaseStrategy", + "src": "src/strategies/BaseStrategy.sol:51" + }, + { + "label": "__gap", + "offset": 0, + "slot": "615", + "type": "t_array(t_uint256)50_storage", + "contract": "BaseStrategy", + "src": "src/strategies/BaseStrategy.sol:58" + }, + { + "label": "pool", + "offset": 0, + "slot": "665", + "type": "t_contract(IAavePool)16746", + "contract": "AaveSupplyStrategy", + "src": "src/strategies/AaveSupplyStrategy.sol:22" + }, + { + "label": "aToken", + "offset": 0, + "slot": "666", + "type": "t_contract(IERC20Upgradeable)1579", + "contract": "AaveSupplyStrategy", + "src": "src/strategies/AaveSupplyStrategy.sol:23" + }, + { + "label": "millisecondsPerBlock", + "offset": 0, + "slot": "667", + "type": "t_uint256", + "contract": "AaveSupplyStrategy", + "src": "src/strategies/AaveSupplyStrategy.sol:24" + }, + { + "label": "aaveVersion", + "offset": 0, + "slot": "668", + "type": "t_uint256", + "contract": "AaveSupplyStrategy", + "src": "src/strategies/AaveSupplyStrategy.sol:25" + }, + { + "label": "__gap", + "offset": 0, + "slot": "669", + "type": "t_array(t_uint256)50_storage", + "contract": "AaveSupplyStrategy", + "src": "src/strategies/AaveSupplyStrategy.sol:29" + } + ], + "types": { + "t_address": { + "label": "address", + "numberOfBytes": "20" + }, + "t_address_payable": { + "label": "address payable", + "numberOfBytes": "20" + }, + "t_array(t_uint256)49_storage": { + "label": "uint256[49]", + "numberOfBytes": "1568" + }, + "t_array(t_uint256)50_storage": { + "label": "uint256[50]", + "numberOfBytes": "1600" + }, + "t_bool": { + "label": "bool", + "numberOfBytes": "1" + }, + "t_contract(AggregatorV3Interface)45": { + "label": "contract AggregatorV3Interface", + "numberOfBytes": "20" + }, + "t_contract(IAavePool)16746": { + "label": "contract IAavePool", + "numberOfBytes": "20" + }, + "t_contract(IERC20Upgradeable)1579": { + "label": "contract IERC20Upgradeable", + "numberOfBytes": "20" + }, + "t_contract(IHealthCheck)11341": { + "label": "contract IHealthCheck", + "numberOfBytes": "20" + }, + "t_contract(IOps)10756": { + "label": "contract IOps", + "numberOfBytes": "20" + }, + "t_contract(IStrategiesLender)11604": { + "label": "contract IStrategiesLender", + "numberOfBytes": "20" + }, + "t_uint256": { + "label": "uint256", + "numberOfBytes": "32" + }, + "t_uint8": { + "label": "uint8", + "numberOfBytes": "1" + } + }, + "namespaces": {} + } } } } diff --git a/packages/contracts/.proxies/bsc_mainnet_dev.json b/packages/contracts/.proxies/bsc_mainnet_dev.json index 5e9ed44a..ea5fb41c 100644 --- a/packages/contracts/.proxies/bsc_mainnet_dev.json +++ b/packages/contracts/.proxies/bsc_mainnet_dev.json @@ -19,5 +19,11 @@ "USDC": "0x8450D8f56E68630b37f18B51A09bcFEab5C60A89", "USDT": "0x1D0cC9fa6AcBdD11a9BF1153628A634313496878", "WETH": "0x5F4Ff008813F78E96fa584Cd35aE8B5610eAe2d5" + }, + "AaveSupplyStrategy": { + "USDC": "0xe0b01300125E44eaFC09E0Ed56270209e05d3cd3", + "USDT": "0x1eeE96dD36A72AB53dD8F3d617A907a24Dec1678", + "BTCB": "0x7E25E4F4b695c63947ce033611299004d6089212", + "WETH": "0x343Ed658945E523896B058aFb4d0764217726147" } } \ No newline at end of file diff --git a/packages/contracts/hardhat.config.ts b/packages/contracts/hardhat.config.ts index f515cb0a..b9771a5a 100644 --- a/packages/contracts/hardhat.config.ts +++ b/packages/contracts/hardhat.config.ts @@ -89,6 +89,11 @@ function getPathForTests(root = './test') { return chain === Chain.UNKNOWN ? root : `${root}/integration/${chain.toLowerCase()}` } -config.networks = config.availableNetworks +config.networks = { + ...config.availableNetworks, + localhost: { + url: 'http://127.0.0.1:8545', + }, +} export default config diff --git a/packages/contracts/hardhat/deployment/addresses/AaveV3LikePool.ts b/packages/contracts/hardhat/deployment/addresses/AaveV3LikePool.ts index 0586ecb7..764d0646 100644 --- a/packages/contracts/hardhat/deployment/addresses/AaveV3LikePool.ts +++ b/packages/contracts/hardhat/deployment/addresses/AaveV3LikePool.ts @@ -12,6 +12,9 @@ export class AaveV3LikePool extends BaseAddresses { [Chain.ETH]: { ANY_ENVIRONMENT: '0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2', }, + [Chain.BSC]: { + ANY_ENVIRONMENT: '0x6807dc923806fE8Fd134338EABCA509979a7e0cB', + }, } } diff --git a/packages/contracts/hardhat/deployment/helpers/attach-to-vault.ts b/packages/contracts/hardhat/deployment/helpers/attach-to-vault.ts index 5087f76f..e4717c62 100644 --- a/packages/contracts/hardhat/deployment/helpers/attach-to-vault.ts +++ b/packages/contracts/hardhat/deployment/helpers/attach-to-vault.ts @@ -36,6 +36,6 @@ export async function attachToVault( console.log(`Strategy attached successfully with ratio: ${debtRatio}!`) if (debtRatio === 0n) { - console.warn('Debt ratio of the strategy is 0, so it\'s not active. You should adjust it manually!') + console.log('Debt ratio of the strategy is 0, so it\'s not active. You should adjust it manually!') } } diff --git a/packages/contracts/hardhat/tasks/adjust-vault-debt-ratio.ts b/packages/contracts/hardhat/tasks/adjust-vault-debt-ratio.ts new file mode 100644 index 00000000..a0d84a71 --- /dev/null +++ b/packages/contracts/hardhat/tasks/adjust-vault-debt-ratio.ts @@ -0,0 +1,286 @@ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +import readline from 'node:readline' +import { NetworkEnvironment, TokenSymbol, resolveNetworkEnvironment } from '@eonian/upgradeable' +import { task } from 'hardhat/config' +import type { HardhatRuntimeEnvironment } from 'hardhat/types' +import _ from 'lodash' +import { Addresses } from '../deployment' + +/** + * Example: ` + * yarn hardhat adjust-vault-debt-ratio \ + * --ratio 'ApeLendingStrategy:2000;AaveSupplyStrategy:8000' \ + * --tokens 'USDT' + * --network localhost + * ` + * + * Will print: + * + * Current vault structure: + * { + * "USDT": { + * "ApeLendingStrategy": 10000, + * "AaveSupplyStrategy": 0 + * } + * } + * New vault structure: + * { + * "USDT": { + * "ApeLendingStrategy": 2000, + * "AaveSupplyStrategy": 8000 + * } + * } + * [USDT] ApeLendingStrategy: 10000 -> 2000 + * [USDT] AaveSupplyStrategy: 0 -> 8000 + * Updated vault structure: + * { + * "USDT": { + * "ApeLendingStrategy": 2000, + * "AaveSupplyStrategy": 8000 + * } + * } + */ +task('adjust-vault-debt-ratio', 'Changes debt ratio for the vault strategies') + .addOptionalParam('ratio', 'New debt ratio proportion', '') + .addOptionalParam('tokens', 'Comma-separated token symbols of corresponding vaults', '') + .setAction(async (taskArgs, hre) => { + const ratio = parseRatio(taskArgs) + const tokenSymbols = parseTokenSymbols(taskArgs) + console.log('Ratio is:', ratio) + console.log('Tokens (vaults) are:', tokenSymbols) + + const env = resolveNetworkEnvironment(hre) + if (env === NetworkEnvironment.LOCAL) { + const temp = console.log + console.log('Running in local (testing) mode. Deploying contracts...') + console.log = () => {} + await hre.run('deploy') + console.log = temp + } + + const currentStructure = await getStructure(hre, tokenSymbols) + printStructure(currentStructure, 'Current vault structure') + + if (Object.keys(ratio).length === 0) { + console.warn('Param --ratio is not set, exiting...') + return + } + + const newStructure = applyRatioToStructure(ratio, currentStructure) + printStructure(newStructure, 'New vault structure') + + const continueIfStructureCorrect = await askQuestion('Continue? y/n') + if (continueIfStructureCorrect !== 'y') { + console.log('Aborted') + return + } + + const updatedStructure = await updateDebtRatio(newStructure, tokenSymbols, hre) + printStructure(updatedStructure, 'Updated vault structure') + }) + +async function updateDebtRatio( + structure: Structure, + tokenSymbols: TokenSymbol[], + hre: HardhatRuntimeEnvironment, +): Promise { + const accounts = await hre.ethers.getSigners() + const signer = accounts[0] + + const balanceA = await hre.ethers.provider.getBalance(signer.address) + console.log(`Balance before update: ${hre.ethers.formatEther(balanceA)}`) + + const vaultTokens = Object.keys(structure) + for (const vaultToken of vaultTokens) { + const vaultStructure = structure[vaultToken as TokenSymbol]! + const strategies = _.sortBy(vaultStructure.strategies, strategy => strategy.debtRatio) + for (const strategy of strategies) { + const debtRatio = await currentDebtRatio(hre, vaultStructure.address, strategy.address) + const newDebtRatio = strategy.debtRatio + if (debtRatio === newDebtRatio) { + console.log(`[${vaultToken}] ${strategy.name}: ${debtRatio} = ${newDebtRatio}, skip...`) + continue + } + console.log(`[${vaultToken}] ${strategy.name}: ${debtRatio} -> ${newDebtRatio}`) + await retry(async () => { + const vault = await hre.ethers.getContractAt('Vault', vaultStructure.address, signer) + await vault.setBorrowerDebtRatio(strategy.address, newDebtRatio) + }) + } + } + + const balanceB = await hre.ethers.provider.getBalance(signer.address) + console.log(`Balance after update: ${hre.ethers.formatEther(balanceB)}`) + + return await getStructure(hre, tokenSymbols) +} + +async function retry(block: () => Promise, attempts = 10) { + for (let i = 1; i <= attempts; i++) { + try { + await block() + break + } + catch (e) { + if (i >= attempts) { + throw e + } + else { + console.log('Error occured, retrying...') + await new Promise(resolve => setTimeout(resolve, 1000)) + } + } + } +} + +function askQuestion(query: string) { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }) + return new Promise((resolve) => { + rl.question(`${query}\nAnswer: `, (answer) => { + rl.close() + resolve(answer) + }) + }) +} + +function applyRatioToStructure(ratio: Record, structure: Structure): Structure { + const vaultStructures = Object.values(structure) + for (const vaultStructure of vaultStructures) { + let totalRatio = 0 + for (const strategy of vaultStructure.strategies) { + const matchedRatio = ratio[strategy.name] + if (matchedRatio === undefined) { + continue + } + strategy.debtRatio = matchedRatio + totalRatio += matchedRatio + } + if (totalRatio !== 10_000) { + throw new Error('Total ratio is not 10_000!') + } + } + return structure +} + +function parseTokenSymbols(taskArgs: any): TokenSymbol[] { + const value = String(taskArgs.tokens).trim() + const availableTokenSymbols = Object.values(TokenSymbol) + if (!value) { + return availableTokenSymbols + } + const parsedTokenSymbols = value.split(',').map(symbol => symbol.trim()) as TokenSymbol[] + for (const tokenSymbol of parsedTokenSymbols) { + if (!availableTokenSymbols.includes(tokenSymbol)) { + throw new Error(`Unknown token symbol: ${tokenSymbol}`) + } + } + return parsedTokenSymbols +} + +function parseRatio(taskArgs: any): Record { + const value = String(taskArgs.ratio).trim() + if (!value) { + return {} + } + const listOfNameToRatio = value.split(';') + const result: Record = {} + + let total = 0 + for (const nameToRatio of listOfNameToRatio) { + const [name, ratio] = nameToRatio.split(':') + result[name] = +ratio + total += +ratio + } + if (total !== 10000) { + throw new Error('Total ratio should be equal to 10_000') + } + return result +} + +interface VaultStructure { + address: string + strategies: Array<{ + address: string + debtRatio: number + name: string + }> +} + +type Structure = Partial> + +async function getStructure(hre: HardhatRuntimeEnvironment, tokens: TokenSymbol[]): Promise { + const result: Structure = {} + for (const tokenSymbol of tokens) { + try { + const vaultAddress = await hre.addresses.getForToken(Addresses.VAULT, tokenSymbol) + const vault = await hre.ethers.getContractAt('Vault', vaultAddress) + + result[tokenSymbol] = { + address: vaultAddress, + strategies: [], + } + + const strategies = result[tokenSymbol].strategies + + for (let i = 0; i < Number.POSITIVE_INFINITY; i++) { + try { + const strategyAddress = await vault.withdrawalQueue(i) + const debtRatio = await currentDebtRatio(hre, vaultAddress, strategyAddress) + const contractName = await resolveStrategyContractName(strategyAddress, hre) + strategies.push({ + address: strategyAddress, + name: contractName, + debtRatio: Number(debtRatio), + }) + } + catch (e) { + break + } + } + } + catch (e) { + continue + } + } + return result +} + +async function currentDebtRatio( + hre: HardhatRuntimeEnvironment, + vaultAddress: string, + strategyAddress: string, +): Promise { + const vault = await hre.ethers.getContractAt('Vault', vaultAddress) + const debtRatio = await vault['currentDebtRatio(address)'](strategyAddress) + return Number(debtRatio) +} + +function simplifyStructure(structure: Structure) { + return _.mapValues(structure, vault => simplifyStrategies(vault!.strategies)) +} + +function simplifyStrategies(strategies: Array) { + return _.chain(strategies) + .groupBy(strategy => strategy.name) + .mapValues(strategy => strategy[0].debtRatio) + .value() +} + +function printStructure(structure: Structure, prefix?: string) { + const simplified = simplifyStructure(structure) + const stringified = JSON.stringify(simplified, null, 2) + prefix ? console.log(`${prefix}:`, '\n', stringified) : console.log(stringified) +} + +async function resolveStrategyContractName(address: string, hre: HardhatRuntimeEnvironment): Promise { + const proxyRecords = await hre.proxyRegister.getAll() + for (const record of proxyRecords) { + if (record.address === address) { + return record.contractName + } + } + throw new Error(`Cannot find strategy contract name (address: ${address})!`) +} diff --git a/packages/contracts/hardhat/tasks/deploy/deploy.ts b/packages/contracts/hardhat/tasks/deploy/deploy.ts index 6d9b7091..5a3084be 100644 --- a/packages/contracts/hardhat/tasks/deploy/deploy.ts +++ b/packages/contracts/hardhat/tasks/deploy/deploy.ts @@ -1,52 +1,65 @@ -import { task } from 'hardhat/config'; -import type { HardhatRuntimeEnvironment } from 'hardhat/types'; -import { execute } from '@eonian/upgradeable'; -import deployHealthCheck from '../../deployment/deployHealthCheck'; -import deployVault from '../../deployment/deployVault'; -import deployVFT from '../../deployment/deployVFT'; -import { getStrategyDeploymentPlan, Strategy, getStrategyDeployer } from './strategy-deployment-plan'; -import _ from 'lodash'; +import { task } from 'hardhat/config' +import type { HardhatRuntimeEnvironment } from 'hardhat/types' +import { execute } from '@eonian/upgradeable' +import _ from 'lodash' +import deployHealthCheck from '../../deployment/deployHealthCheck' +import deployVault from '../../deployment/deployVault' +import deployVFT from '../../deployment/deployVFT' +import type { Strategy, StrategyDeploymentPlan } from './strategy-deployment-plan' +import { getStrategyDeployer, getStrategyDeploymentPlan } from './strategy-deployment-plan' export const deployTask = task('deploy', 'Deploy (or upgade) production contracts', async (args, hre) => { - return await deployTaskAction(hre); -}); + const strategyDeploymentPlan = getPlanFromArguments(args) ?? getStrategyDeploymentPlan(hre) + return await deployTaskAction(hre, strategyDeploymentPlan) +}) export async function deployTaskAction( hre: HardhatRuntimeEnvironment, - strategyDeploymentPlan = getStrategyDeploymentPlan(hre) + strategyDeploymentPlan: StrategyDeploymentPlan, ) { - console.log(`Strategy deployment plan: ${JSON.stringify(strategyDeploymentPlan)}`); + console.log(`Strategy deployment plan: ${JSON.stringify(strategyDeploymentPlan)}`) - const tokens = new Set(Object.values(strategyDeploymentPlan).flat()); + const tokens = new Set(Object.values(strategyDeploymentPlan).flat()) if (tokens.size <= 0) { - console.log('No contracts to deploy, aborted'); - return; + console.log('No contracts to deploy, aborted') + return } - console.log('\nDeploying common contracts for...\n'); + console.log('\nDeploying common contracts for...\n') - await execute(deployHealthCheck, hre); + await execute(deployHealthCheck, hre) for (const token of tokens) { - console.log(`\nDeploying vault-related contracts for ${token}...\n`); - await execute(deployVault, token, hre); - await execute(deployVFT, token, hre); + console.log(`\nDeploying vault-related contracts for ${token}...\n`) + await execute(deployVault, token, hre) + await execute(deployVFT, token, hre) } - const strategies = Object.keys(strategyDeploymentPlan) as Strategy[]; + const strategies = Object.keys(strategyDeploymentPlan) as Strategy[] for (const strategy of strategies) { - console.log(`\nStarting to deploy ${strategy} strategy...`); + console.log(`\nStarting to deploy ${strategy} strategy...`) - const strategyTokens = strategyDeploymentPlan[strategy]!; + const strategyTokens = strategyDeploymentPlan[strategy]! for (const token of strategyTokens) { - console.log(`Deploying ${strategy} strategy for ${token} token...`); + console.log(`Deploying ${strategy} strategy for ${token} token...`) - const deployer = getStrategyDeployer(strategy, token); - await execute(deployer, hre); + const deployer = getStrategyDeployer(strategy, token) + await execute(deployer, hre) } } - await hre.proxyValidator.validateLastDeployments(); + await hre.proxyValidator.validateLastDeployments() - console.log('\nDeployment is done!\n'); + console.log('\nDeployment is done!\n') +} + +/** + * Gets strategy deployment plan from the hardhat task arguments. Used in tests only. + * Production plan is declared in separate file (strategy-deployment-plan.ts). + */ +function getPlanFromArguments(args: unknown): StrategyDeploymentPlan | null { + if (typeof args === 'object' && !!args && 'plan' in args && typeof args.plan === 'string') { + return JSON.parse(args.plan) as StrategyDeploymentPlan + } + return null } diff --git a/packages/contracts/hardhat/tasks/deploy/strategy-deployment-plan.ts b/packages/contracts/hardhat/tasks/deploy/strategy-deployment-plan.ts index 5766821a..0bed6866 100644 --- a/packages/contracts/hardhat/tasks/deploy/strategy-deployment-plan.ts +++ b/packages/contracts/hardhat/tasks/deploy/strategy-deployment-plan.ts @@ -1,7 +1,8 @@ -import { Chain, DeployResult, resolveChain, TokenSymbol } from '@eonian/upgradeable'; -import { HardhatRuntimeEnvironment } from 'hardhat/types'; -import deployAaveSupplyStrategy from '../../deployment/deployAaveSupplyStrategy'; -import deployApeLendingStrategy from '../../deployment/deployApeLendingStrategy'; +import type { DeployResult } from '@eonian/upgradeable' +import { Chain, TokenSymbol, resolveChain } from '@eonian/upgradeable' +import type { HardhatRuntimeEnvironment } from 'hardhat/types' +import deployAaveSupplyStrategy from '../../deployment/deployAaveSupplyStrategy' +import deployApeLendingStrategy from '../../deployment/deployApeLendingStrategy' export enum Strategy { APESWAP = 'APESWAP', @@ -9,14 +10,17 @@ export enum Strategy { AAVE_V3 = 'AAVE_V3', } +export type StrategyDeploymentPlan = Partial> + /** * Add here all the new strategies that are planned to be deployed in production. */ -const strategyDeploymentPlan: Partial>>> = { +const strategyDeploymentPlan: Partial> = { [Chain.BSC]: { [Strategy.APESWAP]: [TokenSymbol.USDC, TokenSymbol.USDT, TokenSymbol.BTCB, TokenSymbol.WETH], + [Strategy.AAVE_V3]: [TokenSymbol.USDC, TokenSymbol.USDT, TokenSymbol.BTCB, TokenSymbol.WETH], }, -}; +} /** * Deployment functions for each strategy. @@ -31,11 +35,11 @@ export function getStrategyDeployer(strategy: Strategy, token: TokenSymbol) { return (hre: HardhatRuntimeEnvironment) => deployers[strategy](token, hre) } -export function getStrategyDeploymentPlan(hre: HardhatRuntimeEnvironment) { - const chain = resolveChain(hre); - const strategies = strategyDeploymentPlan[chain]; +export function getStrategyDeploymentPlan(hre: HardhatRuntimeEnvironment): StrategyDeploymentPlan { + const chain = resolveChain(hre) + const strategies = strategyDeploymentPlan[chain] if (!strategies) { - throw new Error(`No strategies to deploy for "${chain}" chain!`); - } + throw new Error(`No strategies to deploy for "${chain}" chain!`) + } return strategies } diff --git a/packages/contracts/hardhat/tasks/index.ts b/packages/contracts/hardhat/tasks/index.ts index b9f52fa5..67e0ba52 100644 --- a/packages/contracts/hardhat/tasks/index.ts +++ b/packages/contracts/hardhat/tasks/index.ts @@ -1,6 +1,7 @@ import './deploy' import './deploy-error-catcher' import './transfer-ownership' +import './adjust-vault-debt-ratio' export * from './accounts' export * from './start-hardhat-node' diff --git a/packages/contracts/test/integration/bsc/bsc-deploy-task.test.ts b/packages/contracts/test/integration/bsc/bsc-deploy-task.test.ts index 28345972..a41f3a07 100644 --- a/packages/contracts/test/integration/bsc/bsc-deploy-task.test.ts +++ b/packages/contracts/test/integration/bsc/bsc-deploy-task.test.ts @@ -2,9 +2,11 @@ import hre from 'hardhat' import * as helpers from '@nomicfoundation/hardhat-network-helpers' import { expect } from 'chai' import { type DeployResult, DeployStatus, TokenSymbol } from '@eonian/upgradeable' +import type { ContractName } from 'hardhat/types' import { deleteErrorFile } from '../../../hardhat/tasks/deploy-error-catcher' import { clearDeployments } from '../../deploy/helpers' -import { Addresses } from '../../../hardhat/deployment' +import { getStrategyDeploymentPlan } from '../../../hardhat/tasks/deploy/strategy-deployment-plan' +import type { Strategy, StrategyDeploymentPlan } from '../../../hardhat/tasks/deploy/strategy-deployment-plan' describe('BSC Deploy Task', () => { clearDeployments(hre) @@ -41,7 +43,13 @@ describe('BSC Deploy Task', () => { } } - await hre.run('deploy', { tokens: [TokenSymbol.USDC].join(), strategies: [Addresses.APESWAP].join() }) + const deployTaskArgs = { + plan: JSON.stringify({ + APESWAP: [TokenSymbol.USDC], + } satisfies StrategyDeploymentPlan), + } + + await hre.run('deploy', deployTaskArgs) await expect(hre.run('check-deploy-error')).to.be.rejectedWith(Error, 'sender doesn\'t have enough funds to send tx') }) @@ -56,8 +64,14 @@ describe('BSC Deploy Task', () => { } } + const deployTaskArgs = { + plan: JSON.stringify({ + APESWAP: [TokenSymbol.USDC], + } satisfies StrategyDeploymentPlan), + } + // First deploy. Only the first proxy should be deployed. - await hre.run('deploy', { tokens: [TokenSymbol.USDC].join(), strategies: [Addresses.APESWAP].join() }) + await hre.run('deploy', deployTaskArgs) const [firstDeployedProxy, ...restProxies] = getDeployments(TokenSymbol.USDC) expect(firstDeployedProxy.status).to.be.equal(DeployStatus.DEPLOYED) expect(restProxies.length).to.be.equal(0) @@ -66,7 +80,7 @@ describe('BSC Deploy Task', () => { // Second deploy. The first proxy should be skipped, but the rest ones are deployed. await setDeployerBalance(100n * 10n ** 18n) - await hre.run('deploy', { tokens: [TokenSymbol.USDC].join(), strategies: [Addresses.APESWAP].join() }) + await hre.run('deploy', deployTaskArgs) const [firstProxy, ...restDeployedProxies] = getDeployments(TokenSymbol.USDC) expect(firstProxy.status).to.be.equal(DeployStatus.NONE) expect(restDeployedProxies.every(deployment => deployment.status === DeployStatus.DEPLOYED)).to.be.equal(true) @@ -74,6 +88,44 @@ describe('BSC Deploy Task', () => { // No errors should be thrown. await expect(hre.run('check-deploy-error')).not.to.be.rejectedWith(Error) }) + + it('Should deploy every strategy from deployment-plan', async () => { + process.env.CI = 'true' + + await setDeployerBalance(100n * 10n ** 18n) + await hre.run('deploy') + + const plan = getStrategyDeploymentPlan(hre) + const strategyToContractNameLookup: Partial> = { + AAVE_V3: 'AaveSupplyStrategy', + APESWAP: 'ApeLendingStrategy', + } + + const vaultAddresses = new Set() + + const strategies = Object.keys(plan) as Strategy[] + for (const strategy of strategies) { + const contractName = strategyToContractNameLookup[strategy]! + const tokens = plan[strategy]! + for (const token of tokens) { + const deployment = getDeployment(contractName, token) + const strategyContract = await hre.ethers.getContractAt('BaseStrategy', deployment.proxyAddress) + + const vaultAddress = await strategyContract.lender() + const vaultDeployment = getDeployment('Vault', token) + expect(vaultAddress, 'Different vault').to.be.eq(vaultDeployment.proxyAddress) + + const vault = await hre.ethers.getContractAt('Vault', vaultAddress) + const strategyData = await vault.borrowersData(deployment.proxyAddress) + + expect(strategyData.activationTimestamp, 'Strategy is not active!').to.be.greaterThan(0) + + vaultAddresses.add(vaultAddress) + } + } + + expect(vaultAddresses.size).to.be.eq(4) + }) }) function getDeployments(token: TokenSymbol): DeployResult[] { @@ -81,6 +133,13 @@ function getDeployments(token: TokenSymbol): DeployResult[] { return deployments.filter(deployment => deployment.deploymentId === token || !deployment.deploymentId) } +function getDeployment(contractName: ContractName, token: TokenSymbol): DeployResult { + const deployments = Object.values(hre.lastDeployments) + const deployment = deployments.find(deployment => deployment.deploymentId === token && deployment.contractName === contractName) + expect(deployment, `Missing deployment for ${contractName} (${token})`).not.to.be.eq(undefined) + return deployment! +} + async function setDeployerBalance(balance: bigint) { const [deployer] = await hre.ethers.getSigners() await helpers.setBalance(deployer.address, balance) diff --git a/packages/contracts/test/integration/bsc/debt-ratio-change.test.ts b/packages/contracts/test/integration/bsc/debt-ratio-change.test.ts new file mode 100644 index 00000000..0c00ed74 --- /dev/null +++ b/packages/contracts/test/integration/bsc/debt-ratio-change.test.ts @@ -0,0 +1,330 @@ +import hre from 'hardhat' +import { expect } from 'chai' +import * as helpers from '@nomicfoundation/hardhat-network-helpers' +import { TokenSymbol } from '@eonian/upgradeable' +import type { HardhatEthersSigner } from '@nomicfoundation/hardhat-ethers/signers' +import _ from 'lodash' +import type { ContractName } from 'hardhat/types' +import { clearDeployments } from '../../deploy/helpers' +import { deployTaskAction } from '../../../hardhat/tasks' +import { Addresses } from '../../../hardhat/deployment' +import type { BaseStrategy, IERC20, Vault } from '../../../typechain-types' +import { getContractAt } from '../helpers/get-contract-at' +import { getAddress } from '../helpers/get-address' +import { depositToVault } from '../helpers/vault-deposit-withdraw' +import warp from '../helpers/warp' +import { Strategy } from '../../../hardhat/tasks/deploy/strategy-deployment-plan' +import { expectAlmostEqual } from '../helpers/expect-almost-equal' + +const { ethers } = hre +const token = TokenSymbol.USDC + +describeDebtRatioChange(Strategy.AAVE_V3, 'AaveSupplyStrategy') +describeDebtRatioChange(Strategy.APESWAP, 'ApeLendingStrategy') + +function describeDebtRatioChange(startegy: Strategy, contractName: ContractName) { + describe(`Test debt ratio change (${startegy})`, () => suite(startegy, contractName)) +} + +function suite(startegy: Strategy, contractName: ContractName) { + clearDeployments(hre) + + const minReportInterval = 3600 + + let holderA: HardhatEthersSigner + let holderB: HardhatEthersSigner + + let vault: Vault + let assetToken: IERC20 + + let vaultAddress: string + let rewardsAddress: string + + let strategy: BaseStrategy + let strategyAddress: string + + async function setup() { + process.env.TEST_STRATEGY_MIN_REPORT_INTERVAL = String(minReportInterval) + + await deployTaskAction(hre, { [startegy]: [token] }) + + vault = await getContractAt('Vault', token) + vaultAddress = await vault.getAddress() + + holderA = await ethers.getSigner('0x8894e0a0c962cb723c1976a4421c95949be2d4e3') // Binance Hot Wallet #6 + await helpers.impersonateAccount(holderA.address) + + holderB = await ethers.getSigner('0xe2fc31F816A9b94326492132018C3aEcC4a93aE1') // Binance Hot Wallet #7 + await helpers.impersonateAccount(holderB.address) + + const gelatoAddress = await getAddress(Addresses.GELATO) + await helpers.impersonateAccount(gelatoAddress) + + strategy = await getContractAt(contractName, token) + strategyAddress = await strategy.getAddress() + + const assetAddress = await getAddress(Addresses.TOKEN, token) + assetToken = await hre.ethers.getContractAt('IERC20', assetAddress) + rewardsAddress = await vault.rewards() + + await verifyBalances() + } + + it('Should re-allocate strategy funds when debt ratio is decreased', async () => { + const amountToDeposit = 30n * 10n ** 18n + + // Debt ratio is 10000 (max) for the existing strategy, all funds are assigned to it + { + const totalDebtRatio = await vault.debtRatio() + const max = await vault.MAX_BPS() + expect(totalDebtRatio).to.be.eq(max) + + const strategyDebtRatio = await vault['currentDebtRatio(address)'](strategyAddress) + expect(strategyDebtRatio).to.be.eq(max) + } + + // All deposited funds should be allocated to the first strategy (due to max ratio) + { + await depositToVault(holderA, amountToDeposit, vault) + + await strategy.work() + + // There should be no profit, loss or debt payment after the first harvest (work), thestrategy just took funds from the vault. + await expectHarvestEvent(strategy, { + profit: 0n, + loss: 0n, + debtPayment: 0n, + outstandingDebt: 0n, + }) + + const totalDebt = await vault.totalDebt() + expect(totalDebt).to.be.eq(amountToDeposit) + + const strategyDebt = await vault['currentDebt(address)'](strategyAddress) + expect(strategyDebt).to.be.eq(amountToDeposit) + } + + // Decrease debt ratio for the strategy in half + { + const max = await vault.MAX_BPS() + + const ratio = max / 2n + await vault.setBorrowerDebtRatio(strategyAddress, ratio) + + expect(await vault.debtRatio()).to.be.eq(ratio) + expect(await vault['currentDebtRatio(address)'](strategyAddress)).to.be.eq(ratio) + } + + // (Work #1) Strategy should start repaying half of the funds back to the vault + { + await warp(minReportInterval * 2) + await strategy.work() + + const data = await getHarvestEventData(strategy) + expect(data.profit).to.be.eq(0) + expect(data.loss).to.be.eq(0) + + const half = amountToDeposit / 2n + expect(data.debtPayment + data.outstandingDebt).to.be.eq(half) + + // After the first work, vault should mark excess half as "outstanding debt" + expectAlmostEqual(half, data.outstandingDebt) + } + + // (Work #2) Strategy should give all the extra funds back to the vault (as debt payment) + { + await warp(minReportInterval * 2) + await strategy.work() + + const data = await getHarvestEventData(strategy) + + // On this step strategy has some profit + expect(data.profit).to.be.greaterThan(0) + expect(data.loss).to.be.eq(0) + + // Since strategy ratio is cutted on half, half of the profit should be taken by the vault + const roundedProfit = data.profit % 2n === 1n ? (data.profit + 1n) : data.profit + expect(data.outstandingDebt).to.be.eq(roundedProfit / 2n) + + // Half of the strategy deposits is taken by the vault + expectAlmostEqual(amountToDeposit / 2n, data.debtPayment) + } + + // (Work #3) Strategy should repay the debt from the previous "work" + // and provide half of the profit as an outstanding debt to the vault again. + { + await warp(minReportInterval * 2) + await strategy.work() + + const data = await getHarvestEventData(strategy) + + expect(data.profit).to.be.greaterThan(0) + expect(data.loss).to.be.eq(0) + expect(data.debtPayment).to.be.greaterThan(0) + + expectAlmostEqual(data.outstandingDebt, data.profit / 2n) + } + + // (Work #4) Strategy should repay the outstanding debt from the previous "work", + // and report about some extra profit it made. No more outstanding debt is expected. + { + await warp(minReportInterval * 2) + await strategy.work() + + const data = await getHarvestEventData(strategy) + expect(data.profit).to.be.greaterThan(0) + expect(data.loss).to.be.eq(0) + expect(data.debtPayment).to.be.greaterThan(0) + expect(data.outstandingDebt).to.be.eq(0) + } + + // (Work #5+) No more debt to pay, + // strategy should operate normally and report about new profits + for (let i = 0; i < 10; i++) { + await warp(minReportInterval * 2) + await strategy.work() + + const data = await getHarvestEventData(strategy) + expect(data.profit).to.be.greaterThan(0) + expect(data.loss).to.be.eq(0) + expect(data.debtPayment).to.be.eq(0) + expect(data.outstandingDebt).to.be.eq(0) + } + + // Total vault assets should be equal to initial deposit (+ gained profit) + expectAlmostEqual(await vault.fundAssets(), amountToDeposit) + + // Strategy should have just half of the vault's funds (estimated) + expectAlmostEqual(await strategy.estimatedTotalAssets(), await vault.fundAssets() / 2n) + }) + + it('Should re-allocate strategy funds when debt ratio is increased', async () => { + const amountToDeposit = 30n * 10n ** 18n + + // Immediately decrease debt ratio for the strategy in half + { + const max = await vault.MAX_BPS() + + const ratio = max / 2n + await vault.setBorrowerDebtRatio(strategyAddress, ratio) + + expect(await vault.debtRatio()).to.be.eq(ratio) + expect(await vault['currentDebtRatio(address)'](strategyAddress)).to.be.eq(ratio) + } + + // Half of the deposited funds should be allocated to the first strategy + { + await depositToVault(holderA, amountToDeposit, vault) + + await strategy.work() + + // There should be no profit, loss or debt payment after the first harvest (work), thestrategy just took funds from the vault. + await expectHarvestEvent(strategy, { + profit: 0n, + loss: 0n, + debtPayment: 0n, + outstandingDebt: 0n, + }) + + const totalAssets = await vault.fundAssets() + expect(totalAssets).to.be.eq(amountToDeposit) + + const totalDebt = await vault.totalDebt() + expect(totalDebt).to.be.eq(amountToDeposit / 2n) + + const strategyDebt = await vault['currentDebt(address)'](strategyAddress) + expect(strategyDebt).to.be.eq(amountToDeposit / 2n) + } + + // Increase debt ratio for the strategy back to the max + { + const max = await vault.MAX_BPS() + await vault.setBorrowerDebtRatio(strategyAddress, max) + + expect(await vault.debtRatio()).to.be.eq(max) + expect(await vault['currentDebtRatio(address)'](strategyAddress)).to.be.eq(max) + } + + // (Work #1) Strategy should take missing funds from the vault after "work" is called + { + expectAlmostEqual(await strategy.estimatedTotalAssets(), amountToDeposit / 2n) + + await warp(minReportInterval * 2) + await strategy.work() + + expectAlmostEqual(await strategy.estimatedTotalAssets(), amountToDeposit) + + const data = await getHarvestEventData(strategy) + expect(data.profit).to.be.greaterThan(0n) + expect(data.loss).to.be.eq(0n) + expect(data.debtPayment).to.be.eq(0n) + expect(data.outstandingDebt).to.be.eq(0n) + } + + // (Work #2+) Strategy should operate normally + for (let i = 0; i < 10; i++) { + await warp(minReportInterval * 2) + await strategy.work() + + expect(await strategy.estimatedTotalAssets()).to.be.greaterThan(amountToDeposit) + + const data = await getHarvestEventData(strategy) + expect(data.profit).to.be.greaterThan(0n) + expect(data.loss).to.be.eq(0n) + expect(data.debtPayment).to.be.eq(0n) + expect(data.outstandingDebt).to.be.eq(0n) + } + }) + + /********************************************* + * Helper functions + *********************************************/ + + async function verifyBalances() { + await verifyEmptyBalances() + + const min = 300n * 10n ** 18n + expect(await assetToken.balanceOf(holderA.address), 'low holderA balance').to.be.greaterThan(min) + expect(await assetToken.balanceOf(holderB.address), 'low holderB balance').to.be.greaterThan(min) + } + + async function verifyEmptyBalances() { + await verifyStrategyEmptyBalance(strategy) + expect(await vault['currentDebt()'](), 'currentDebt is not 0').to.be.eq(0n) + expect(await assetToken.balanceOf(vaultAddress), 'vault balance is not 0').to.be.eq(0n) + expect(await assetToken.balanceOf(rewardsAddress), 'rewards balance is not 0').to.be.eq(0n) + } + + async function verifyStrategyEmptyBalance(strategy: BaseStrategy) { + const name = await strategy.name() + expect(await strategy.estimatedTotalAssets(), `total assets of ${name} is not 0`).to.be.eq(0n) + + const strategyAddress = await strategy.getAddress() + expect(await assetToken.balanceOf(strategyAddress), `balance of ${name} is not 0`).to.be.eq(0n) + } + + interface HarvestEventData { profit: bigint; loss: bigint; debtPayment: bigint; outstandingDebt: bigint } + + async function getHarvestEventData(strategy: BaseStrategy): Promise { + const filter = strategy.filters.Harvested + const events = await strategy.queryFilter(filter, -1) + if (events.length <= 0) { + throw new Error('No harvest events found!') + } + const [profit, loss, debtPayment, outstandingDebt] = events[0].args + return { profit, loss, debtPayment, outstandingDebt } + } + + async function expectHarvestEvent(strategy: BaseStrategy, expected: ((data: HarvestEventData) => void) | HarvestEventData) { + const data = await getHarvestEventData(strategy) + + if (typeof expected === 'function') { + return expected(data) + } + expect(data, 'Unexpected data of the harvest event').to.be.deep.equal(expected) + } + + beforeEach(async () => { + await helpers.loadFixture(setup) + }) +} diff --git a/packages/contracts/test/integration/bsc/multiple-strategies.test.ts b/packages/contracts/test/integration/bsc/multiple-strategies.test.ts new file mode 100644 index 00000000..d39abbda --- /dev/null +++ b/packages/contracts/test/integration/bsc/multiple-strategies.test.ts @@ -0,0 +1,200 @@ +/* eslint-disable no-lone-blocks */ +import hre from 'hardhat' +import { TokenSymbol } from '@eonian/upgradeable' +import * as helpers from '@nomicfoundation/hardhat-network-helpers' +import { expect } from 'chai' +import type { HardhatEthersSigner } from '@nomicfoundation/hardhat-ethers/signers' +import { Addresses } from '../../../hardhat/deployment' +import { deployTaskAction } from '../../../hardhat/tasks' +import { Strategy } from '../../../hardhat/tasks/deploy/strategy-deployment-plan' +import type { BaseStrategy, IERC20, Vault } from '../../../typechain-types' +import { clearDeployments } from '../../deploy/helpers' +import { getContractAt } from '../helpers/get-contract-at' +import resetBalance from '../helpers/reset-balance' +import { getAddress } from '../helpers/get-address' +import { depositToVault, withdrawFromVault } from '../helpers/vault-deposit-withdraw' +import warp from '../helpers/warp' +import { expectAlmostEqual } from '../helpers/expect-almost-equal' + +describe('Interaction with vault (multiple strategies)', () => { + clearDeployments(hre) + + const { ethers } = hre + + const token = TokenSymbol.USDT + const minReportInterval = 3600 + + let holderA: HardhatEthersSigner + let holderB: HardhatEthersSigner + + let strategyA: BaseStrategy + let strategyAddressA: string + + let strategyB: BaseStrategy + let strategyAddressB: string + + let vault: Vault + let vaultAddress: string + + let assetToken: IERC20 + + async function setup() { + process.env.TEST_STRATEGY_MIN_REPORT_INTERVAL = String(minReportInterval) + + await deployTaskAction(hre, { [Strategy.APESWAP]: [token], [Strategy.AAVE_V3]: [token] }) + + vault = await getContractAt('Vault', token) + vaultAddress = await vault.getAddress() + + holderA = await ethers.getSigner('0x8894e0a0c962cb723c1976a4421c95949be2d4e3') // Binance Hot Wallet #6 + await helpers.impersonateAccount(holderA.address) + + holderB = await ethers.getSigner('0xF977814e90dA44bFA03b6295A0616a897441aceC') // Binance Hot Wallet #20 + await helpers.impersonateAccount(holderB.address) + + const gelatoAddress = await getAddress(Addresses.GELATO) + await helpers.impersonateAccount(gelatoAddress) + + strategyA = await getContractAt('ApeLendingStrategy', token) + strategyAddressA = await strategyA.getAddress() + + strategyB = await getContractAt('AaveSupplyStrategy', token) + strategyAddressB = await strategyB.getAddress() + + const assetAddress = await getAddress(Addresses.TOKEN, token) + assetToken = await hre.ethers.getContractAt('IERC20', assetAddress) + + await resetBalance(vaultAddress, { tokens: [await vault.asset()] }) + + // Both strategies have equal debt ratio (5000) + const maxDebtRatio = await vault.MAX_BPS() + await vault.setBorrowerDebtRatio(strategyAddressA, maxDebtRatio / 2n) + await vault.setBorrowerDebtRatio(strategyAddressB, maxDebtRatio / 2n) + + expect(await vault['currentDebtRatio(address)'](strategyAddressA), 'Wrong ratio of A').to.be.eq(maxDebtRatio / 2n) + expect(await vault['currentDebtRatio(address)'](strategyAddressB), 'Wrong ratio of B').to.be.eq(maxDebtRatio / 2n) + + // Ensure strategy A is first in "withdrawal" order + expect(await vault.withdrawalQueue(0), 'Wrong order').to.be.eq(strategyAddressA) + expect(await vault.withdrawalQueue(1), 'Wrong order').to.be.eq(strategyAddressB) + } + + beforeEach(async () => { + await helpers.loadFixture(setup) + }) + + it('Should deposit and withdraw from the vault', async () => { + // Vault is empty, no USDT amount on the token balance + expect(await assetToken.balanceOf(vaultAddress)).to.be.equal(0) + + // Make sure that the holder has some amount of USDT (e.g., >300) + expect(await assetToken.balanceOf(holderA.address)).to.be.greaterThan(300n * 10n ** 18n) + + // Approve and deposit 30 USDT in the vault on behalf of the holder + const depositAmount = 30n * 10n ** 18n + await depositToVault(holderA, depositAmount, vault) + + // Strategy A should take half of the vault's funds after work is called + { + await warp(minReportInterval * 2) + await strategyA.work() + expect(await assetToken.balanceOf(vaultAddress), '[work A] Wrong vault balance').to.be.eq(depositAmount / 2n) + expectAlmostEqual(await strategyA.estimatedTotalAssets(), depositAmount / 2n, '[work A] Wrong A balance') + expect(await vault['currentDebt(address)'](strategyAddressA), '[work A] Wrong A balance').to.be.eq(depositAmount / 2n) + expect(await vault['currentDebt(address)'](strategyAddressB), '[work A] Wrong B balance').to.be.eq(0) + } + + // Strategy B should take other half of the vault's funds after work is called + { + await warp(minReportInterval * 2) + await strategyB.work() + expect(await assetToken.balanceOf(vaultAddress), '[work B] Wrong vault balance').to.be.eq(0) + expectAlmostEqual(await strategyB.estimatedTotalAssets(), depositAmount / 2n, '[work B] Wrong B balance') + expect(await vault['currentDebt(address)'](strategyAddressA), '[work B] Wrong A balance').to.be.eq(depositAmount / 2n) + expect(await vault['currentDebt(address)'](strategyAddressB), '[work B] Wrong B balance').to.be.eq(depositAmount / 2n) + } + + await warp(minReportInterval * 2) + await strategyA.work() + await strategyB.work() + + // User should get some profit + const userInvestment = await vault.maxWithdraw(holderA) + expect(userInvestment).to.be.greaterThan(depositAmount) + + await withdrawFromVault(holderA, userInvestment, vault, { + addresses: [holderA.address], + balanceChanges: [userInvestment], + }) + + expect(await vault['currentDebt(address)'](strategyAddressA), 'Wrong strategy A debt').to.be.eq(0n) + + // Last strategy in the queue might have some small amount of leftover funds + expect(await vault['currentDebt(address)'](strategyAddressB), 'Wrong strategy B debt').to.be.lessThan(10n ** 18n) + }) + + it('Should deposit and withdraw from the vault (multiple users)', async () => { + // Vault is empty, no USDT amount on the token balance + expect(await assetToken.balanceOf(vaultAddress)).to.be.equal(0) + + // Make sure that the holders have some amount of USDT (e.g., >300) + expect(await assetToken.balanceOf(holderA.address)).to.be.greaterThan(300n * 10n ** 18n) + expect(await assetToken.balanceOf(holderB.address)).to.be.greaterThan(300n * 10n ** 18n) + + // Approve and deposit 30 USDT in the vault on behalf of the holder + const depositAmount = 60n * 10n ** 18n + await depositToVault(holderA, depositAmount / 2n, vault) + await depositToVault(holderB, depositAmount / 2n, vault) + + // Strategy A should take half of the vault's funds after work is called + { + await warp(minReportInterval * 2) + await strategyA.work() + expect(await assetToken.balanceOf(vaultAddress), '[work A] Wrong vault balance').to.be.eq(depositAmount / 2n) + expectAlmostEqual(await strategyA.estimatedTotalAssets(), depositAmount / 2n, '[work A] Wrong A balance') + expect(await vault['currentDebt(address)'](strategyAddressA), '[work A] Wrong A balance').to.be.eq(depositAmount / 2n) + expect(await vault['currentDebt(address)'](strategyAddressB), '[work A] Wrong B balance').to.be.eq(0) + } + + // Strategy B should take other half of the vault's funds after work is called + { + await warp(minReportInterval * 2) + await strategyB.work() + expect(await assetToken.balanceOf(vaultAddress), '[work B] Wrong vault balance').to.be.eq(0) + expectAlmostEqual(await strategyB.estimatedTotalAssets(), depositAmount / 2n, '[work B] Wrong B balance') + expect(await vault['currentDebt(address)'](strategyAddressA), '[work B] Wrong A balance').to.be.eq(depositAmount / 2n) + expect(await vault['currentDebt(address)'](strategyAddressB), '[work B] Wrong B balance').to.be.eq(depositAmount / 2n) + } + + await warp(minReportInterval * 2) + await strategyA.work() + await strategyB.work() + + // Withdraw as holder A and get some profit above initial deposit + { + const userInvestment = await vault.maxWithdraw(holderA) + expect(userInvestment).to.be.greaterThan(depositAmount / 2n) + + await withdrawFromVault(holderA, userInvestment, vault, { + addresses: [holderA.address], + balanceChanges: [userInvestment], + }) + } + + // Withdraw as holder B and get some profit above initial deposit + { + const userInvestment = await vault.maxWithdraw(holderB) + expect(userInvestment).to.be.greaterThan(depositAmount / 2n) + + await withdrawFromVault(holderB, userInvestment, vault, { + addresses: [holderB.address], + balanceChanges: [userInvestment], + }) + } + + expect(await vault['currentDebt(address)'](strategyAddressA), 'Wrong strategy A debt').to.be.eq(0n) + + // Last strategy in the queue might have some small amount of leftover funds + expect(await vault['currentDebt(address)'](strategyAddressB), 'Wrong strategy B debt').to.be.lessThan(10n ** 18n) + }) +}) diff --git a/packages/contracts/test/integration/eth/aave-supply-strategy.test.ts b/packages/contracts/test/integration/eth/aave-supply-strategy.test.ts index 37a5974b..a8df11c7 100644 --- a/packages/contracts/test/integration/eth/aave-supply-strategy.test.ts +++ b/packages/contracts/test/integration/eth/aave-supply-strategy.test.ts @@ -45,7 +45,7 @@ function suite(aaveStrategy: Strategy.AAVE_V3 | Strategy.AAVE_V2) { vault = await getContractAt('Vault', token) vaultAddress = await vault.getAddress() - holderA = await ethers.getSigner('0xF977814e90dA44bFA03b6295A0616a897441aceC') // Binance Hot Wallet #20 + holderA = await ethers.getSigner('0xf89d7b9c864f589bbF53a82105107622B35EaA40') // Bybit wallet await helpers.impersonateAccount(holderA.address) holderB = await ethers.getSigner('0x28C6c06298d514Db089934071355E5743bf21d60') // Binance Hot Wallet #14 diff --git a/packages/contracts/test/integration/helpers/expect-almost-equal.ts b/packages/contracts/test/integration/helpers/expect-almost-equal.ts new file mode 100644 index 00000000..5ab6a03c --- /dev/null +++ b/packages/contracts/test/integration/helpers/expect-almost-equal.ts @@ -0,0 +1,17 @@ +import { expect } from 'chai' + +export function percentDifference(aBN: bigint, bBN: bigint): number { + const a = Number(aBN) + const b = Number(bBN) + const lower = Math.min(a, b) + const bigger = Math.max(a, b) + return 100 - (lower / bigger * 100) +} + +export function isAlmostEqual(aBN: bigint, bBN: bigint, precision = 0.1): boolean { + return percentDifference(aBN, bBN) < precision +} + +export function expectAlmostEqual(aBN: bigint, bBN: bigint, message?: string, precision = 0.1) { + expect(isAlmostEqual(aBN, bBN, precision), message).to.be.eq(true) +} diff --git a/packages/contracts/test/integration/helpers/reset-balance.ts b/packages/contracts/test/integration/helpers/reset-balance.ts index 0d25423e..c66b014e 100644 --- a/packages/contracts/test/integration/helpers/reset-balance.ts +++ b/packages/contracts/test/integration/helpers/reset-balance.ts @@ -17,9 +17,8 @@ export default async function resetBalance( await provider.send('hardhat_setBalance', [address, ethers.toBeHex(ethers.parseEther('10'))]) - const signer = await ethers.getSigner(address) for (const tokenAddress of tokens) { - const token = await getToken(tokenAddress, signer) + const token = await getToken(tokenAddress) const balance = await token.balanceOf(address) await token.transfer('0x000000000000000000000000000000000000dEaD', balance) } diff --git a/packages/upgradeable/src/chains/Chain.ts b/packages/upgradeable/src/chains/Chain.ts index 6e84a3f4..4b8ca273 100644 --- a/packages/upgradeable/src/chains/Chain.ts +++ b/packages/upgradeable/src/chains/Chain.ts @@ -17,7 +17,7 @@ export function resolveChain(hre: HardhatRuntimeEnvironment): Chain { } // "Hardhat" is a local running node that can be a fork of a real node. - if (hardhatNetwork === 'hardhat') { + if (hardhatNetwork === 'hardhat' || hardhatNetwork === 'localhost') { return getChainForFork() } diff --git a/packages/upgradeable/src/environment/NetworkEnvironment.ts b/packages/upgradeable/src/environment/NetworkEnvironment.ts index 3ee2d6fc..b5b38bcb 100644 --- a/packages/upgradeable/src/environment/NetworkEnvironment.ts +++ b/packages/upgradeable/src/environment/NetworkEnvironment.ts @@ -9,7 +9,7 @@ export enum NetworkEnvironment { export function resolveNetworkEnvironment(hre: HardhatRuntimeEnvironment): NetworkEnvironment { const hardhatNetwork = hre.network.name - if (hardhatNetwork === 'ganache' || hardhatNetwork === 'hardhat') { + if (hardhatNetwork === 'ganache' || hardhatNetwork === 'hardhat' || hardhatNetwork === 'localhost') { return NetworkEnvironment.LOCAL } const environmentString = hardhatNetwork.split('_').at(-1)