diff --git a/contracts/contracts/harvest/OETHHarvesterSimple.sol b/contracts/contracts/harvest/OETHHarvesterSimple.sol new file mode 100644 index 0000000000..23c6ecd983 --- /dev/null +++ b/contracts/contracts/harvest/OETHHarvesterSimple.sol @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import { Strategizable } from "../governance/Strategizable.sol"; +import { IStrategy } from "../interfaces/IStrategy.sol"; +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; + +contract OETHHarvesterSimple is Strategizable { + using SafeERC20 for IERC20; + + //////////////////////////////////////////////////// + /// --- STORAGE + //////////////////////////////////////////////////// + mapping(address => bool) public supportedStrategies; + + //////////////////////////////////////////////////// + /// --- EVENTS + //////////////////////////////////////////////////// + event Harvested(address strategy, address token, uint256 amount); + event SupportedStrategyUpdated(address strategy, bool status); + + //////////////////////////////////////////////////// + /// --- CONSTRUCTOR + //////////////////////////////////////////////////// + constructor(address _governor, address _strategist) { + _setStrategistAddr(_strategist); + _changeGovernor(_governor); + } + + //////////////////////////////////////////////////// + /// --- MUTATIVE FUNCTIONS + //////////////////////////////////////////////////// + function harvestAndTransfer(address _strategy) external { + _harvestAndTransfer(_strategy); + } + + function harvestAndTransfer(address[] calldata _strategies) external { + for (uint256 i = 0; i < _strategies.length; i++) { + _harvestAndTransfer(_strategies[i]); + } + } + + function _harvestAndTransfer(address _strategy) internal { + // Ensure strategy is supported + require(supportedStrategies[_strategy], "Strategy not supported"); + + // Harvest rewards + IStrategy(_strategy).collectRewardTokens(); + + // Cache reward tokens + address[] memory rewardTokens = IStrategy(_strategy) + .getRewardTokenAddresses(); + for (uint256 i = 0; i < rewardTokens.length; i++) { + // Cache balance + uint256 balance = IERC20(rewardTokens[i]).balanceOf(address(this)); + if (balance > 0) { + // Transfer to strategist + IERC20(rewardTokens[i]).safeTransfer(strategistAddr, balance); + emit Harvested(_strategy, rewardTokens[i], balance); + } + } + } + + //////////////////////////////////////////////////// + /// --- GOVERNANCE + //////////////////////////////////////////////////// + function setSupportedStrategy(address _strategy, bool _isSupported) + external + onlyGovernorOrStrategist + { + require(_strategy != address(0), "Invalid strategy"); + supportedStrategies[_strategy] = _isSupported; + emit SupportedStrategyUpdated(_strategy, _isSupported); + } + + function transferToken(address _asset, uint256 _amount) + external + onlyGovernorOrStrategist + { + IERC20(_asset).safeTransfer(strategistAddr, _amount); + } +} diff --git a/contracts/deploy/mainnet/114_simple_harvester.js b/contracts/deploy/mainnet/114_simple_harvester.js new file mode 100644 index 0000000000..f4d40821de --- /dev/null +++ b/contracts/deploy/mainnet/114_simple_harvester.js @@ -0,0 +1,47 @@ +const addresses = require("../../utils/addresses"); +const { deploymentWithGovernanceProposal } = require("../../utils/deploy"); + +module.exports = deploymentWithGovernanceProposal( + { + deployName: "114_simple_harvester", + forceDeploy: false, + // forceSkip: true, + reduceQueueTime: true, + deployerIsProposer: false, + // proposalId: "", + }, + async ({ deployWithConfirmation }) => { + const { strategistAddr } = await getNamedAccounts(); + + // 1. Deploy contract + const dOETHHarvesterSimple = await deployWithConfirmation( + "OETHHarvesterSimple", + [addresses.mainnet.Timelock, strategistAddr] + ); + + console.log("strategistAddr: ", strategistAddr); + const cOETHHarvesterSimple = await ethers.getContractAt( + "OETHHarvesterSimple", + dOETHHarvesterSimple.address + ); + + // Get AMO contract + const cAMO = await ethers.getContractAt( + "ConvexEthMetaStrategy", + addresses.mainnet.ConvexOETHAMOStrategy + ); + + // Governance Actions + // ---------------- + return { + name: "Change harvester in OETH AMO", + actions: [ + { + contract: cAMO, + signature: "setHarvesterAddress(address)", + args: [cOETHHarvesterSimple.address], + }, + ], + }; + } +); diff --git a/contracts/test/_fixture.js b/contracts/test/_fixture.js index 87de4f5e58..71120df1b8 100644 --- a/contracts/test/_fixture.js +++ b/contracts/test/_fixture.js @@ -336,6 +336,10 @@ const defaultFixture = deployments.createFixture(async () => { morphoGauntletPrimeUSDTStrategyProxy.address ); + const simpleOETHHarvester = isFork + ? await ethers.getContract("OETHHarvesterSimple") + : undefined; + let usdt, dai, tusd, @@ -805,6 +809,7 @@ const defaultFixture = deployments.createFixture(async () => { morphoGauntletPrimeUSDCVault, morphoGauntletPrimeUSDTStrategy, morphoGauntletPrimeUSDTVault, + simpleOETHHarvester, // Flux strategy fluxStrategy, diff --git a/contracts/test/harvest/simple-harvester.mainnet.fork-test.js b/contracts/test/harvest/simple-harvester.mainnet.fork-test.js new file mode 100644 index 0000000000..d6b52e0732 --- /dev/null +++ b/contracts/test/harvest/simple-harvester.mainnet.fork-test.js @@ -0,0 +1,200 @@ +const { expect } = require("chai"); + +const addresses = require("../../utils/addresses"); +const { isCI } = require("../helpers"); +const { setERC20TokenBalance } = require("../_fund"); + +const { loadDefaultFixture } = require("../_fixture"); + +describe("ForkTest: CurvePoolBooster", function () { + this.timeout(0); + + // Retry up to 3 times on CI + this.retries(isCI ? 3 : 0); + + let fixture; + beforeEach(async () => { + fixture = await loadDefaultFixture(); + }); + + it("Should have correct parameters", async () => { + const { simpleOETHHarvester } = fixture; + const { strategistAddr } = await getNamedAccounts(); + + expect(await simpleOETHHarvester.governor()).to.be.equal( + addresses.mainnet.Timelock + ); + + expect(await simpleOETHHarvester.strategistAddr()).to.be.equal( + strategistAddr + ); + }); + + it("Should support Strategy as governor", async () => { + const { simpleOETHHarvester } = fixture; + const timelock = await ethers.provider.getSigner( + addresses.mainnet.Timelock + ); + + expect( + await simpleOETHHarvester.supportedStrategies( + addresses.mainnet.ConvexOETHAMOStrategy + ) + ).to.be.equal(false); + await simpleOETHHarvester + .connect(timelock) + .setSupportedStrategy(addresses.mainnet.ConvexOETHAMOStrategy, true); + expect( + await simpleOETHHarvester.supportedStrategies( + addresses.mainnet.ConvexOETHAMOStrategy + ) + ).to.be.equal(true); + }); + + it("Should support Strategy as strategist", async () => { + const { simpleOETHHarvester } = fixture; + const strategist = await ethers.provider.getSigner( + await simpleOETHHarvester.strategistAddr() + ); + + expect( + await simpleOETHHarvester.supportedStrategies( + addresses.mainnet.ConvexOETHAMOStrategy + ) + ).to.be.equal(false); + await simpleOETHHarvester + .connect(strategist) + .setSupportedStrategy(addresses.mainnet.ConvexOETHAMOStrategy, true); + expect( + await simpleOETHHarvester.supportedStrategies( + addresses.mainnet.ConvexOETHAMOStrategy + ) + ).to.be.equal(true); + }); + + it("Should revert if support strategy is not governor or strategist", async () => { + const { simpleOETHHarvester, josh } = fixture; + + await expect( + // prettier-ignore + simpleOETHHarvester + .connect(josh) + .setSupportedStrategy(addresses.mainnet.ConvexOETHAMOStrategy, true) + ).to.be.revertedWith("Caller is not the Strategist or Governor"); + }); + + it("Should revert if when setting strategist is not governor", async () => { + const { simpleOETHHarvester, josh } = fixture; + + await expect( + // prettier-ignore + simpleOETHHarvester + .connect(josh) + .setStrategistAddr(josh.address) + ).to.be.revertedWith("Caller is not the Governor"); + }); + + it("Should Set strategist", async () => { + const { simpleOETHHarvester, josh } = fixture; + const timelock = await ethers.provider.getSigner( + addresses.mainnet.Timelock + ); + + expect(await simpleOETHHarvester.strategistAddr()).not.to.equal( + josh.address + ); + await simpleOETHHarvester.connect(timelock).setStrategistAddr(josh.address); + expect(await simpleOETHHarvester.strategistAddr()).to.equal(josh.address); + }); + + it("Should Harvest and transfer rewards as strategist", async () => { + const { simpleOETHHarvester, convexEthMetaStrategy, crv } = fixture; + const strategistAddress = await simpleOETHHarvester.strategistAddr(); + const strategist = await ethers.provider.getSigner(strategistAddress); + + const balanceBeforeCRV = await crv.balanceOf(strategistAddress); + await simpleOETHHarvester + .connect(strategist) + .setSupportedStrategy(convexEthMetaStrategy.address, true); + // prettier-ignore + await simpleOETHHarvester + .connect(strategist)["harvestAndTransfer(address)"](convexEthMetaStrategy.address); + + const balanceAfterCRV = await crv.balanceOf(strategistAddress); + expect(balanceAfterCRV).to.be.gt(balanceBeforeCRV); + }); + + it("Should Harvest and transfer rewards as governor", async () => { + const { simpleOETHHarvester, convexEthMetaStrategy, crv } = fixture; + const timelock = await ethers.provider.getSigner( + addresses.mainnet.Timelock + ); + const strategist = await simpleOETHHarvester.strategistAddr(); + + const balanceBeforeCRV = await crv.balanceOf(strategist); + await simpleOETHHarvester + .connect(timelock) + .setSupportedStrategy(convexEthMetaStrategy.address, true); + // prettier-ignore + await simpleOETHHarvester + .connect(timelock)["harvestAndTransfer(address)"](convexEthMetaStrategy.address); + + const balanceAfterCRV = await crv.balanceOf(strategist); + expect(balanceAfterCRV).to.be.gt(balanceBeforeCRV); + }); + + it("Should revert if strategy is not authorized", async () => { + const { simpleOETHHarvester, convexEthMetaStrategy } = fixture; + const timelock = await ethers.provider.getSigner( + addresses.mainnet.Timelock + ); + + await expect( + // prettier-ignore + simpleOETHHarvester + .connect(timelock)["harvestAndTransfer(address)"](convexEthMetaStrategy.address) + ).to.be.revertedWith("Strategy not supported"); + }); + + it("Should revert if strategy is address 0", async () => { + const { simpleOETHHarvester } = fixture; + const timelock = await ethers.provider.getSigner( + addresses.mainnet.Timelock + ); + + await expect( + // prettier-ignore + simpleOETHHarvester + .connect(timelock).setSupportedStrategy(addresses.zero, true) + ).to.be.revertedWith("Invalid strategy"); + }); + + it("Should test to rescue tokens as governor", async () => { + const { simpleOETHHarvester, crv } = fixture; + const timelock = await ethers.provider.getSigner( + addresses.mainnet.Timelock + ); + + await setERC20TokenBalance(simpleOETHHarvester.address, crv, "1000"); + const balanceBeforeCRV = await crv.balanceOf(simpleOETHHarvester.address); + await simpleOETHHarvester + .connect(timelock) + .transferToken(crv.address, "1000"); + const balanceAfterCRV = await crv.balanceOf(simpleOETHHarvester.address); + expect(balanceAfterCRV).to.be.lt(balanceBeforeCRV); + }); + + it("Should test to rescue tokens as strategist", async () => { + const { simpleOETHHarvester, crv } = fixture; + const strategistAddress = await simpleOETHHarvester.strategistAddr(); + const strategist = await ethers.provider.getSigner(strategistAddress); + + await setERC20TokenBalance(simpleOETHHarvester.address, crv, "1000"); + const balanceBeforeCRV = await crv.balanceOf(simpleOETHHarvester.address); + await simpleOETHHarvester + .connect(strategist) + .transferToken(crv.address, "1000"); + const balanceAfterCRV = await crv.balanceOf(simpleOETHHarvester.address); + expect(balanceAfterCRV).to.be.lt(balanceBeforeCRV); + }); +});