diff --git a/contracts/ERC4626InvestStrategy.sol b/contracts/ERC4626InvestStrategy.sol new file mode 100644 index 0000000..4341be3 --- /dev/null +++ b/contracts/ERC4626InvestStrategy.sol @@ -0,0 +1,93 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import {IERC20} from "@openzeppelin/contracts/interfaces/IERC20.sol"; +import {IERC4626} from "@openzeppelin/contracts/interfaces/IERC4626.sol"; +import {IInvestStrategy} from "./interfaces/IInvestStrategy.sol"; +import {InvestStrategyClient} from "./InvestStrategyClient.sol"; + +/** + * @title AaveV3InvestStrategy + * + * @dev Strategy that invests/deinvests into a 4626 vault + * + * @custom:security-contact security@ensuro.co + * @author Ensuro + */ +contract ERC4626InvestStrategy is IInvestStrategy { + address private immutable __self = address(this); + bytes32 public immutable storageSlot = InvestStrategyClient.makeStorageSlot(this); + + IERC4626 internal immutable _vault; + IERC20 internal immutable _asset; + + error CanBeCalledOnlyThroughDelegateCall(); + error CannotDisconnectWithAssets(); + error NoExtraDataAllowed(); + + modifier onlyDelegCall() { + if (address(this) == __self) revert CanBeCalledOnlyThroughDelegateCall(); + _; + } + + constructor(IERC4626 vault_) { + _vault = vault_; + _asset = IERC20(vault_.asset()); + } + + /// @inheritdoc IInvestStrategy + function connect(bytes memory initData) external virtual override onlyDelegCall { + if (initData.length != 0) revert NoExtraDataAllowed(); + } + + /// @inheritdoc IInvestStrategy + function disconnect(bool force) external virtual override onlyDelegCall { + // Here I check _vault.balanceOf() instead of totalAssets(). In an extreme cases, when the vault lost all its + // value these can differ, but on those cases I think it's safer to block the disconnection unless forced + if (!force && _vault.balanceOf(address(this)) != 0) revert CannotDisconnectWithAssets(); + } + + /// @inheritdoc IInvestStrategy + function maxWithdraw(address contract_) public view virtual override returns (uint256) { + return _vault.maxWithdraw(contract_); + } + + /// @inheritdoc IInvestStrategy + function maxDeposit(address contract_) public view virtual override returns (uint256) { + return _vault.maxDeposit(contract_); + } + + /// @inheritdoc IInvestStrategy + function asset(address) public view virtual override returns (address) { + return address(_asset); + } + + /** + * @dev Returns the ERC4626 where this strategy invests the funds + */ + function investVault() public view returns (IERC4626) { + return _vault; + } + + /// @inheritdoc IInvestStrategy + function totalAssets(address contract_) public view virtual override returns (uint256 assets) { + return _vault.convertToAssets(_vault.balanceOf(contract_)); + } + + /// @inheritdoc IInvestStrategy + function withdraw(uint256 assets) external virtual override onlyDelegCall { + _vault.withdraw(assets, address(this), address(this)); + } + + /// @inheritdoc IInvestStrategy + function deposit(uint256 assets) external virtual override onlyDelegCall { + _asset.approve(address(_vault), assets); + _vault.deposit(assets, address(this)); + } + + /// @inheritdoc IInvestStrategy + function forwardEntryPoint(uint8, bytes memory) external view onlyDelegCall returns (bytes memory) { + // solhint-disable-next-line gas-custom-errors,reason-string + revert(); + } +} diff --git a/hardhat.config.js b/hardhat.config.js index 3b54ce2..cd8b73e 100644 --- a/hardhat.config.js +++ b/hardhat.config.js @@ -31,6 +31,7 @@ module.exports = { dependencyCompiler: { paths: [ "@ensuro/utils/contracts/TestCurrency.sol", + "@ensuro/utils/contracts/TestERC4626.sol", "@ensuro/swaplibrary/contracts/mocks/SwapRouterMock.sol", "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol", "@openzeppelin/contracts/access/manager/AccessManager.sol", diff --git a/package-lock.json b/package-lock.json index 39f9d16..b87831c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "0.0.1", "dependencies": { "@ensuro/swaplibrary": "1.0.0", - "@ensuro/utils": "^0.1.1", + "@ensuro/utils": "^0.2.6", "@openzeppelin/contracts": "^5.1.0", "@openzeppelin/contracts-upgradeable": "^5.1.0", "@uniswap/v3-periphery": "^1.4.4", @@ -157,7 +157,9 @@ "license": "Apache-2.0" }, "node_modules/@ensuro/utils": { - "version": "0.1.1", + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/@ensuro/utils/-/utils-0.2.6.tgz", + "integrity": "sha512-uwdT6FR1M3Z6RJllsEKpYq1tonoesTfUs6zFeWfKOGYQGvno49KqvP/i/IV0qOfF13TxDcnK9xA+T763GBv+ZA==", "license": "Apache-2.0" }, "node_modules/@eslint-community/eslint-utils": { diff --git a/package.json b/package.json index 8b590db..c571897 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "solhint-plugin-prettier": "0.1.0" }, "dependencies": { - "@ensuro/utils": "^0.1.1", + "@ensuro/utils": "^0.2.6", "@ensuro/swaplibrary": "1.0.0", "@openzeppelin/contracts": "^5.1.0", "@openzeppelin/contracts-upgradeable": "^5.1.0", diff --git a/test/test-compound-v3-vault.js b/test/test-compound-v3-vault.js index bffae23..74544e3 100644 --- a/test/test-compound-v3-vault.js +++ b/test/test-compound-v3-vault.js @@ -903,7 +903,7 @@ variants.forEach((variant) => { expect(evt).not.equal(null); expect(evt.args.token).to.equal(ADDRESSES.COMP); - expect(evt.args.rewards).to.equal(_W("0.126432")); + expect(evt.args.rewards).to.closeTo(_W("0.126432"), _W("0.00001")); expect(evt.args.receivedInAsset).to.equal(_A("10.684546")); await expect(tx).to.emit(currency, "Transfer").withArgs(vault, ADDRESSES.cUSDCv3, _A("10.684546")); diff --git a/test/test-erc4626-invest-strategy.js b/test/test-erc4626-invest-strategy.js new file mode 100644 index 0000000..824eb1b --- /dev/null +++ b/test/test-erc4626-invest-strategy.js @@ -0,0 +1,282 @@ +const { expect } = require("chai"); +const { amountFunction, _W, getRole, getTransactionEvent } = require("@ensuro/utils/js/utils"); +const { encodeDummyStorage, tagit } = require("./utils"); +const { initCurrency } = require("@ensuro/utils/js/test-utils"); +const { anyUint } = require("@nomicfoundation/hardhat-chai-matchers/withArgs"); +const hre = require("hardhat"); +const helpers = require("@nomicfoundation/hardhat-network-helpers"); + +const { ethers } = hre; +const { MaxUint256 } = hre.ethers; + +const CURRENCY_DECIMALS = 6; +const _A = amountFunction(CURRENCY_DECIMALS); +const INITIAL = 10000; +const NAME = "Single Strategy Vault"; +const SYMB = "SSV"; + +const CENT = _A("0.01"); +const MCENT = CENT / 1000n; + +const OverrideOption = { + deposit: 0, + mint: 1, + withdraw: 2, + redeem: 3, +}; + +async function setUp() { + const [, lp, lp2, anon, guardian, admin] = await ethers.getSigners(); + + const USDC = await initCurrency( + { + name: "Test Currency with 6 decimals", + symbol: "USDC", + decimals: 6, + initial_supply: _A(50000), + extraArgs: [admin], + }, + [lp, lp2], + [_A(INITIAL), _A(INITIAL)] + ); + + const adminAddr = await ethers.resolveAddress(admin); + const DummyInvestStrategy = await ethers.getContractFactory("DummyInvestStrategy"); + const ERC4626InvestStrategy = await ethers.getContractFactory("ERC4626InvestStrategy"); + const SingleStrategyERC4626 = await ethers.getContractFactory("SingleStrategyERC4626"); + const TestERC4626 = await ethers.getContractFactory("TestERC4626"); + const investVault = await TestERC4626.deploy("Some vault", "VAULT", USDC); + + // Grant roles to the test vault, so it can mint/burn earnings/losses + await USDC.connect(admin).grantRole(getRole("MINTER_ROLE"), investVault); + await USDC.connect(admin).grantRole(getRole("BURNER_ROLE"), investVault); + + async function setupVault(asset, strategy, strategyData = ethers.toUtf8Bytes("")) { + const vault = await hre.upgrades.deployProxy( + SingleStrategyERC4626, + [NAME, SYMB, adminAddr, await ethers.resolveAddress(asset), await ethers.resolveAddress(strategy), strategyData], + { + kind: "uups", + unsafeAllow: ["delegatecall"], + } + ); + // Whitelist LPs + await asset.connect(lp).approve(vault, MaxUint256); + await asset.connect(lp2).approve(vault, MaxUint256); + await vault.connect(admin).grantRole(getRole("LP_ROLE"), lp); + await vault.connect(admin).grantRole(getRole("LP_ROLE"), lp2); + return vault; + } + + return { + USDC, + SingleStrategyERC4626, + ERC4626InvestStrategy, + DummyInvestStrategy, + adminAddr, + lp, + lp2, + anon, + guardian, + admin, + investVault, + setupVault, + }; +} + +async function setUpCommon() { + const ret = await helpers.loadFixture(setUp); + const strategy = await ret.ERC4626InvestStrategy.deploy(ret.investVault); + const vault = await ret.setupVault(ret.USDC, strategy); + return { ...ret, vault, strategy }; +} + +describe("ERC4626InvestStrategy contract tests", function () { + it("Initializes the vault correctly", async () => { + const { USDC, investVault, vault, strategy } = await setUpCommon(); + expect(await vault.name()).to.equal(NAME); + expect(await vault.symbol()).to.equal(SYMB); + expect(await vault.strategy()).to.equal(strategy); + expect(await vault.asset()).to.equal(USDC); + expect(await vault.totalAssets()).to.equal(0); + expect(await strategy.asset(vault)).to.equal(USDC); + expect(await strategy.investVault(vault)).to.equal(investVault); + }); + + it("Deposit and accounting works", async () => { + const { USDC, investVault, vault, lp } = await setUpCommon(); + await vault.connect(lp).deposit(_A(100), lp); + expect(await vault.totalAssets()).to.equal(_A(100)); + expect(await investVault.convertToAssets(await investVault.balanceOf(vault))).to.equal(_A(100)); + + // Checks allowance backs to 0 after the deposit + expect(await USDC.allowance(vault, investVault)).to.equal(0); + + await investVault.discreteEarning(_A(40)); + expect(await vault.totalAssets()).to.closeTo(_A(140), MCENT); + + await investVault.discreteEarning(-_A(50)); + expect(await vault.totalAssets()).to.closeTo(_A(90), MCENT); + }); + + it("Withdraws and reduces the assets", async () => { + const { USDC, investVault, vault, lp } = await setUpCommon(); + await vault.connect(lp).deposit(_A(100), lp); + expect(await vault.totalAssets()).to.equal(_A(100)); + + await vault.connect(lp).withdraw(_A(80), lp, lp); + expect(await vault.totalAssets()).to.equal(_A(20)); + + await investVault.discreteEarning(_A(40)); + expect(await vault.totalAssets()).to.closeTo(_A(60), MCENT); + + await vault.connect(lp).redeem(_A(20), lp, lp); + + expect(await USDC.balanceOf(lp)).to.closeTo(_A(INITIAL + 40), MCENT); + }); + + it("Checks maxWithdraw and maxDeposit reflect the limits of the investVault", async () => { + const { investVault, vault, lp, strategy } = await setUpCommon(); + + await investVault.setOverride(OverrideOption.deposit, _A(10)); + + await expect(vault.connect(lp).deposit(_A(100), lp)).to.be.revertedWithCustomError( + vault, + "ERC4626ExceededMaxDeposit" + ); + expect(await strategy.maxDeposit(vault)).to.equal(_A(10)); + + await vault.connect(lp).deposit(_A(10), lp); + expect(await vault.totalAssets()).to.equal(_A(10)); + + await investVault.setOverride(OverrideOption.withdraw, _A(0)); + + await expect(vault.connect(lp).withdraw(_A(9), lp, lp)).to.be.revertedWithCustomError( + vault, + "ERC4626ExceededMaxWithdraw" + ); + expect(await strategy.maxWithdraw(vault)).to.equal(_A(0)); + + await investVault.setOverride(OverrideOption.withdraw, await investVault.OVERRIDE_UNSET()); + + const maxWithdraw = await strategy.maxWithdraw(vault); + expect(maxWithdraw).to.closeTo(_A(10), MCENT); + + await vault.connect(lp).withdraw(maxWithdraw, lp, lp); + expect(await vault.totalAssets()).to.equal(_A(0)); + }); + + it("Checks methods can't be called directly", async () => { + const { strategy } = await setUpCommon(); + + await expect(strategy.getFunction("connect")(ethers.toUtf8Bytes(""))).to.be.revertedWithCustomError( + strategy, + "CanBeCalledOnlyThroughDelegateCall" + ); + + await expect(strategy.disconnect(false)).to.be.revertedWithCustomError( + strategy, + "CanBeCalledOnlyThroughDelegateCall" + ); + + await expect(strategy.deposit(123)).to.be.revertedWithCustomError(strategy, "CanBeCalledOnlyThroughDelegateCall"); + + await expect(strategy.withdraw(123)).to.be.revertedWithCustomError(strategy, "CanBeCalledOnlyThroughDelegateCall"); + + await expect(strategy.forwardEntryPoint(1, ethers.toUtf8Bytes(""))).to.be.revertedWithCustomError( + strategy, + "CanBeCalledOnlyThroughDelegateCall" + ); + }); + + it("Checks forwardToStrategy fails with any input", async () => { + const { vault } = await setUpCommon(); + await expect(vault.forwardToStrategy(123, ethers.toUtf8Bytes(""))).to.be.reverted; + }); + + it("Verifies an investVault with a different asset doesn't work", async () => { + const { setupVault, investVault, admin, SingleStrategyERC4626, ERC4626InvestStrategy } = + await helpers.loadFixture(setUp); + const strategy = await ERC4626InvestStrategy.deploy(investVault); + const EURC = await initCurrency( + { + name: "Euro", + symbol: "EURC", + decimals: 6, + initial_supply: _A(50000), + extraArgs: [admin], + }, + [], + [] + ); + await expect(setupVault(EURC, strategy)).to.be.revertedWithCustomError( + SingleStrategyERC4626, + "InvalidStrategyAsset" + ); + }); + + it("Verifies connect doesn't accept extra data", async () => { + const { setupVault, investVault, USDC, ERC4626InvestStrategy } = await helpers.loadFixture(setUp); + const strategy = await ERC4626InvestStrategy.deploy(investVault); + await expect(setupVault(USDC, strategy, ethers.toUtf8Bytes("foobar"))).to.be.revertedWithCustomError( + strategy, + "NoExtraDataAllowed" + ); + }); + + it("Checks the strategy can't be disconnected with assets unless forced", async () => { + const { USDC, investVault, vault, lp, DummyInvestStrategy, admin, strategy } = await setUpCommon(); + await vault.connect(lp).deposit(_A(100), lp); + + const dummy = await DummyInvestStrategy.deploy(USDC); + + await vault.connect(admin).grantRole(getRole("SET_STRATEGY_ROLE"), admin); + + expect(await investVault.totalAssets()).to.equal(_A(100)); + expect(await strategy.totalAssets(vault)).to.equal(_A(100)); + + // This works fine because the funds are withdrawn from strategy and deposited into dummy + await vault.connect(admin).setStrategy(dummy, encodeDummyStorage({}), false); + + expect(await strategy.totalAssets(vault)).to.equal(_A(0)); + expect(await vault.totalAssets()).to.equal(_A(100)); + + // Reconnect the strategy + await vault.connect(admin).setStrategy(strategy, ethers.toUtf8Bytes(""), false); + + // Now set maxWithdraw to 10 + await investVault.setOverride(OverrideOption.withdraw, _A(10)); + await expect(vault.connect(admin).setStrategy(dummy, encodeDummyStorage({}), false)).to.be.revertedWithCustomError( + vault, + "ERC4626ExceededMaxWithdraw" + ); + await investVault.setOverride(OverrideOption.withdraw, await investVault.OVERRIDE_UNSET()); + await investVault.setBroken(true); + + await expect(vault.connect(admin).setStrategy(dummy, encodeDummyStorage({}), false)).to.be.revertedWithCustomError( + investVault, + "VaultIsBroken" + ); + // But with forced disconnect works fine + await vault.connect(admin).setStrategy(dummy, encodeDummyStorage({}), true); + }); + + it("Checks the strategy can't be disconnected with SHARES in the investVault unless forced", async () => { + const { USDC, investVault, vault, lp, DummyInvestStrategy, admin, strategy } = await setUpCommon(); + await vault.connect(lp).deposit(_A(100), lp); + + const dummy = await DummyInvestStrategy.deploy(USDC); + + await vault.connect(admin).grantRole(getRole("SET_STRATEGY_ROLE"), admin); + + await investVault.discreteEarning(-_A(100)); + expect(await vault.totalAssets()).to.equal(_A(0)); + + await expect(vault.connect(admin).setStrategy(dummy, encodeDummyStorage({}), false)).to.be.revertedWithCustomError( + strategy, + "CannotDisconnectWithAssets" + ); + + await expect(vault.connect(admin).setStrategy(dummy, encodeDummyStorage({}), true)).not.to.be.reverted; + }); +});