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/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/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) }