diff --git a/contracts/contracts/interfaces/IMerkl.sol b/contracts/contracts/interfaces/IMerkl.sol new file mode 100644 index 0000000000..adb4a66eda --- /dev/null +++ b/contracts/contracts/interfaces/IMerkl.sol @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.0; + +interface IDistributor { + event Claimed(address indexed user, address indexed token, uint256 amount); + + function claim( + address[] calldata users, + address[] calldata tokens, + uint256[] calldata amounts, + bytes32[][] calldata proofs + ) external; +} diff --git a/contracts/contracts/strategies/Generalized4626Strategy.sol b/contracts/contracts/strategies/Generalized4626Strategy.sol index 1e5d850740..0695a6fc0c 100644 --- a/contracts/contracts/strategies/Generalized4626Strategy.sol +++ b/contracts/contracts/strategies/Generalized4626Strategy.sol @@ -8,8 +8,13 @@ pragma solidity ^0.8.0; */ import { IERC4626 } from "../../lib/openzeppelin/interfaces/IERC4626.sol"; import { IERC20, InitializableAbstractStrategy } from "../utils/InitializableAbstractStrategy.sol"; +import { IDistributor } from "../interfaces/IMerkl.sol"; contract Generalized4626Strategy is InitializableAbstractStrategy { + /// @notice The address of the Merkle Distributor contract. + IDistributor public constant merkleDistributor = + IDistributor(0x3Ef3D8bA38EBe18DB133cEc108f4D14CE00Dd9Ae); + /// @dev Replaced with an immutable variable // slither-disable-next-line constable-states address private _deprecate_shareToken; @@ -23,6 +28,8 @@ contract Generalized4626Strategy is InitializableAbstractStrategy { // For future use uint256[50] private __gap; + event ClaimedRewards(address indexed token, uint256 amount); + /** * @param _baseConfig Base strategy config with platformAddress (ERC-4626 Vault contract), eg sfrxETH or sDAI, * and vaultAddress (OToken Vault contract), eg VaultProxy or OETHVaultProxy @@ -210,4 +217,30 @@ contract Generalized4626Strategy is InitializableAbstractStrategy { function removePToken(uint256) external override onlyGovernor { revert("unsupported function"); } + + /// @notice Claim tokens from the Merkle Distributor + /// @param token The address of the token to claim. + /// @param amount The amount of tokens to claim. + /// @param proof The Merkle proof to validate the claim. + function merkleClaim( + address token, + uint256 amount, + bytes32[] calldata proof + ) external { + address[] memory users = new address[](1); + users[0] = address(this); + + address[] memory tokens = new address[](1); + tokens[0] = token; + + uint256[] memory amounts = new uint256[](1); + amounts[0] = amount; + + bytes32[][] memory proofs = new bytes32[][](1); + proofs[0] = proof; + + merkleDistributor.claim(users, tokens, amounts, proofs); + + emit ClaimedRewards(token, amount); + } } diff --git a/contracts/deploy/mainnet/160_upgrade_morpho_strategies.js b/contracts/deploy/mainnet/160_upgrade_morpho_strategies.js new file mode 100644 index 0000000000..95b929e8c2 --- /dev/null +++ b/contracts/deploy/mainnet/160_upgrade_morpho_strategies.js @@ -0,0 +1,157 @@ +const addresses = require("../../utils/addresses"); +const { deploymentWithGovernanceProposal } = require("../../utils/deploy"); + +module.exports = deploymentWithGovernanceProposal( + { + deployName: "160_upgrade_morpho_strategies", + forceDeploy: false, + // forceSkip: true, + // reduceQueueTime: true, + deployerIsProposer: false, + // proposalId: "", + }, + async ({ deployWithConfirmation, getTxOpts, withConfirmation }) => { + // Current OUSD Vault contracts + const cVaultProxy = await ethers.getContract("VaultProxy"); + const cVaultAdmin = await ethers.getContractAt( + "VaultAdmin", + cVaultProxy.address + ); + const dMorphoSteakhouseUSDCStrategyProxy = await ethers.getContract( + "MetaMorphoStrategyProxy" + ); + const dMorphoGauntletPrimeUSDCStrategyProxy = await ethers.getContract( + "MorphoGauntletPrimeUSDCStrategyProxy" + ); + const dMorphoGauntletPrimeUSDTStrategyProxy = await ethers.getContract( + "MorphoGauntletPrimeUSDTStrategyProxy" + ); + + // Deployer Actions + // ---------------- + + // Fix the signer to the deployer of the Morpho OUSD v2 strategy proxy + const sDeployer = await ethers.provider.getSigner( + "0x58890A9cB27586E83Cb51d2d26bbE18a1a647245" + ); + + // 1. Deploy new contract for Morpho Steakhouse USDC + const dMorphoSteakhouseUSDCStrategyImpl = await deployWithConfirmation( + "Generalized4626Strategy", + [ + [addresses.mainnet.MorphoSteakhouseUSDCVault, cVaultProxy.address], + addresses.mainnet.USDC, + ] + ); + + // 2. Deploy new contract for Morpho Gauntlet Prime USDC + const dMorphoGauntletPrimeUSDCStrategyImpl = await deployWithConfirmation( + "Generalized4626Strategy", + [ + [addresses.mainnet.MorphoGauntletPrimeUSDCVault, cVaultProxy.address], + addresses.mainnet.USDC, + ] + ); + + // 3. Deploy new contract for Morpho Gauntlet Prime USDT + const dMorphoGauntletPrimeUSDTStrategyImpl = await deployWithConfirmation( + "Generalized4626USDTStrategy", + [ + [addresses.mainnet.MorphoGauntletPrimeUSDTVault, cVaultProxy.address], + addresses.mainnet.USDT, + ] + ); + + // 4. Get previously deployed proxy to Morpho OUSD v2 strategy + const cOUSDMorphoV2StrategyProxy = await ethers.getContract( + "OUSDMorphoV2StrategyProxy" + ); + + // 5. Deploy new strategy for the Morpho Yearn OUSD V2 Vault + const dOUSDMorphoV2StrategyImpl = await deployWithConfirmation( + "Generalized4626Strategy", + [ + [addresses.mainnet.MorphoOUSDv2Vault, cVaultProxy.address], + addresses.mainnet.USDC, + ] + ); + const cOUSDMorphoV2Strategy = await ethers.getContractAt( + "Generalized4626Strategy", + cOUSDMorphoV2StrategyProxy.address + ); + + // 6. Construct initialize call data to initialize and configure the new strategy + const initData = cOUSDMorphoV2Strategy.interface.encodeFunctionData( + "initialize()", + [] + ); + + // 7. Init the proxy to point at the implementation, set the governor, and call initialize + const initFunction = "initialize(address,address,bytes)"; + await withConfirmation( + cOUSDMorphoV2StrategyProxy.connect(sDeployer)[initFunction]( + dOUSDMorphoV2StrategyImpl.address, + addresses.mainnet.Timelock, // governor + initData, // data for delegate call to the initialize function on the strategy + await getTxOpts() + ) + ); + + // Governance Actions + // ---------------- + return { + name: `Upgrade Morpho Steakhouse and Gauntlet Prime Strategies to claim MORPHO rewards from Merkl. +Remove the Morpho Steakhouse Strategy. +Set the Morpho Gauntlet Prime USDC strategy as the default for USDC +Add new Morpho OUSD v2 Strategy and deposit 10k USDC. +`, + actions: [ + { + // 1. Upgrade Morpho Steakhouse USDC Strategy + contract: dMorphoSteakhouseUSDCStrategyProxy, + signature: "upgradeTo(address)", + args: [dMorphoSteakhouseUSDCStrategyImpl.address], + }, + { + // 2. Upgrade Morpho Gauntlet Prime USDC Strategy + contract: dMorphoGauntletPrimeUSDCStrategyProxy, + signature: "upgradeTo(address)", + args: [dMorphoGauntletPrimeUSDCStrategyImpl.address], + }, + { + // 3. Upgrade Morpho Gauntlet Prime USDT Strategy + contract: dMorphoGauntletPrimeUSDTStrategyProxy, + signature: "upgradeTo(address)", + args: [dMorphoGauntletPrimeUSDTStrategyImpl.address], + }, + { + // 4. Add the new Morpho OUSD v2 strategy to the vault + contract: cVaultAdmin, + signature: "approveStrategy(address)", + args: [cOUSDMorphoV2Strategy.address], + }, + { + // 5. Set the Harvester of the Morpho OUSD v2 strategy to the BuyBack Operator + contract: cOUSDMorphoV2Strategy, + signature: "setHarvesterAddress(address)", + args: [addresses.multichainBuybackOperator], + }, + { + // 6. Set the Morpho Gauntlet Prime USDC strategy as the default for USDC + contract: cVaultAdmin, + signature: "setAssetDefaultStrategy(address,address)", + args: [ + addresses.mainnet.USDC, + dMorphoGauntletPrimeUSDCStrategyProxy.address, + ], + }, + { + // 7. Remove the Morpho Steakhouse strategy + contract: cVaultAdmin, + signature: "removeStrategy(address)", + args: [dMorphoSteakhouseUSDCStrategyProxy.address], + }, + ], + }; + } +); diff --git a/contracts/tasks/merkl.js b/contracts/tasks/merkl.js new file mode 100644 index 0000000000..0b7bbcd7b8 --- /dev/null +++ b/contracts/tasks/merkl.js @@ -0,0 +1,66 @@ +const axios = require("axios"); +const { formatUnits } = require("ethers/lib/utils"); + +const { logTxDetails } = require("../utils/txLogger"); + +const log = require("../utils/logger")("task:merkl"); + +const MERKL_API_ENDPOINT = "https://api.merkl.xyz/v4"; + +const getMerklRewards = async ({ userAddress, chainId = 1 }) => { + const url = `${MERKL_API_ENDPOINT}/users/${userAddress}/rewards?chainId=${chainId}`; + try { + log(`Getting Merkl rewards data from ${url}`); + + const response = await axios.get(url); + + if (response.data.length === 0 || response.data[0].rewards.length === 0) { + return { + amount: 0n, + token: null, + proofs: [], + }; + } + + return { + amount: response.data[0].rewards[0].amount, + token: response.data[0].rewards[0].token.address, + proofs: response.data[0].rewards[0].proofs, + }; + } catch (err) { + if (err.response) { + console.error("Response data : ", err.response.data); + console.error("Response status: ", err.response.status); + console.error("Response status: ", err.response.statusText); + } + throw Error(`Call to Merkl API failed: ${err.message}`); + } +}; + +async function claimMerklRewards(strategyAddress, signer) { + const result = await getMerklRewards({ + userAddress: strategyAddress, + chainId: 1, + }); + + log( + `${formatUnits(result.amount, 18)} ${ + result.token + } rewards available to claim.` + ); + + const strategy = await ethers.getContractAt( + "Generalized4626Strategy", + strategyAddress, + signer + ); + + const tx = await strategy.merkleClaim( + result.token, + result.amount, + result.proofs + ); + await logTxDetails(tx, "merkleClaim"); +} + +module.exports = { claimMerklRewards, getMerklRewards }; diff --git a/contracts/tasks/tasks.js b/contracts/tasks/tasks.js index 210de44de1..b4578c1eaa 100644 --- a/contracts/tasks/tasks.js +++ b/contracts/tasks/tasks.js @@ -141,6 +141,7 @@ const { mockBeaconRoot, copyBeaconRoot, } = require("./beaconTesting"); +const { claimMerklRewards } = require("./merkl"); const log = require("../utils/logger")("tasks"); @@ -2376,3 +2377,27 @@ subtask("tenderlyUpload", "Uploads a contract to Tenderly.") task("tenderlyUpload").setAction(async (_, __, runSuper) => { return runSuper(); }); + +subtask( + "claimMorphoRewards", + "Claim MORPHO rewards from the Morpho Vaults." +).setAction(async () => { + const signer = await getSigner(); + + const morphoVaultAddresses = [ + // Morpho Gauntlet Prime USDC + "0x2b8f37893ee713a4e9ff0ceb79f27539f20a32a1", + // Morpho Gauntlet Prime USDT + "0xe3ae7c80a1b02ccd3fb0227773553aeb14e32f26", + // Meta Morpho Vault + "0x603CDEAEC82A60E3C4A10dA6ab546459E5f64Fa0", + ]; + + for (const morphoVaultAddress of morphoVaultAddresses) { + log(`Claiming MORPHO rewards from Morpho Vault: ${morphoVaultAddress}`); + await claimMerklRewards(morphoVaultAddress, signer); + } +}); +task("claimMorphoRewards").setAction(async (_, __, runSuper) => { + return runSuper(); +}); diff --git a/contracts/test/_fixture.js b/contracts/test/_fixture.js index 86cd1ecc8e..8173f8e4f8 100644 --- a/contracts/test/_fixture.js +++ b/contracts/test/_fixture.js @@ -658,6 +658,16 @@ const defaultFixture = deployments.createFixture(async () => { morphoGauntletPrimeUSDTStrategyProxy.address ); + const morphoOUSDv2StrategyProxy = !isFork + ? undefined + : await ethers.getContract("OUSDMorphoV2StrategyProxy"); + const morphoOUSDv2Strategy = !isFork + ? undefined + : await ethers.getContractAt( + "Generalized4626Strategy", + morphoOUSDv2StrategyProxy.address + ); + const curvePoolBooster = isFork ? await ethers.getContractAt( "CurvePoolBooster", @@ -744,6 +754,7 @@ const defaultFixture = deployments.createFixture(async () => { morphoSteakHouseUSDCVault, morphoGauntletPrimeUSDCVault, morphoGauntletPrimeUSDTVault, + morphoOUSDv2Vault, ssv; let chainlinkOracleFeedDAI, @@ -824,6 +835,10 @@ const defaultFixture = deployments.createFixture(async () => { metamorphoAbi, addresses.mainnet.MorphoGauntletPrimeUSDTVault ); + morphoOUSDv2Vault = await ethers.getContractAt( + metamorphoAbi, + addresses.mainnet.MorphoOUSDv2Vault + ); morphoToken = await ethers.getContractAt( erc20Abi, addresses.mainnet.MorphoToken @@ -1143,6 +1158,8 @@ const defaultFixture = deployments.createFixture(async () => { morphoGauntletPrimeUSDCVault, morphoGauntletPrimeUSDTStrategy, morphoGauntletPrimeUSDTVault, + morphoOUSDv2Strategy, + morphoOUSDv2Vault, curvePoolBooster, simpleOETHHarvester, oethFixedRateDripper, @@ -1851,6 +1868,74 @@ async function morphoGauntletPrimeUSDTFixture( return fixture; } +/** + * Configure a Vault with default USDC strategy to Yearn's Morpho OUSD v2 Vault. + */ +async function morphoOUSDv2Fixture( + config = { + usdcMintAmount: 0, + depositToStrategy: false, + } +) { + const fixture = await defaultFixture(); + + if (isFork) { + const { usdc, josh, morphoOUSDv2Strategy, strategist, vault } = fixture; + + // TODO remove once Yearn has done this on mainnet + // // Whitelist the strategy + // const gateOwner = await impersonateAndFund( + // "0x50B75d586929Ab2F75dC15f07E1B921b7C4Ba8fA" + // ); + // const gate = await ethers.getContractAt( + // ["function setIsWhitelisted(address,bool) external"], + // "0x6704aB7aF6787930c60DFa422104E899E823e657" + // ); + // await gate + // .connect(gateOwner) + // .setIsWhitelisted(morphoOUSDv2Strategy.address, true); + + // Impersonate the OUSD Vault + fixture.vaultSigner = await impersonateAndFund(vault.address); + + fixture.buyBackSigner = await impersonateAndFund( + addresses.multichainBuybackOperator + ); + + // mint some OUSD using USDC if configured + if (config?.usdcMintAmount > 0) { + const usdcMintAmount = parseUnits(config.usdcMintAmount.toString(), 6); + await vault.connect(josh).rebase(); + await vault.connect(josh).allocate(); + + // Approve the Vault to transfer USDC + await usdc.connect(josh).approve(vault.address, usdcMintAmount); + + // Mint OUSD with USDC + // This will sit in the vault, not the strategy + await vault.connect(josh).mint(usdc.address, usdcMintAmount, 0); + + // Add USDC to the strategy + if (config?.depositToStrategy) { + // The strategist deposits the USDC to the strategy + await vault + .connect(strategist) + .depositToStrategy( + morphoOUSDv2Strategy.address, + [usdc.address], + [usdcMintAmount] + ); + } + } + } else { + throw new Error( + "Yearn's Morpho OUSD v2 strategy only supported in forked test environment" + ); + } + + return fixture; +} + /** * Configure a Vault with only the Morpho strategy. */ @@ -2928,6 +3013,7 @@ module.exports = { morphoSteakhouseUSDCFixture, morphoGauntletPrimeUSDCFixture, morphoGauntletPrimeUSDTFixture, + morphoOUSDv2Fixture, morphoCompoundFixture, aaveFixture, morphoAaveFixture, diff --git a/contracts/test/strategies/ousd-morpho-guantlet-prime-usdc.mainnet.fork-test.js b/contracts/test/strategies/ousd-morpho-guantlet-prime-usdc.mainnet.fork-test.js index c85fb37567..16048298ff 100644 --- a/contracts/test/strategies/ousd-morpho-guantlet-prime-usdc.mainnet.fork-test.js +++ b/contracts/test/strategies/ousd-morpho-guantlet-prime-usdc.mainnet.fork-test.js @@ -1,6 +1,7 @@ const { expect } = require("chai"); const { formatUnits, parseUnits } = require("ethers/lib/utils"); +const { getMerklRewards } = require("../../tasks/merkl"); const addresses = require("../../utils/addresses"); const { units, isCI } = require("../helpers"); @@ -9,7 +10,9 @@ const { morphoGauntletPrimeUSDCFixture, } = require("../_fixture"); -const log = require("../../utils/logger"); +const log = require("../../utils/logger")( + "test:fork:ousd-morpho-gauntlet-usdc" +); describe("ForkTest: Morpho Gauntlet Prime USDC Strategy", function () { this.timeout(0); @@ -357,6 +360,56 @@ describe("ForkTest: Morpho Gauntlet Prime USDC Strategy", function () { }); }); + describe("claim and collect MORPHO rewards", () => { + const loadFixture = createFixtureLoader(morphoGauntletPrimeUSDCFixture); + beforeEach(async () => { + fixture = await loadFixture(); + }); + it("Should claim MORPHO rewards", async () => { + const { josh, morphoGauntletPrimeUSDCStrategy, morphoToken } = fixture; + + const { amount, proofs } = await getMerklRewards({ + userAddress: morphoGauntletPrimeUSDCStrategy.address, + chainId: 1, + }); + log(`MORPHO rewards available to claim: ${formatUnits(amount, 18)}`); + + const tx = await morphoGauntletPrimeUSDCStrategy + .connect(josh) + .merkleClaim(morphoToken.address, amount, proofs); + await expect(tx) + .to.emit(morphoGauntletPrimeUSDCStrategy, "ClaimedRewards") + .withArgs(morphoToken.address, amount); + }); + + it("Should be able to collect MORPHO rewards", async () => { + const { strategist, josh, morphoGauntletPrimeUSDCStrategy, morphoToken } = + fixture; + + const { amount, proofs } = await getMerklRewards({ + userAddress: morphoGauntletPrimeUSDCStrategy.address, + chainId: 1, + }); + log(`MORPHO rewards available to claim: ${formatUnits(amount, 18)}`); + + await morphoGauntletPrimeUSDCStrategy + .connect(josh) + .merkleClaim(morphoToken.address, amount, proofs); + + const tx = await morphoGauntletPrimeUSDCStrategy + .connect(strategist) + .collectRewardTokens(); + + await expect(tx) + .to.emit(morphoToken, "Transfer") + .withArgs( + morphoGauntletPrimeUSDCStrategy.address, + strategist.address, + amount + ); + }); + }); + describe("administration", () => { const loadFixture = createFixtureLoader(morphoGauntletPrimeUSDCFixture); beforeEach(async () => { diff --git a/contracts/test/strategies/ousd-v2-morpho.mainnet.fork-test.js b/contracts/test/strategies/ousd-v2-morpho.mainnet.fork-test.js new file mode 100644 index 0000000000..1e21d6b992 --- /dev/null +++ b/contracts/test/strategies/ousd-v2-morpho.mainnet.fork-test.js @@ -0,0 +1,399 @@ +const { expect } = require("chai"); +const { formatUnits, parseUnits } = require("ethers/lib/utils"); + +const addresses = require("../../utils/addresses"); +const { getMerklRewards } = require("../../tasks/merkl"); +const { units, isCI } = require("../helpers"); + +const { createFixtureLoader, morphoOUSDv2Fixture } = require("../_fixture"); + +const log = require("../../utils/logger")("test:fork:ousd-v2-morpho"); + +describe("ForkTest: Yearn's Morpho OUSD v2 Strategy", function () { + this.timeout(0); + + // Retry up to 3 times on CI + this.retries(isCI ? 3 : 0); + + let fixture; + + describe("post deployment", () => { + const loadFixture = createFixtureLoader(morphoOUSDv2Fixture); + beforeEach(async () => { + fixture = await loadFixture(); + }); + it("Should have constants and immutables set", async () => { + const { vault, morphoOUSDv2Strategy } = fixture; + + expect(await morphoOUSDv2Strategy.platformAddress()).to.equal( + addresses.mainnet.MorphoOUSDv2Vault + ); + expect(await morphoOUSDv2Strategy.vaultAddress()).to.equal(vault.address); + expect(await morphoOUSDv2Strategy.shareToken()).to.equal( + addresses.mainnet.MorphoOUSDv2Vault + ); + expect(await morphoOUSDv2Strategy.assetToken()).to.equal( + addresses.mainnet.USDC + ); + expect( + await morphoOUSDv2Strategy.supportsAsset(addresses.mainnet.USDC) + ).to.equal(true); + expect( + await morphoOUSDv2Strategy.assetToPToken(addresses.mainnet.USDC) + ).to.equal(addresses.mainnet.MorphoOUSDv2Vault); + expect(await morphoOUSDv2Strategy.governor()).to.equal( + addresses.mainnet.Timelock + ); + }); + it("Should be able to check balance", async () => { + const { usdc, josh, morphoOUSDv2Strategy } = fixture; + + // This uses a transaction to call a view function so the gas usage can be reported. + const tx = await morphoOUSDv2Strategy + .connect(josh) + .populateTransaction.checkBalance(usdc.address); + await josh.sendTransaction(tx); + }); + it("Only Governor can approve all tokens", async () => { + const { + timelock, + oldTimelock, + strategist, + josh, + daniel, + domen, + morphoOUSDv2Strategy, + usdc, + vaultSigner, + } = fixture; + + // Governor can approve all tokens + const tx = await morphoOUSDv2Strategy + .connect(timelock) + .safeApproveAllTokens(); + await expect(tx).to.emit(usdc, "Approval"); + + for (const signer of [ + daniel, + domen, + josh, + strategist, + oldTimelock, + vaultSigner, + ]) { + const tx = morphoOUSDv2Strategy.connect(signer).safeApproveAllTokens(); + await expect(tx).to.be.revertedWith("Caller is not the Governor"); + } + }); + }); + + describe("with some USDC in the vault", () => { + const loadFixture = createFixtureLoader(morphoOUSDv2Fixture, { + usdcMintAmount: 12000, + depositToStrategy: false, + }); + beforeEach(async () => { + fixture = await loadFixture(); + }); + + it("Vault should deposit some USDC to strategy", async function () { + const { + usdc, + ousd, + morphoOUSDv2Strategy, + vault, + strategist, + vaultSigner, + } = fixture; + + const checkBalanceBefore = await morphoOUSDv2Strategy.checkBalance( + usdc.address + ); + + const usdcDepositAmount = await units("1000", usdc); + + // Vault transfers USDC to strategy + await usdc + .connect(vaultSigner) + .transfer(morphoOUSDv2Strategy.address, usdcDepositAmount); + + await vault.connect(strategist).rebase(); + + const ousdSupplyBefore = await ousd.totalSupply(); + + const tx = await morphoOUSDv2Strategy + .connect(vaultSigner) + .deposit(usdc.address, usdcDepositAmount); + + // Check emitted event + await expect(tx) + .to.emit(morphoOUSDv2Strategy, "Deposit") + .withArgs( + usdc.address, + addresses.mainnet.MorphoOUSDv2Vault, + usdcDepositAmount + ); + + // Check the OUSD total supply increase + const ousdSupplyAfter = await ousd.totalSupply(); + expect(ousdSupplyAfter).to.approxEqualTolerance( + ousdSupplyBefore.add(usdcDepositAmount), + 0.1 // 0.1% or 10 basis point + ); + expect( + await morphoOUSDv2Strategy.checkBalance(usdc.address) + ).to.approxEqualTolerance( + checkBalanceBefore.add(usdcDepositAmount), + 0.01 + ); // 0.01% or 1 basis point + }); + it("Only vault can deposit some USDC to the strategy", async function () { + const { + usdc, + morphoOUSDv2Strategy, + vaultSigner, + strategist, + timelock, + oldTimelock, + josh, + } = fixture; + + const depositAmount = await units("50", usdc); + await usdc + .connect(vaultSigner) + .transfer(morphoOUSDv2Strategy.address, depositAmount); + + for (const signer of [strategist, oldTimelock, timelock, josh]) { + const tx = morphoOUSDv2Strategy + .connect(signer) + .deposit(usdc.address, depositAmount); + + await expect(tx).to.revertedWith("Caller is not the Vault"); + } + }); + it("Only vault can deposit all USDC to strategy", async function () { + const { + usdc, + morphoOUSDv2Strategy, + vaultSigner, + strategist, + timelock, + oldTimelock, + josh, + } = fixture; + + const depositAmount = await units("50", usdc); + await usdc + .connect(vaultSigner) + .transfer(morphoOUSDv2Strategy.address, depositAmount); + + for (const signer of [strategist, oldTimelock, timelock, josh]) { + const tx = morphoOUSDv2Strategy.connect(signer).depositAll(); + + await expect(tx).to.revertedWith("Caller is not the Vault"); + } + + const tx = await morphoOUSDv2Strategy.connect(vaultSigner).depositAll(); + await expect(tx).to.emit(morphoOUSDv2Strategy, "Deposit"); + }); + }); + + describe("with the strategy having some USDC in Morpho Strategy", () => { + const loadFixture = createFixtureLoader(morphoOUSDv2Fixture, { + usdcMintAmount: 12000, + depositToStrategy: true, + }); + beforeEach(async () => { + fixture = await loadFixture(); + }); + + it("Vault should be able to withdraw all", async () => { + const { + usdc, + morphoOUSDv2Vault, + morphoOUSDv2Strategy, + ousd, + vault, + vaultSigner, + } = fixture; + + const minBalance = await units("12000", usdc); + const strategyVaultShares = await morphoOUSDv2Vault.balanceOf( + morphoOUSDv2Strategy.address + ); + const usdcWithdrawAmountExpected = + await morphoOUSDv2Vault.convertToAssets(strategyVaultShares); + expect(usdcWithdrawAmountExpected).to.be.gte(minBalance.sub(1)); + + log( + `Expected to withdraw ${formatUnits( + usdcWithdrawAmountExpected, + 6 + )} USDC` + ); + + const ousdSupplyBefore = await ousd.totalSupply(); + const vaultUSDCBalanceBefore = await usdc.balanceOf(vault.address); + + log("Before withdraw all from strategy"); + + // Now try to withdraw all the WETH from the strategy + const tx = await morphoOUSDv2Strategy.connect(vaultSigner).withdrawAll(); + + log("After withdraw all from strategy"); + + // Check emitted event + await expect(tx).to.emittedEvent("Withdrawal", [ + usdc.address, + morphoOUSDv2Vault.address, + (amount) => + expect(amount).approxEqualTolerance( + usdcWithdrawAmountExpected, + 0.01, + "Withdrawal amount" + ), + ]); + + // Check the OUSD total supply stays the same + expect(await ousd.totalSupply()).to.approxEqualTolerance( + ousdSupplyBefore, + 0.01 // 0.01% or 1 basis point + ); + + // Check the USDC amount in the vault increases + expect(await usdc.balanceOf(vault.address)).to.approxEqualTolerance( + vaultUSDCBalanceBefore.add(usdcWithdrawAmountExpected), + 0.01 + ); + }); + it("Vault should be able to withdraw some USDC", async () => { + const { + usdc, + morphoOUSDv2Vault, + morphoOUSDv2Strategy, + ousd, + vault, + vaultSigner, + } = fixture; + + const withdrawAmount = await units("1000", usdc); + + const ousdSupplyBefore = await ousd.totalSupply(); + const vaultUSDCBalanceBefore = await usdc.balanceOf(vault.address); + + log(`Before withdraw of ${formatUnits(withdrawAmount, 6)} from strategy`); + + // Now try to withdraw the USDC from the strategy + const tx = await morphoOUSDv2Strategy + .connect(vaultSigner) + .withdraw(vault.address, usdc.address, withdrawAmount); + + log("After withdraw from strategy"); + + // Check emitted event + await expect(tx) + .to.emit(morphoOUSDv2Strategy, "Withdrawal") + .withArgs(usdc.address, morphoOUSDv2Vault.address, withdrawAmount); + + // Check the OUSD total supply stays the same + const ousdSupplyAfter = await ousd.totalSupply(); + expect(ousdSupplyAfter).to.approxEqualTolerance( + ousdSupplyBefore, + 0.01 // 0.01% or 1 basis point + ); + + // Check the USDC balance in the Vault + expect(await usdc.balanceOf(vault.address)).to.equal( + vaultUSDCBalanceBefore.add(withdrawAmount) + ); + }); + it("Only vault can withdraw some USDC from strategy", async function () { + const { + morphoOUSDv2Strategy, + oethVault, + strategist, + timelock, + oldTimelock, + josh, + weth, + } = fixture; + + for (const signer of [strategist, timelock, oldTimelock, josh]) { + const tx = morphoOUSDv2Strategy + .connect(signer) + .withdraw(oethVault.address, weth.address, parseUnits("50")); + + await expect(tx).to.revertedWith("Caller is not the Vault"); + } + }); + it("Only vault and governor can withdraw all USDC from Maker DSR strategy", async function () { + const { morphoOUSDv2Strategy, strategist, timelock, josh } = fixture; + + for (const signer of [strategist, josh]) { + const tx = morphoOUSDv2Strategy.connect(signer).withdrawAll(); + + await expect(tx).to.revertedWith("Caller is not the Vault or Governor"); + } + + // Governor can withdraw all + const tx = morphoOUSDv2Strategy.connect(timelock).withdrawAll(); + await expect(tx).to.emit(morphoOUSDv2Strategy, "Withdrawal"); + }); + }); + + describe("claim and collect MORPHO rewards", () => { + const loadFixture = createFixtureLoader(morphoOUSDv2Fixture); + beforeEach(async () => { + fixture = await loadFixture(); + }); + it("Should claim MORPHO rewards", async () => { + const { josh, morphoOUSDv2Strategy, morphoToken } = fixture; + + const { amount, proofs } = await getMerklRewards({ + userAddress: morphoOUSDv2Strategy.address, + chainId: 1, + }); + log(`MORPHO rewards available to claim: ${formatUnits(amount, 18)}`); + + if (amount != "0") { + const tx = await morphoOUSDv2Strategy + .connect(josh) + .merkleClaim(morphoToken.address, amount, proofs); + await expect(tx) + .to.emit(morphoOUSDv2Strategy, "ClaimedRewards") + .withArgs(morphoToken.address, amount); + } + }); + + it("Should be able to collect MORPHO rewards", async () => { + const { buyBackSigner, josh, morphoOUSDv2Strategy, morphoToken } = + fixture; + + const { amount, proofs } = await getMerklRewards({ + userAddress: morphoOUSDv2Strategy.address, + chainId: 1, + }); + log(`MORPHO rewards available to claim: ${formatUnits(amount, 18)}`); + + if (amount != "0") { + await morphoOUSDv2Strategy + .connect(josh) + .merkleClaim(morphoToken.address, amount, proofs); + } + + const tx = await morphoOUSDv2Strategy + .connect(buyBackSigner) + .collectRewardTokens(); + + if (amount != "0") { + await expect(tx) + .to.emit(morphoToken, "Transfer") + .withArgs( + morphoOUSDv2Strategy.address, + buyBackSigner.address, + amount + ); + } + }); + }); +}); diff --git a/contracts/utils/addresses.js b/contracts/utils/addresses.js index d7f4d34b84..bec94b3b2c 100644 --- a/contracts/utils/addresses.js +++ b/contracts/utils/addresses.js @@ -207,6 +207,8 @@ addresses.mainnet.MorphoGauntletPrimeUSDCVault = "0xdd0f28e19C1780eb6396170735D45153D261490d"; addresses.mainnet.MorphoGauntletPrimeUSDTVault = "0x8CB3649114051cA5119141a34C200D65dc0Faa73"; +addresses.mainnet.MorphoOUSDv2Vault = + "0xFB154c729A16802c4ad1E8f7FF539a8b9f49c960"; addresses.mainnet.UniswapOracle = "0xc15169Bad17e676b3BaDb699DEe327423cE6178e"; addresses.mainnet.CompensationClaims =