Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 82 additions & 0 deletions contracts/IdleInvestStrategy.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
// SPDX-License-Identifier: Apache-2.0
pragma solidity ^0.8.0;

import {IERC20Metadata} from "@openzeppelin/contracts/interfaces/IERC20Metadata.sol";
import {IInvestStrategy} from "./interfaces/IInvestStrategy.sol";
import {InvestStrategyClient} from "./InvestStrategyClient.sol";

/**
* @title IdleInvestStrategy
* @dev Strategy that keeps the funds idle, in vault's asset(), without generating any yield.
*
* @custom:security-contact [email protected]
* @author Ensuro
*/
contract IdleInvestStrategy is IInvestStrategy {
address internal immutable __self = address(this);
bytes32 public immutable storageSlot = InvestStrategyClient.makeStorageSlot(this);

IERC20Metadata internal immutable _asset;

error CanBeCalledOnlyThroughDelegateCall();
error CannotDisconnectWithAssets();
error NoExtraDataAllowed();

modifier onlyDelegCall() {
if (address(this) == __self) revert CanBeCalledOnlyThroughDelegateCall();
_;
}

/**
* @dev Constructor of the strategy
*
* @param asset_ The address of the underlying token used for accounting, depositing, and withdrawing.
*/
constructor(IERC20Metadata asset_) {
_asset = 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 {
if (!force && totalAssets(address(this)) != 0) revert CannotDisconnectWithAssets();
}

/// @inheritdoc IInvestStrategy
function maxWithdraw(address contract_) public view virtual override returns (uint256) {
return totalAssets(contract_);
}

/// @inheritdoc IInvestStrategy
function maxDeposit(address /*contract_*/) public view virtual override returns (uint256) {
return type(uint256).max;
}

/// @inheritdoc IInvestStrategy
function asset(address) public view virtual override returns (address) {
return address(_asset);
}

/// @inheritdoc IInvestStrategy
function totalAssets(address contract_) public view virtual override returns (uint256 assets) {
return _asset.balanceOf(contract_);
}

/// @inheritdoc IInvestStrategy
// solhint-disable-next-line no-empty-blocks
function withdraw(uint256 assets) public virtual override onlyDelegCall {}

/// @inheritdoc IInvestStrategy
// solhint-disable-next-line no-empty-blocks
function deposit(uint256 assets) public virtual override onlyDelegCall {}

/// @inheritdoc IInvestStrategy
function forwardEntryPoint(uint8, bytes memory) external view onlyDelegCall returns (bytes memory) {
// solhint-disable-next-line gas-custom-errors,reason-string
revert();
}
}
1 change: 0 additions & 1 deletion test/test-compound-v3-vault.js
Original file line number Diff line number Diff line change
Expand Up @@ -531,7 +531,6 @@ variants.push({
variants.forEach((variant) => {
const it = (testDescription, test) => tagitVariant(variant, false, testDescription, test);
it.only = (testDescription, test) => tagitVariant(variant, true, testDescription, test);
it.foobar = 123;

describe(`${variant.name} contract tests`, function () {
before(async () => {
Expand Down
238 changes: 238 additions & 0 deletions test/test-idle-invest-strategy.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,238 @@
const { expect } = require("chai");
const { amountFunction, getRole, makeAllViewsPublic, setupAMRole } = require("@ensuro/utils/js/utils");
const { encodeDummyStorage } = require("./utils");
const { initCurrency } = require("@ensuro/utils/js/test-utils");
const hre = require("hardhat");
const helpers = require("@nomicfoundation/hardhat-network-helpers");
const { deploy: ozUpgradesDeploy } = require("@openzeppelin/hardhat-upgrades/dist/utils");

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 IdleInvestStrategy = await ethers.getContractFactory("IdleInvestStrategy");
const AaveV3InvestStrategy = await ethers.getContractFactory("AaveV3InvestStrategy");
const ERC4626InvestStrategy = await ethers.getContractFactory("ERC4626InvestStrategy");
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);

const AccessManagedMSV = await ethers.getContractFactory("AccessManagedMSV");
const AccessManagedProxy = await ethers.getContractFactory("AccessManagedProxy");
const AccessManager = await ethers.getContractFactory("AccessManager");
const acMgr = await AccessManager.deploy(admin);
const roles = {
LP_ROLE: 1,
LOM_ADMIN: 2,
REBALANCER_ROLE: 3,
STRATEGY_ADMIN_ROLE: 4,
QUEUE_ADMIN_ROLE: 5,
FORWARD_TO_STRATEGY_ROLE: 6,
};

async function setupVault(asset, strategies_, initStrategyDatas, depositQueue, withdrawQueue) {
const vault = await hre.upgrades.deployProxy(
AccessManagedMSV,
[
NAME,
SYMB,
await ethers.resolveAddress(asset),
await Promise.all(strategies_.map(ethers.resolveAddress)),
initStrategyDatas,
depositQueue,
withdrawQueue,
],
{
kind: "uups",
unsafeAllow: ["delegatecall"],
proxyFactory: AccessManagedProxy,
deployFunction: async (hre, opts, factory, ...args) => ozUpgradesDeploy(hre, opts, factory, ...args, acMgr),
}
);
await makeAllViewsPublic(acMgr.connect(admin), vault);
await setupAMRole(acMgr.connect(admin), vault, roles, "LP_ROLE", [
"withdraw",
"deposit",
"mint",
"redeem",
"transfer",
]);
// Whitelist LPs
await asset.connect(lp).approve(vault, MaxUint256);
await asset.connect(lp2).approve(vault, MaxUint256);
await acMgr.connect(admin).grantRole(roles.LP_ROLE, lp, 0);
await acMgr.connect(admin).grantRole(roles.LP_ROLE, lp2, 0);
return vault;
}

return {
USDC,
IdleInvestStrategy,
AaveV3InvestStrategy,
ERC4626InvestStrategy,
DummyInvestStrategy,
adminAddr,
lp,
lp2,
anon,
guardian,
admin,
investVault,
setupVault,
};
}

async function setUpIdleOnly() {
const ret = await helpers.loadFixture(setUp);
const strategy = await ret.IdleInvestStrategy.deploy(ret.USDC);
const vault = await ret.setupVault(ret.USDC, [strategy], [ethers.toUtf8Bytes("")], [0], [0]);
return { ...ret, vault, strategy };
}

async function setUpMultiStrategies() {
const ret = await helpers.loadFixture(setUp);
const strategy = await ret.IdleInvestStrategy.deploy(ret.USDC);
const erc4626strategy = await ret.ERC4626InvestStrategy.deploy(ret.investVault);
const vault = await ret.setupVault(
ret.USDC,
[erc4626strategy, strategy],
[ethers.toUtf8Bytes(""), ethers.toUtf8Bytes("")],
[0, 1],
[1, 0] // withdraw first from strategy
);
return { ...ret, vault, strategy, erc4626strategy };
}

describe("IdleInvestStrategy contract tests", function () {
it("Initializes the vault correctly", async () => {
const { USDC, vault, strategy } = await setUpIdleOnly();
expect(await vault.name()).to.equal(NAME);
expect(await vault.symbol()).to.equal(SYMB);
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.totalAssets(vault)).to.equal(0);
});

it("Deposit and withdrawal works", async () => {
const { USDC, vault, lp, strategy } = await setUpIdleOnly();
const lpBalance = await USDC.balanceOf(lp);
await vault.connect(lp).deposit(_A(100), lp);
expect(await vault.totalAssets()).to.equal(_A(100));
await vault.connect(lp).withdraw(_A(30), lp, lp);
expect(await vault.totalAssets()).to.equal(_A(70));
expect(await USDC.balanceOf(lp)).to.equal(lpBalance - _A(70));
expect(await strategy.maxDeposit(vault)).to.equal(MaxUint256);
expect(await strategy.maxWithdraw(vault)).to.equal(_A(70));

await USDC.connect(lp).transfer(vault, _A(10));
expect(await strategy.maxWithdraw(vault)).to.equal(_A(80));
expect(await strategy.totalAssets(vault)).to.equal(_A(80));
});

it("Can be combined with ERC4626InvestStrategy", async () => {
const { USDC, vault, lp, strategy, admin } = await setUpMultiStrategies();

const lpBalance = await USDC.balanceOf(lp);
await vault.connect(lp).deposit(_A(100), lp);
expect(await vault.totalAssets()).to.equal(_A(100));

await vault.connect(lp).withdraw(_A(30), lp, lp);
expect(await vault.totalAssets()).to.equal(_A(70));

expect(await strategy.maxWithdraw(vault)).to.equal(_A(0));
await vault.connect(admin).rebalance(0, 1, _A(20));

await vault.connect(lp).withdraw(_A(10), lp, lp);
await vault.connect(lp).withdraw(_A(40), lp, lp);

expect(await USDC.balanceOf(lp)).to.equal(lpBalance - _A(20));
});

it("Can be removed", async () => {
const { USDC, vault, lp, strategy, admin } = await setUpMultiStrategies();

const lpBalance = await USDC.balanceOf(lp);
await vault.connect(lp).deposit(_A(100), lp);
expect(await vault.totalAssets()).to.equal(_A(100));

await vault.connect(admin).rebalance(0, 1, _A(80));
expect(await strategy.totalAssets(vault)).equal(_A(80));

await expect(vault.connect(admin).removeStrategy(1, false)).to.be.revertedWithCustomError(
vault,
"CannotRemoveStrategyWithAssets"
);
await expect(vault.connect(admin).removeStrategy(1, true)).not.to.be.reverted;
expect(await vault.totalAssets()).to.equal(_A(20));
expect(await strategy.totalAssets(vault)).equal(_A(80));
await expect(
vault.connect(admin).addStrategy(strategy, ethers.toUtf8Bytes("foobar"))
).to.be.revertedWithCustomError(strategy, "NoExtraDataAllowed");
await expect(vault.connect(admin).addStrategy(strategy, ethers.toUtf8Bytes(""))).not.to.be.reverted;
expect(await vault.totalAssets()).to.equal(_A(100));
});

it("Checks methods can't be called directly", async () => {
const { strategy } = await setUpIdleOnly();

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, admin } = await setUpIdleOnly();
await expect(vault.connect(admin).forwardToStrategy(0, 123, ethers.toUtf8Bytes(""))).to.be.reverted;
});
});
6 changes: 2 additions & 4 deletions test/test-merkl-rewards-invest-strategy.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,11 @@ const { WEEK } = require("@ensuro/utils/js/constants");
const { buildUniswapConfig } = require("@ensuro/swaplibrary/js/utils");
const { encodeSwapConfig } = require("./utils");
const { initForkCurrency, amScheduleAndExecuteBatch, setupChain } = 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, ZeroAddress } = hre.ethers;
const { MaxUint256 } = hre.ethers;

const CURRENCY_DECIMALS = 6;
const _A = amountFunction(CURRENCY_DECIMALS);
Expand Down Expand Up @@ -98,9 +97,8 @@ async function fetchRewards(userAddress) {
const rewardData = await resp.json();
fs.writeFileSync("./test/merkl-api-resp-2.json", JSON.stringify(rewardData));
return rewardData;
} else {
return JSON.parse(fs.readFileSync("./test/merkl-api-resp.json"));
}
return JSON.parse(fs.readFileSync("./test/merkl-api-resp.json"));
}

describe("MerklRewardsInvestStrategy contract tests", function () {
Expand Down
Loading