diff --git a/contracts/contracts/mocks/MockERC4626Vault.sol b/contracts/contracts/mocks/MockERC4626Vault.sol index 02b4672c2d..c679037409 100644 --- a/contracts/contracts/mocks/MockERC4626Vault.sol +++ b/contracts/contracts/mocks/MockERC4626Vault.sol @@ -22,6 +22,7 @@ contract MockERC4626Vault is IERC4626, ERC20 { function deposit(uint256 assets, address receiver) public + virtual override returns (uint256 shares) { @@ -46,7 +47,7 @@ contract MockERC4626Vault is IERC4626, ERC20 { uint256 assets, address receiver, address owner - ) public override returns (uint256 shares) { + ) public virtual override returns (uint256 shares) { shares = previewWithdraw(assets); if (msg.sender != owner) { // No approval check for mock @@ -70,7 +71,7 @@ contract MockERC4626Vault is IERC4626, ERC20 { return assets; } - function totalAssets() public view override returns (uint256) { + function totalAssets() public view virtual override returns (uint256) { return IERC20(asset).balanceOf(address(this)); } @@ -110,11 +111,23 @@ contract MockERC4626Vault is IERC4626, ERC20 { return type(uint256).max; } - function maxWithdraw(address owner) public view override returns (uint256) { + function maxWithdraw(address owner) + public + view + virtual + override + returns (uint256) + { return convertToAssets(balanceOf(owner)); } - function maxRedeem(address owner) public view override returns (uint256) { + function maxRedeem(address owner) + public + view + virtual + override + returns (uint256) + { return balanceOf(owner); } diff --git a/contracts/contracts/mocks/MockMorphoV2CreditVault.sol b/contracts/contracts/mocks/MockMorphoV2CreditVault.sol new file mode 100644 index 0000000000..caa8594f47 --- /dev/null +++ b/contracts/contracts/mocks/MockMorphoV2CreditVault.sol @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.0; + +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; + +import { MockERC4626Vault } from "./MockERC4626Vault.sol"; + +/** + * @title Mock Morpho Vault V2 credit vault + * @notice ERC-4626 mock that decouples the reported position value (previewRedeem) from the + * liquid balance available to withdraw, so tests can model accrued-but-unrealized + * interest and OToken that has been borrowed out. + * @dev Mirrors production: maxWithdraw()/maxRedeem() return 0 (the strategy reads liquidity via + * MorphoV2VaultUtils, not these), and a liquidityAdapter() getter is exposed. The strategy + * is the sole depositor in the gated vault, so it always holds 100% of the shares and + * previewRedeem(allShares) == reported total assets. + */ +contract MockMorphoV2CreditVault is MockERC4626Vault { + using SafeERC20 for IERC20; + + /// @notice Reported total assets, driving previewRedeem independent of the idle balance. + uint256 private _reportedTotalAssets; + + /// @notice The Morpho V1 liquidity adapter address. + address public liquidityAdapter; + + constructor(address _asset) MockERC4626Vault(_asset) {} + + function setLiquidityAdapter(address _liquidityAdapter) external { + liquidityAdapter = _liquidityAdapter; + } + + /// @dev Reported value, not the idle token balance. + function totalAssets() public view override returns (uint256) { + return _reportedTotalAssets; + } + + function deposit(uint256 assets, address receiver) + public + override + returns (uint256 shares) + { + shares = super.deposit(assets, receiver); + _reportedTotalAssets += assets; + } + + function withdraw( + uint256 assets, + address receiver, + address owner + ) public override returns (uint256 shares) { + shares = super.withdraw(assets, receiver, owner); + _reportedTotalAssets -= assets; + } + + /// @notice Simulate interest accruing as a claim: lifts previewRedeem without adding liquidity. + function simulateInterest(uint256 amount) external { + _reportedTotalAssets += amount; + } + + /// @notice Simulate OToken being borrowed/drawn: removes idle liquidity without changing value. + function simulateBorrow(uint256 amount, address to) external { + IERC20(asset).safeTransfer(to, amount); + } + + /// @dev Morpho V2 vaults return 0 from these; liquidity is read via MorphoV2VaultUtils. + function maxWithdraw(address) public view override returns (uint256) { + return 0; + } + + function maxRedeem(address) public view override returns (uint256) { + return 0; + } +} diff --git a/contracts/contracts/proxies/Proxies.sol b/contracts/contracts/proxies/Proxies.sol index 1190afac45..d418eb97eb 100644 --- a/contracts/contracts/proxies/Proxies.sol +++ b/contracts/contracts/proxies/Proxies.sol @@ -252,6 +252,15 @@ contract OUSDMorphoV2StrategyProxy is InitializeGovernedUpgradeabilityProxy { } +/** + * @notice OUSDCreditMarketAMOStrategyProxy delegates calls to a CreditMarketAMOStrategy implementation + */ +contract OUSDCreditMarketAMOStrategyProxy is + InitializeGovernedUpgradeabilityProxy +{ + +} + /** * @notice Legacy Supernova AMO proxy */ diff --git a/contracts/contracts/strategies/AbstractMerkleClaimStrategy.sol b/contracts/contracts/strategies/AbstractMerkleClaimStrategy.sol new file mode 100644 index 0000000000..d35667c6eb --- /dev/null +++ b/contracts/contracts/strategies/AbstractMerkleClaimStrategy.sol @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.0; + +/** + * @title Abstract strategy with Merkl reward claiming + * @notice Adds the ability to claim rewards from the Merkl Distributor to a strategy. + * @dev Holds no storage so it is safe to insert into the inheritance chain of an + * already deployed upgradeable strategy without shifting any storage slots. + * @author Origin Protocol Inc + */ +import { InitializableAbstractStrategy } from "../utils/InitializableAbstractStrategy.sol"; +import { IDistributor } from "../interfaces/IMerkl.sol"; + +abstract contract AbstractMerkleClaimStrategy is InitializableAbstractStrategy { + /// @notice The address of the Merkle Distributor contract. + IDistributor public constant merkleDistributor = + IDistributor(0x3Ef3D8bA38EBe18DB133cEc108f4D14CE00Dd9Ae); + + event ClaimedRewards(address indexed token, uint256 amount); + + /** + * @param _baseConfig The platform and OToken vault addresses + */ + constructor(BaseStrategyConfig memory _baseConfig) + InitializableAbstractStrategy(_baseConfig) + {} + + /// @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/contracts/strategies/CreditMarketAMOStrategy.sol b/contracts/contracts/strategies/CreditMarketAMOStrategy.sol new file mode 100644 index 0000000000..61bcff0cce --- /dev/null +++ b/contracts/contracts/strategies/CreditMarketAMOStrategy.sol @@ -0,0 +1,306 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.0; + +/** + * @title Credit Market AMO Strategy + * @notice AMO strategy that mints an OToken and supplies it as lending liquidity into a + * deposit-gated Morpho Vault V2 ("credit vault"). Borrowers post their own collateral + * and borrow the OToken; the interest they pay grows this strategy's ERC-4626 position + * and reaches OToken holders as yield through the Vault's normal rebase. + * + * The strategy never holds the Vault's backing asset (USDC/WETH). The only thing it + * puts in is OToken it mints itself; the only thing it takes out is OToken it burns. + * The loan book can only shrink. This is the crvUSD/GHO model: minted OToken is backed + * by the borrower's collateral in the credit vault's markets. + * + * @dev Phantom backing, by design: checkBalance reports the full live position value, which + * keeps total value and rebase correct, but this strategy provides ZERO redemption + * capacity to the Vault - it can never return a real asset, only burn OToken. Because a + * borrower can draw the minted OToken and redeem it 1:1 at the OToken Vault for the + * backing asset, `mintCap` is effectively a claim this credit book lays against the + * Vault's redeemable reserves. Operate with: mintCap <= the real redeemable liquidity + * elsewhere that can absorb a run while this book is wound down by burning. + * + * @author Origin Protocol Inc + */ +import { Math } from "@openzeppelin/contracts/utils/math/Math.sol"; + +import { IERC20, InitializableAbstractStrategy } from "../utils/InitializableAbstractStrategy.sol"; +import { AbstractMerkleClaimStrategy } from "./AbstractMerkleClaimStrategy.sol"; +import { MorphoV2VaultUtils } from "./MorphoV2VaultUtils.sol"; +import { StableMath } from "../utils/StableMath.sol"; +import { IVault } from "../interfaces/IVault.sol"; +import { IVaultV2 } from "../interfaces/morpho/IVaultV2.sol"; +import { IBasicToken } from "../interfaces/IBasicToken.sol"; + +contract CreditMarketAMOStrategy is AbstractMerkleClaimStrategy { + using StableMath for uint256; + + /// @notice The OToken this strategy mints and supplies, e.g. OUSD or OETH. + IERC20 public immutable oToken; + /// @notice The Vault's backing asset, e.g. USDC or WETH. Used only to denominate checkBalance. + IERC20 public immutable hardAsset; + /// @notice The deposit-gated Morpho Vault V2 credit vault. Its asset() must equal oToken. + IVaultV2 public immutable creditVault; + /// @notice Decimals of the OToken, for checkBalance scaling. + uint8 public immutable oTokenDecimals; + /// @notice Decimals of the hard asset, for checkBalance scaling. + uint8 public immutable hardAssetDecimals; + + /// @notice The strategy's principal still deployed in the credit vault, in OToken units, and + /// the value bounded by `mintCap`. Raised by mintAndSupply; on a burn it is lowered to + /// the position's remaining value when that is smaller, i.e. accrued interest is drawn + /// down before principal. Hence the invariant `netMinted <= positionValue()` holds. + /// @dev Burns are NAV-neutral (a burn lowers both OToken supply and checkBalance by the same + /// amount), so how a burn is split between interest and principal is only an accounting + /// label here, never an input to Vault solvency. + uint256 public netMinted; + /// @notice Maximum allowed `netMinted`. Starts at 0, so minting is off until governance raises it. + uint256 public mintCap; + + uint256[48] private __gap; + + event Supplied(uint256 oTokenAmount, uint256 sharesReceived); + event Redeemed(uint256 oTokenAmount, uint256 sharesBurned); + event MintCapUpdated(uint256 oldCap, uint256 newCap); + + /** + * @param _baseConfig platformAddress = the Morpho V2 credit vault, + * vaultAddress = the OToken Vault (eg VaultProxy or OETHVaultProxy). + * @param _oToken The OToken to mint and supply (eg OUSD or OETH). + * @param _hardAsset The Vault's backing asset (eg USDC or WETH). + */ + constructor( + BaseStrategyConfig memory _baseConfig, + address _oToken, + address _hardAsset + ) AbstractMerkleClaimStrategy(_baseConfig) { + creditVault = IVaultV2(_baseConfig.platformAddress); + oToken = IERC20(_oToken); + hardAsset = IERC20(_hardAsset); + oTokenDecimals = IBasicToken(_oToken).decimals(); + hardAssetDecimals = IBasicToken(_hardAsset).decimals(); + } + + /** + * @notice Initialize the strategy. Validates the credit vault's asset and approves it to + * pull the OToken. + * @dev No assetToPToken plumbing: the supported asset is the hardAsset (for Vault + * accounting) while the ERC-4626 underlying is the oToken. + */ + function initialize() external onlyGovernor initializer { + require( + creditVault.asset() == address(oToken), + "Credit vault asset must be oToken" + ); + + InitializableAbstractStrategy._initialize( + new address[](0), // reward tokens, set later via setRewardTokenAddresses + new address[](0), // assets + new address[](0) // pTokens + ); + + _approveBase(); + } + + /*************************************** + Credit AMO actions + ****************************************/ + + /** + * @notice Mint OToken through the Vault and supply it to the credit vault. + * @param amount Amount of OToken to mint and supply. + * @return shares Credit vault shares received. + */ + function mintAndSupply(uint256 amount) + external + onlyGovernorOrStrategist + nonReentrant + returns (uint256 shares) + { + require(amount > 0, "Must mint something"); + uint256 newNetMinted = netMinted + amount; + require(newNetMinted <= mintCap, "Mint cap exceeded"); + + // Update accounting before the external calls (checks-effects-interactions). + // Both calls are to trusted contracts and revert the whole tx on failure. + netMinted = newNetMinted; + + // Mint the OToken to this strategy, then supply it to the credit vault. + IVault(vaultAddress).mintForStrategy(amount); + shares = creditVault.deposit(amount, address(this)); + + emit Supplied(amount, shares); + } + + /** + * @notice Withdraw OToken from the credit vault and burn it through the Vault. + * Capped at the currently liquid amount. + * @param amount Desired amount of OToken to redeem and burn. + * @return withdrawn Amount of OToken actually withdrawn and burned. + */ + function redeemAndBurn(uint256 amount) + external + onlyGovernorOrStrategist + nonReentrant + returns (uint256 withdrawn) + { + withdrawn = Math.min(amount, maxWithdrawable()); + require(withdrawn > 0, "Nothing to withdraw"); + _redeemAndBurn(withdrawn); + } + + /** + * @notice Withdraw all currently liquid OToken and burn it. Anything still lent out stays + * in the position and can be redeemed later as it frees up. + * @dev Tolerates zero liquidity (no-op) so removeStrategy / governance cleanup never reverts. + */ + function withdrawAll() external override onlyVaultOrGovernor nonReentrant { + uint256 withdrawn = maxWithdrawable(); + if (withdrawn > 0) { + _redeemAndBurn(withdrawn); + } + } + + /** + * @dev Pull `withdrawn` OToken out of the credit vault and burn it. `withdrawn` must already + * be capped at maxWithdrawable() and be greater than zero. + */ + function _redeemAndBurn(uint256 withdrawn) internal { + // Yield-first accounting: principal is whatever the position is worth after this burn, + // capped at its prior value. Interest is drawn down before principal, preserving the + // invariant netMinted <= positionValue(). Written before the external calls (CEI). + uint256 pv = positionValue(); + uint256 remaining = pv > withdrawn ? pv - withdrawn : 0; // clamp guards rounding dust + if (netMinted > remaining) { + netMinted = remaining; + } + + // Withdraw to self, then burn from self. + uint256 shares = creditVault.withdraw( + withdrawn, + address(this), + address(this) + ); + IVault(vaultAddress).burnForStrategy(withdrawn); + + emit Redeemed(withdrawn, shares); + } + + /*************************************** + Liquidity and analytics + ****************************************/ + + /** + * @notice OToken that can be pulled from the credit vault right now: the vault's idle + * balance plus the V1 adapter's maxWithdraw. + */ + function maxWithdrawable() public view returns (uint256) { + return + MorphoV2VaultUtils.maxWithdrawableAssets( + address(creditVault), + address(oToken) + ); + } + + /// @notice Full position value (principal plus accrued interest), in OToken units. + /// @dev Uses previewRedeem, which on a Morpho V2 vault is pure share-accounting: it reports the + /// vault's full asset value per share, including assets lent out / borrowed, and does NOT + /// drop when utilization is high. That full claim is exactly what checkBalance and rebase + /// need; the liquid portion is tracked separately by maxWithdrawable(). A liquidity-aware + /// ERC-4626 (e.g. an Aave vault) would need convertToAssets instead, but this strategy is + /// Morpho V2 specific. + function positionValue() public view returns (uint256) { + return creditVault.previewRedeem(creditVault.balanceOf(address(this))); + } + + /// @notice Interest currently embedded in the position above the deployed principal, in OToken + /// units. A live snapshot under yield-first accounting (netMinted <= positionValue()), + /// not lifetime yield earned. + function accruedYield() external view returns (uint256) { + uint256 value = positionValue(); + return value > netMinted ? value - netMinted : 0; + } + + /*************************************** + Vault accounting + ****************************************/ + + /** + * @notice Full live position value, denominated in the hard asset at a 1:1 OToken value. + * @dev Reports principal plus accrued interest. Never reverts and never goes negative. + * @param _asset Must be the hard asset. + * @return balance The position value scaled to hard asset decimals. + */ + function checkBalance(address _asset) + external + view + override + returns (uint256 balance) + { + require(_asset == address(hardAsset), "Unsupported asset"); + balance = positionValue().scaleBy(hardAssetDecimals, oTokenDecimals); + } + + /// @notice True only for the hard asset. + function supportsAsset(address _asset) public view override returns (bool) { + return _asset == address(hardAsset); + } + + /*************************************** + Config and admin + ****************************************/ + + /// @notice Set the maximum allowed `netMinted`. Starts at 0 (minting off). + function setMintCap(uint256 _mintCap) external onlyGovernorOrStrategist { + emit MintCapUpdated(mintCap, _mintCap); + mintCap = _mintCap; + } + + /// @notice Approve the credit vault to pull the OToken on deposit. + function safeApproveAllTokens() external override onlyGovernor { + _approveBase(); + } + + function _approveBase() internal { + // slither-disable-next-line unused-return + oToken.approve(address(creditVault), type(uint256).max); + } + + /*************************************** + Disabled entry and exit + ****************************************/ + + /// @dev The Vault never sends backing assets to a credit AMO. Reverts so any misrouted + /// backing asset fails loudly rather than being silently stranded. + function deposit(address, uint256) external pure override { + revert("unsupported function"); + } + + /// @dev See deposit(). + function depositAll() external pure override { + revert("unsupported function"); + } + + /// @dev See deposit(). + function withdraw( + address, + address, + uint256 + ) external pure override { + revert("unsupported function"); + } + + /// @notice Not supported. The credit vault is fixed at deploy time. + function setPTokenAddress(address, address) external override onlyGovernor { + revert("unsupported function"); + } + + /// @notice Not supported. The credit vault is fixed at deploy time. + function removePToken(uint256) external override onlyGovernor { + revert("unsupported function"); + } + + /// @dev This strategy uses no per-asset platform token. + function _abstractSetPToken(address, address) internal override {} +} diff --git a/contracts/contracts/strategies/Generalized4626Strategy.sol b/contracts/contracts/strategies/Generalized4626Strategy.sol index d451ff672e..49c9a94f68 100644 --- a/contracts/contracts/strategies/Generalized4626Strategy.sol +++ b/contracts/contracts/strategies/Generalized4626Strategy.sol @@ -11,13 +11,9 @@ 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); +import { AbstractMerkleClaimStrategy } from "./AbstractMerkleClaimStrategy.sol"; +contract Generalized4626Strategy is AbstractMerkleClaimStrategy { /// @dev Replaced with an immutable variable // slither-disable-next-line constable-states address private _deprecate_shareToken; @@ -31,15 +27,13 @@ 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 * @param _assetToken Address of the ERC-4626 asset token. eg frxETH or DAI */ constructor(BaseStrategyConfig memory _baseConfig, address _assetToken) - InitializableAbstractStrategy(_baseConfig) + AbstractMerkleClaimStrategy(_baseConfig) { shareToken = IERC20(_baseConfig.platformAddress); assetToken = IERC20(_assetToken); @@ -233,30 +227,4 @@ 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/201_ousd_credit_amo_proxy.js b/contracts/deploy/mainnet/201_ousd_credit_amo_proxy.js new file mode 100644 index 0000000000..8ff5e13e32 --- /dev/null +++ b/contracts/deploy/mainnet/201_ousd_credit_amo_proxy.js @@ -0,0 +1,26 @@ +const { deploymentWithGovernanceProposal } = require("../../utils/deploy"); + +// Deploys the OUSD Credit Market AMO strategy proxy first, so its address is known +// before the gated Morpho V2 credit vault is configured (the credit vault must allow +// only this proxy to deposit). The implementation + registration follow in deploy 202. +module.exports = deploymentWithGovernanceProposal( + { + deployName: "201_ousd_credit_amo_proxy", + forceDeploy: false, + reduceQueueTime: true, + deployerIsProposer: false, + proposalId: "", + }, + async ({ deployWithConfirmation }) => { + await deployWithConfirmation("OUSDCreditMarketAMOStrategyProxy"); + const cProxy = await ethers.getContract("OUSDCreditMarketAMOStrategyProxy"); + + console.log( + `OUSDCreditMarketAMOStrategyProxy deployed to ${cProxy.address}` + ); + + return { + actions: [], + }; + } +); diff --git a/contracts/deploy/mainnet/202_ousd_credit_amo.js b/contracts/deploy/mainnet/202_ousd_credit_amo.js new file mode 100644 index 0000000000..a2e5b49cf5 --- /dev/null +++ b/contracts/deploy/mainnet/202_ousd_credit_amo.js @@ -0,0 +1,84 @@ +const addresses = require("../../utils/addresses"); +const { deploymentWithGovernanceProposal } = require("../../utils/deploy"); + +// Deploys the OUSD Credit Market AMO strategy implementation, initializes its proxy and +// registers it with the OUSD Vault. +// +// Gated with `forceSkip: true` until the gated Morpho V2 credit vault exists on-chain. +// To activate: +// 1. Run deploy 201 to get the OUSDCreditMarketAMOStrategyProxy address. +// 2. Create the gated credit vault (asset() == OUSD, sole depositor == that proxy) and +// set addresses.mainnet.OUSDCreditMarketVault to its address. +// 3. Set forceSkip to false, add the governance proposalId, and size the mint cap via a +// follow-up setMintCap call (the cap starts at 0, so minting stays off until then). +module.exports = deploymentWithGovernanceProposal( + { + deployName: "202_ousd_credit_amo", + forceDeploy: false, + forceSkip: true, + reduceQueueTime: true, + deployerIsProposer: false, + proposalId: "", + }, + async ({ deployWithConfirmation, ethers, withConfirmation }) => { + const { deployerAddr } = await getNamedAccounts(); + const sDeployer = await ethers.provider.getSigner(deployerAddr); + + const cVaultProxy = await ethers.getContract("VaultProxy"); + const cVault = await ethers.getContractAt("IVault", cVaultProxy.address); + const cOUSDProxy = await ethers.getContract("OUSDProxy"); + + const cProxy = await ethers.getContract("OUSDCreditMarketAMOStrategyProxy"); + + console.log("Deploy CreditMarketAMOStrategy implementation"); + const dImpl = await deployWithConfirmation("CreditMarketAMOStrategy", [ + // _baseConfig: platformAddress = credit vault, vaultAddress = OUSD Vault + [addresses.mainnet.OUSDCreditMarketVault, cVaultProxy.address], + cOUSDProxy.address, // oToken (OUSD) + addresses.mainnet.USDC, // hardAsset + ]); + const cImpl = await ethers.getContractAt( + "CreditMarketAMOStrategy", + dImpl.address + ); + const cStrategy = await ethers.getContractAt( + "CreditMarketAMOStrategy", + cProxy.address + ); + + const initData = cImpl.interface.encodeFunctionData("initialize()", []); + + console.log("Initialize OUSDCreditMarketAMOStrategyProxy"); + await withConfirmation( + cProxy.connect(sDeployer)[ + // eslint-disable-next-line no-unexpected-multiline + "initialize(address,address,bytes)" + ]( + dImpl.address, + addresses.mainnet.Timelock, // governor + initData + ) + ); + + return { + name: "Deploy the OUSD Credit Market AMO Strategy and register it with the OUSD Vault", + actions: [ + { + contract: cVault, + signature: "approveStrategy(address)", + args: [cProxy.address], + }, + { + contract: cVault, + signature: "addStrategyToMintWhitelist(address)", + args: [cProxy.address], + }, + { + contract: cStrategy, + signature: "safeApproveAllTokens()", + args: [], + }, + ], + }; + } +); diff --git a/contracts/test/_fixture.js b/contracts/test/_fixture.js index d41e47da02..3e7a54855c 100644 --- a/contracts/test/_fixture.js +++ b/contracts/test/_fixture.js @@ -1761,6 +1761,112 @@ async function loadSimpleOETHFixture() { return await simpleOETHFixture(); } +// Unit test fixture for the Credit Market AMO Strategy. Deploys a mock Morpho V2 +// credit vault (asset == the OToken) plus the strategy behind a proxy, registers it +// with the OToken vault and turns on minting. Pass { oTokenSymbol: "OETH" } to exercise +// the OETH/WETH (18-decimal) config; defaults to OUSD/USDC (6-decimal). +async function creditMarketAMOFixture(config = {}) { + const oTokenSymbol = config.oTokenSymbol || "OUSD"; + const fixture = await defaultFixture(); + const { josh } = fixture; + + let oToken, hardAsset, oTokenVault; + if (oTokenSymbol === "OETH") { + oToken = fixture.oeth; + hardAsset = fixture.weth; + oTokenVault = fixture.oethVault; + } else { + oToken = fixture.ousd; + hardAsset = fixture.usdc; + oTokenVault = fixture.vault; + } + + // The strategy and vault are governed by the OToken vault's governor (the deployer + // locally, the Timelock on a fork). + const govAddr = await oTokenVault.governor(); + const govSigner = await impersonateAndFund(govAddr); + + // Mock Morpho V2 credit vault (asset == oToken) wired to a Morpho V1 liquidity adapter. + const creditVault = await ( + await ethers.getContractFactory("MockMorphoV2CreditVault") + ) + .connect(josh) + .deploy(oToken.address); + const creditVaultV1Leg = await ( + await ethers.getContractFactory("MockMorphoV1Vault") + ) + .connect(josh) + .deploy(oToken.address); + const creditVaultAdapter = await ( + await ethers.getContractFactory("MockMorphoV1VaultLiquidityAdapter") + ) + .connect(josh) + .deploy(); + await creditVaultAdapter + .connect(josh) + .setMockMorphoVault(creditVaultV1Leg.address); + await creditVault + .connect(josh) + .setLiquidityAdapter(creditVaultAdapter.address); + + // Deploy the strategy behind a proxy, governed by the OToken vault's governor. + const proxy = await ( + await ethers.getContractFactory("OUSDCreditMarketAMOStrategyProxy") + ) + .connect(josh) + .deploy(); + const impl = await ( + await ethers.getContractFactory("CreditMarketAMOStrategy") + ) + .connect(josh) + .deploy( + [creditVault.address, oTokenVault.address], + oToken.address, + hardAsset.address + ); + const initData = impl.interface.encodeFunctionData("initialize()", []); + const initializeProxy = + proxy.connect(josh)["initialize(address,address,bytes)"]; + await initializeProxy(impl.address, govAddr, initData); + const creditAMOStrategy = await ethers.getContractAt( + "CreditMarketAMOStrategy", + proxy.address + ); + + // Register the strategy with the OToken vault and turn on minting. + await oTokenVault + .connect(govSigner) + .approveStrategy(creditAMOStrategy.address); + await oTokenVault + .connect(govSigner) + .addStrategyToMintWhitelist(creditAMOStrategy.address); + await creditAMOStrategy.connect(govSigner).safeApproveAllTokens(); + await creditAMOStrategy + .connect(govSigner) + .setMintCap(parseUnits("1000000", 18)); + + // Ensure capital operations are enabled so mintForStrategy works. + if (await oTokenVault.capitalPaused()) { + await oTokenVault.connect(govSigner).unpauseCapital(); + } + + const oTokenVaultSigner = await impersonateAndFund(oTokenVault.address); + + return { + ...fixture, + governor: govSigner, + oTokenSymbol, + oToken, + hardAsset, + oTokenVault, + oTokenVaultSigner, + creditVault, + creditVaultV1Leg, + creditVaultAdapter, + creditAMOStrategy, + }; +} + mocha.after(async () => { if (snapshotId) { await nodeRevert(snapshotId); @@ -1769,6 +1875,7 @@ mocha.after(async () => { module.exports = { createFixtureLoader, + creditMarketAMOFixture, simpleOETHFixture, loadDefaultFixture, loadSimpleOETHFixture, diff --git a/contracts/test/strategies/credit-market-amo.js b/contracts/test/strategies/credit-market-amo.js new file mode 100644 index 0000000000..280c97e0da --- /dev/null +++ b/contracts/test/strategies/credit-market-amo.js @@ -0,0 +1,280 @@ +const { expect } = require("chai"); +const { parseUnits } = require("ethers").utils; + +const { createFixtureLoader, creditMarketAMOFixture } = require("../_fixture"); +const { impersonateAndFund } = require("../../utils/signers"); + +// OToken amounts are always 18 decimals (OUSD and OETH both use 18). +const oUnits = (n) => parseUnits(n, 18); + +describe("Unit test: Credit Market AMO Strategy", function () { + const configs = [ + { oTokenSymbol: "OUSD", hardAssetDecimals: 6 }, + { oTokenSymbol: "OETH", hardAssetDecimals: 18 }, + ]; + + for (const cfg of configs) { + describe(`${cfg.oTokenSymbol} config`, function () { + const loadFixture = createFixtureLoader(creditMarketAMOFixture, { + oTokenSymbol: cfg.oTokenSymbol, + }); + // checkBalance is denominated in the hard asset. + const hardUnits = (n) => parseUnits(n, cfg.hardAssetDecimals); + + let fixture; + let strategy, creditVault, oToken, hardAsset, oTokenVault; + let governor, vaultSigner, strategist; + + beforeEach(async () => { + fixture = await loadFixture(); + strategy = fixture.creditAMOStrategy; + creditVault = fixture.creditVault; + oToken = fixture.oToken; + hardAsset = fixture.hardAsset; + oTokenVault = fixture.oTokenVault; + governor = fixture.governor; + vaultSigner = fixture.oTokenVaultSigner; + strategist = await impersonateAndFund( + await oTokenVault.strategistAddr() + ); + }); + + describe("config", () => { + it("sets immutables and initial state", async () => { + expect(await strategy.oToken()).to.equal(oToken.address); + expect(await strategy.hardAsset()).to.equal(hardAsset.address); + expect(await strategy.creditVault()).to.equal(creditVault.address); + expect(await strategy.platformAddress()).to.equal( + creditVault.address + ); + expect(await strategy.vaultAddress()).to.equal(oTokenVault.address); + expect(await strategy.oTokenDecimals()).to.equal(18); + expect(await strategy.hardAssetDecimals()).to.equal( + cfg.hardAssetDecimals + ); + expect(await strategy.supportsAsset(hardAsset.address)).to.equal( + true + ); + expect(await strategy.supportsAsset(oToken.address)).to.equal(false); + expect(await strategy.netMinted()).to.equal(0); + expect(await strategy.mintCap()).to.equal(oUnits("1000000")); + }); + }); + + describe("mintAndSupply", () => { + it("mints, supplies, and reports balance 1:1 (neutral)", async () => { + const supplyBefore = await oToken.totalSupply(); + + const tx = await strategy + .connect(governor) + .mintAndSupply(oUnits("100")); + await expect(tx).to.emit(strategy, "Supplied"); + + expect(await oToken.totalSupply()).to.approxEqualTolerance( + supplyBefore.add(oUnits("100")), + 0.01 + ); + expect(await strategy.netMinted()).to.equal(oUnits("100")); + expect(await strategy.positionValue()).to.equal(oUnits("100")); + expect(await strategy.accruedYield()).to.equal(0); + expect(await strategy.maxWithdrawable()).to.equal(oUnits("100")); + expect(await strategy.checkBalance(hardAsset.address)).to.equal( + hardUnits("100") + ); + }); + + it("enforces the mint cap", async () => { + await strategy.connect(governor).setMintCap(oUnits("100")); + await strategy.connect(governor).mintAndSupply(oUnits("100")); + await expect( + strategy.connect(governor).mintAndSupply(oUnits("1")) + ).to.be.revertedWith("Mint cap exceeded"); + }); + + it("reverts on zero amount", async () => { + await expect( + strategy.connect(governor).mintAndSupply(0) + ).to.be.revertedWith("Must mint something"); + }); + + it("is callable by the strategist", async () => { + await strategy.connect(strategist).mintAndSupply(oUnits("10")); + expect(await strategy.netMinted()).to.equal(oUnits("10")); + }); + + it("is not callable by others", async () => { + await expect( + strategy.connect(fixture.josh).mintAndSupply(oUnits("10")) + ).to.be.revertedWith("Caller is not the Strategist or Governor"); + }); + }); + + describe("interest accrual", () => { + it("lifts checkBalance and accruedYield via the position value", async () => { + await strategy.connect(governor).mintAndSupply(oUnits("100")); + await creditVault.simulateInterest(oUnits("1")); + + expect(await strategy.positionValue()).to.equal(oUnits("101")); + expect(await strategy.accruedYield()).to.equal(oUnits("1")); + expect(await strategy.netMinted()).to.equal(oUnits("100")); + expect(await strategy.checkBalance(hardAsset.address)).to.equal( + hardUnits("101") + ); + // Accrued interest is a claim, not yet liquid. + expect(await strategy.maxWithdrawable()).to.equal(oUnits("100")); + }); + }); + + describe("redeemAndBurn", () => { + it("burns liquid OToken and stays neutral", async () => { + await strategy.connect(governor).mintAndSupply(oUnits("100")); + const supplyBefore = await oToken.totalSupply(); + + const tx = await strategy + .connect(governor) + .redeemAndBurn(oUnits("40")); + await expect(tx).to.emit(strategy, "Redeemed"); + + expect(await oToken.totalSupply()).to.approxEqualTolerance( + supplyBefore.sub(oUnits("40")), + 0.01 + ); + expect(await strategy.netMinted()).to.equal(oUnits("60")); + expect(await strategy.positionValue()).to.equal(oUnits("60")); + expect(await strategy.checkBalance(hardAsset.address)).to.equal( + hardUnits("60") + ); + }); + + it("is capped at the liquid amount", async () => { + await strategy.connect(governor).mintAndSupply(oUnits("100")); + // 70 OToken borrowed/drawn out -> only 30 liquid, position value unchanged. + await creditVault.simulateBorrow(oUnits("70"), fixture.josh.address); + expect(await strategy.maxWithdrawable()).to.equal(oUnits("30")); + expect(await strategy.positionValue()).to.equal(oUnits("100")); + + const withdrawn = await strategy + .connect(governor) + .callStatic.redeemAndBurn(oUnits("100")); + expect(withdrawn).to.equal(oUnits("30")); + + await strategy.connect(governor).redeemAndBurn(oUnits("100")); + expect(await strategy.netMinted()).to.equal(oUnits("70")); + expect(await strategy.positionValue()).to.equal(oUnits("70")); + expect(await strategy.maxWithdrawable()).to.equal(0); + }); + + it("reverts when nothing is liquid", async () => { + await expect( + strategy.connect(governor).redeemAndBurn(oUnits("100")) + ).to.be.revertedWith("Nothing to withdraw"); + }); + + it("draws interest down before principal (yield-first)", async () => { + await strategy.connect(governor).mintAndSupply(oUnits("100")); + // Position grows to 130 (30 of accrued interest); liquidity stays at 100. + await creditVault.simulateInterest(oUnits("30")); + + // Burn 50 of the liquid principal. Yield-first accounting draws the 30 of + // interest down first, so netMinted falls by only 20 (50 - 30) to 80. + await strategy.connect(governor).redeemAndBurn(oUnits("50")); + + expect(await strategy.positionValue()).to.equal(oUnits("80")); + expect(await strategy.netMinted()).to.equal(oUnits("80")); + expect(await strategy.accruedYield()).to.equal(0); + }); + }); + + describe("withdrawAll", () => { + it("withdraws only the liquid portion and leaves the rest", async () => { + await strategy.connect(governor).mintAndSupply(oUnits("100")); + // 60 borrowed out -> 40 liquid. withdrawAll burns the liquid 40, + // leaving the 60 that is still lent out in the position. + await creditVault.simulateBorrow(oUnits("60"), fixture.josh.address); + + await strategy.connect(vaultSigner).withdrawAll(); + + expect(await strategy.netMinted()).to.equal(oUnits("60")); + expect(await strategy.positionValue()).to.equal(oUnits("60")); + expect(await strategy.maxWithdrawable()).to.equal(0); + }); + + it("is a no-op when nothing is liquid", async () => { + await strategy.connect(governor).mintAndSupply(oUnits("100")); + await creditVault.simulateBorrow(oUnits("100"), fixture.josh.address); + + // Does not revert even though there is nothing to withdraw. + await strategy.connect(governor).withdrawAll(); + expect(await strategy.netMinted()).to.equal(oUnits("100")); + expect(await strategy.positionValue()).to.equal(oUnits("100")); + }); + + it("is only callable by vault or governor", async () => { + await expect( + strategy.connect(fixture.josh).withdrawAll() + ).to.be.revertedWith("Caller is not the Vault or Governor"); + await expect( + strategy.connect(strategist).withdrawAll() + ).to.be.revertedWith("Caller is not the Vault or Governor"); + }); + }); + + describe("admin and disabled functions", () => { + it("setMintCap is callable by governor or strategist and emits", async () => { + const tx = await strategy.connect(governor).setMintCap(oUnits("5")); + await expect(tx) + .to.emit(strategy, "MintCapUpdated") + .withArgs(oUnits("1000000"), oUnits("5")); + + // Strategist can also move the cap operationally. + await strategy.connect(strategist).setMintCap(oUnits("7")); + expect(await strategy.mintCap()).to.equal(oUnits("7")); + + // Others cannot. + await expect( + strategy.connect(fixture.josh).setMintCap(oUnits("5")) + ).to.be.revertedWith("Caller is not the Strategist or Governor"); + }); + + it("reverts disabled entry and exit", async () => { + await expect( + strategy + .connect(fixture.josh) + .deposit(hardAsset.address, oUnits("1")) + ).to.be.revertedWith("unsupported function"); + await expect( + strategy.connect(fixture.josh).depositAll() + ).to.be.revertedWith("unsupported function"); + await expect( + strategy + .connect(fixture.josh) + .withdraw(oTokenVault.address, hardAsset.address, oUnits("1")) + ).to.be.revertedWith("unsupported function"); + }); + + it("reverts pToken management", async () => { + await expect( + strategy + .connect(governor) + .setPTokenAddress(oToken.address, creditVault.address) + ).to.be.revertedWith("unsupported function"); + await expect( + strategy.connect(governor).removePToken(0) + ).to.be.revertedWith("unsupported function"); + }); + + it("checkBalance reverts for an unsupported asset", async () => { + await expect( + strategy.checkBalance(oToken.address) + ).to.be.revertedWith("Unsupported asset"); + }); + + it("safeApproveAllTokens is governor-only", async () => { + await expect( + strategy.connect(fixture.josh).safeApproveAllTokens() + ).to.be.revertedWith("Caller is not the Governor"); + }); + }); + }); + } +}); diff --git a/contracts/test/strategies/credit-market-amo.mainnet.fork-test.js b/contracts/test/strategies/credit-market-amo.mainnet.fork-test.js new file mode 100644 index 0000000000..afec6d19a4 --- /dev/null +++ b/contracts/test/strategies/credit-market-amo.mainnet.fork-test.js @@ -0,0 +1,125 @@ +const { expect } = require("chai"); +const { parseUnits } = require("ethers/lib/utils"); + +const { createFixtureLoader, creditMarketAMOFixture } = require("../_fixture"); +const { isCI } = require("../helpers"); + +const log = require("../../utils/logger")("test:fork:credit-market-amo"); + +// OToken amounts are 18 decimals; OUSD's hard asset (USDC) is 6. +const oUnits = (n) => parseUnits(n, 18); +const usdcUnits = (n) => parseUnits(n, 6); + +describe("ForkTest: Credit Market AMO Strategy (OUSD)", function () { + this.timeout(0); + this.retries(isCI ? 3 : 0); + + const loadFixture = createFixtureLoader(creditMarketAMOFixture, { + oTokenSymbol: "OUSD", + }); + let fixture; + beforeEach(async () => { + fixture = await loadFixture(); + }); + + it("Should have constants and immutables set", async () => { + const { creditAMOStrategy, creditVault, oTokenVault, oToken, hardAsset } = + fixture; + + expect(await creditAMOStrategy.oToken()).to.equal(oToken.address); + expect(await creditAMOStrategy.hardAsset()).to.equal(hardAsset.address); + expect(await creditAMOStrategy.creditVault()).to.equal(creditVault.address); + expect(await creditAMOStrategy.platformAddress()).to.equal( + creditVault.address + ); + expect(await creditAMOStrategy.vaultAddress()).to.equal( + oTokenVault.address + ); + expect(await creditAMOStrategy.oTokenDecimals()).to.equal(18); + expect(await creditAMOStrategy.hardAssetDecimals()).to.equal(6); + expect(await creditAMOStrategy.supportsAsset(hardAsset.address)).to.equal( + true + ); + }); + + it("Should mint, supply, accrue and burn against the live OUSD Vault", async () => { + const { creditAMOStrategy, creditVault, oToken, hardAsset, oTokenVault } = + fixture; + const { governor } = fixture; + + const supplyBefore = await oToken.totalSupply(); + const vaultValueBefore = await oTokenVault.totalValue(); + + // Mint OToken and supply it to the credit vault. + await creditAMOStrategy.connect(governor).mintAndSupply(oUnits("100000")); + + expect(await creditAMOStrategy.netMinted()).to.equal(oUnits("100000")); + expect(await creditAMOStrategy.checkBalance(hardAsset.address)).to.equal( + usdcUnits("100000") + ); + + // Minting raises OToken supply and the position backs it 1:1 -> neutral. + expect(await oToken.totalSupply()).to.approxEqualTolerance( + supplyBefore.add(oUnits("100000")), + 0.01 + ); + expect(await oTokenVault.totalValue()).to.approxEqualTolerance( + vaultValueBefore.add(oUnits("100000")), + 0.1 + ); + + // Interest accrues on the position and lifts checkBalance (yield to holders). + await creditVault.simulateInterest(oUnits("500")); + expect(await creditAMOStrategy.accruedYield()).to.equal(oUnits("500")); + expect(await creditAMOStrategy.checkBalance(hardAsset.address)).to.equal( + usdcUnits("100500") + ); + + // Burn the 100k of liquid OToken. Yield-first accounting draws the 500 of accrued + // interest down first, so netMinted settles on the 500 residual position value. + await creditAMOStrategy.connect(governor).redeemAndBurn(oUnits("100000")); + + expect(await creditAMOStrategy.netMinted()).to.equal(oUnits("500")); + expect(await creditAMOStrategy.positionValue()).to.equal(oUnits("500")); + expect(await creditAMOStrategy.maxWithdrawable()).to.equal(0); + expect(await creditAMOStrategy.checkBalance(hardAsset.address)).to.equal( + usdcUnits("500") + ); + + // Net: OToken supply back to ~start, vault value up by the accrued ~500 of yield. + expect(await oToken.totalSupply()).to.approxEqualTolerance( + supplyBefore, + 0.01 + ); + expect(await oTokenVault.totalValue()).to.approxEqualTolerance( + vaultValueBefore.add(oUnits("500")), + 0.1 + ); + log("Completed mint -> accrue -> burn lifecycle on the live OUSD Vault"); + }); + + it("Should withdraw only the liquid portion when partially lent out", async () => { + const { creditAMOStrategy, creditVault, oTokenVault, josh } = fixture; + const { governor } = fixture; + + await creditAMOStrategy.connect(governor).mintAndSupply(oUnits("100000")); + + // 60k borrowed/drawn out -> 40k liquid, position value unchanged. + await creditVault.simulateBorrow(oUnits("60000"), josh.address); + expect(await creditAMOStrategy.maxWithdrawable()).to.equal(oUnits("40000")); + + const vaultSigner = fixture.oTokenVaultSigner; + await creditAMOStrategy.connect(vaultSigner).withdrawAll(); + + // Burned the liquid 40k; the 60k still lent out remains in the position. + expect(await creditAMOStrategy.netMinted()).to.equal(oUnits("60000")); + expect(await creditAMOStrategy.positionValue()).to.equal(oUnits("60000")); + expect(await creditAMOStrategy.maxWithdrawable()).to.equal(0); + + // Governor can still remove the rest once it frees up; no revert when dry. + await creditAMOStrategy.connect(governor).withdrawAll(); + expect(await creditAMOStrategy.netMinted()).to.equal(oUnits("60000")); + + expect(await oTokenVault.totalValue()).to.be.gt(0); + }); +}); diff --git a/contracts/utils/addresses.js b/contracts/utils/addresses.js index e71805ad57..e3569a84a8 100644 --- a/contracts/utils/addresses.js +++ b/contracts/utils/addresses.js @@ -228,6 +228,11 @@ addresses.mainnet.MorphoOUSDv2Adapter = addresses.mainnet.MorphoOUSDv2Vault = "0xFB154c729A16802c4ad1E8f7FF539a8b9f49c960"; +// Gated Morpho Vault V2 credit vault for the OUSD Credit Market AMO. +// asset() == OUSD, deposit-gated to OUSDCreditMarketAMOStrategyProxy only. +// TODO: set once the gated credit vault is deployed, then enable deploy 202. +addresses.mainnet.OUSDCreditMarketVault = addresses.zero; + // Morpho Blue singleton (same address on mainnet and Base) addresses.mainnet.MorphoBlue = "0xBBBBBbbBBb9cC5e90e3b3Af64bdAF62C37EEFFCb"; // Morpho Adaptive Curve IRM