Skip to content
Closed
Show file tree
Hide file tree
Changes from 2 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
71 changes: 71 additions & 0 deletions script/deploy/mainnet/044_DeployUSDCMorphoMarketScript.s.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
// SPDX-License-Identifier: MIT
pragma solidity 0.8.23;

// Contracts
import {Proxy} from "contracts/Proxy.sol";
import {Mainnet} from "contracts/utils/Addresses.sol";
import {MultiAssetARM} from "contracts/MultiAssetARM.sol";
import {MorphoVaultV2Market} from "contracts/markets/MorphoVaultV2Market.sol";
import {Abstract4626MarketWrapper} from "contracts/markets/Abstract4626MarketWrapper.sol";

// Deployment
import {AbstractDeployScript} from "script/deploy/helpers/AbstractDeployScript.s.sol";

/// @title Deploy the USDC ARM Morpho market
/// @notice Deploys a Morpho Vault V2 wrapper for the USDC ARM and registers it as a supported
/// market. The mainnet 5/8 multisig owns the wrapper, while the 2/8 multisig harvests its
/// rewards. The market is not activated automatically.
/// @dev The 5/8 registration action is simulated in _fork(); on mainnet it is executed separately
/// by the multisig after the proxy and implementation have been deployed. No governance
/// proposal is required because the 5/8 multisig directly owns the USDC ARM.
///
/// Registration and activation are deliberately separate. Before activation, operators must
/// verify that the Vault V2 gates permit the wrapper and ARM, and that every cap used by the
/// liquidity adapter has enough headroom for the ARM's full one-transaction allocation. Vault
/// V2 automatically forwards deposits to its liquidity adapter, so an otherwise valid ARM
/// allocation can revert when an absolute or relative cap is reached.
///
/// Adapter safety is an ongoing operational assumption. The target currently uses a direct,
/// loss-aware MorphoMarketV1AdapterV2, but its curator can add adapters after deployment. While
/// this ARM market is active, operators must monitor adapter changes and deactivate it before
/// any MorphoVaultV1Adapter over MetaMorpho V1.1 becomes effective, because its lostAssets
/// accounting can keep bad debt in the reported Vault V2 share price.
///
/// Operators must also verify downstream liquidity. MorphoVaultV2Market reports its full
/// economic position from maxWithdraw/maxRedeem because Vault V2 returns zero, but those
/// wrapper values do not guarantee that an immediate withdrawal will succeed.
contract $044_DeployUSDCMorphoMarketScript is AbstractDeployScript("044_DeployUSDCMorphoMarketScript") {
function _execute() internal override {
address usdcARM = resolver.resolve("USDC_ARM");

// 1. Deploy the Morpho Vault V2 market proxy.
Proxy morphoMarketProxy = new Proxy();
_recordDeployment("MORPHO_MARKET_USDC_ARM", address(morphoMarketProxy));

// 2. Deploy the implementation for the USDC ARM and Wintermute USDC Prime vault.
MorphoVaultV2Market morphoMarketImpl =
new MorphoVaultV2Market(usdcARM, Mainnet.MORPHO_WINTERMUTE_USDC_PRIME_VAULT);
_recordDeployment("MORPHO_MARKET_USDC_ARM_IMPL", address(morphoMarketImpl));

// 3. Initialize the wrapper and hand ownership to the USDC ARM's 5/8 multisig.
// The deployment does not activate the market or deposit USDC into Vault V2.
bytes memory data = abi.encodeWithSelector(
Abstract4626MarketWrapper.initialize.selector, Mainnet.MULTISIG_2_OF_8, Mainnet.MERKLE_DISTRIBUTOR
);
morphoMarketProxy.initialize(address(morphoMarketImpl), Mainnet.MULTISIG_5_OF_8, data);
}

function _fork() internal override {
MultiAssetARM usdcARM = MultiAssetARM(payable(resolver.resolve("USDC_ARM")));
address morphoMarket = resolver.resolve("MORPHO_MARKET_USDC_ARM");

// Idempotent: the deployment runner can replay the pending multisig action on forks.
if (usdcARM.supportedMarkets(morphoMarket)) return;

address[] memory markets = new address[](1);
markets[0] = morphoMarket;

vm.prank(Mainnet.MULTISIG_5_OF_8);
usdcARM.addMarkets(markets);
}
}
7 changes: 5 additions & 2 deletions src/contracts/markets/Abstract4626MarketWrapper.sol
Original file line number Diff line number Diff line change
Expand Up @@ -76,9 +76,11 @@ contract Abstract4626MarketWrapper is Initializable, Ownable {

/// @notice Get the max amount of asset tokens that can be withdrawn from the lending market
/// from the lending market shares owned by this contract.
/// @dev Virtual because some ERC-4626 markets expose non-standard max functions and require an
/// integration-specific interpretation; overrides must document any loss of revert-free guarantees.
/// @param owner The owner account has to be the address of the ARM contract.
/// @return maxAssets The max amount of asset tokens that can be withdrawn.
function maxWithdraw(address owner) external view returns (uint256 maxAssets) {
function maxWithdraw(address owner) external view virtual returns (uint256 maxAssets) {
if (owner != arm) return 0;

maxAssets = IERC4626(market).maxWithdraw(address(this));
Expand Down Expand Up @@ -112,9 +114,10 @@ contract Abstract4626MarketWrapper is Initializable, Ownable {
/// from the lending market shares owned by this contract.
/// @dev This can return a smaller amount than balanceOf() if there is not enough liquidity
/// in the lending market.
/// Implementations overriding non-standard max behavior must document any loss of revert-free guarantees.
/// @param owner The owner account has to be the address of the ARM contract.
/// @return maxShares The max amount of lending market shares in this contract that can be burnt.
function maxRedeem(address owner) external view returns (uint256 maxShares) {
function maxRedeem(address owner) external view virtual returns (uint256 maxShares) {
if (owner != arm) return 0;

maxShares = IERC4626(market).maxRedeem(address(this));
Expand Down
64 changes: 64 additions & 0 deletions src/contracts/markets/MorphoVaultV2Market.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
// SPDX-License-Identifier: BUSL-1.1
pragma solidity ^0.8.23;

import {IERC4626} from "@openzeppelin/contracts/interfaces/IERC4626.sol";
import {MorphoMarket} from "./MorphoMarket.sol";

/**
* @title Morpho Vault V2 lending market wrapper.
* @notice Adapts a Morpho Vault V2 position for an ARM.
* @dev Morpho Vault V2 deliberately returns zero from all ERC-4626 max functions because gates,
* caps, and downstream liquidity make a useful revert-free bound impossible. This wrapper
* overrides only maxWithdraw and maxRedeem because the ARM uses them to account for claimable
* liquidity and to pull funds back from its active market.
*
* The returned values are the full economic position, not guaranteed currently withdrawable
* amounts. They can therefore be greater than the vault's downstream liquidity, and a later
* withdraw or redeem can revert. The wrapper does not bypass Vault V2 gates, allocation caps,
* pauses, or liquidity constraints.
*
* This wrapper assumes the Vault V2's adapters report losses through realAssets(), allowing
* Vault V2 totalAssets() and convertToAssets() to decrease. It must not be used for a Vault V2
* whose position is valued through a MorphoVaultV1Adapter over a MetaMorpho V1.1 vault:
* MetaMorpho V1.1 includes lostAssets in its share price, so such bad debt is not reported to
* Vault V2. The intended integration is a Vault V2 using direct, loss-aware adapters such as
* MorphoMarketV1AdapterV2. This is an ongoing integration assumption, not an immutable Vault
* V2 property: a curator may add or replace adapters after the wrapper is deployed. Operators
* must monitor adapter changes and deactivate this market before a MorphoVaultV1Adapter over
* MetaMorpho V1.1 becomes effective.
*
* Vault V2 snapshots its exchange rate on the first accrual in each transaction. A downstream
* loss occurring later in that same transaction is reflected from the next transaction, not by
* subsequent convertToAssets calls in the transaction that realized the loss.
* @author Origin Protocol Inc
*/
contract MorphoVaultV2Market is MorphoMarket {
/// @param _arm The address of the ARM contract.
/// @param _market The address of the Morpho Vault V2 market.
constructor(address _arm, address _market) MorphoMarket(_arm, _market) {}

/// @notice Get the economic asset value of this wrapper's full vault-share position.
/// @dev This intentionally does not promise that `maxAssets` can be withdrawn without reverting.
/// Morpho Vault V2 provides no non-zero revert-free liquidity bound, while the ARM requires a
/// non-zero value for claimable-liquidity and allocation accounting. `convertToAssets` includes
/// losses reported by the Vault V2 adapters, subject to Vault V2's per-transaction rate snapshot.
/// @param owner The owner account, which must be the linked ARM.
/// @return maxAssets The full economic position value in underlying assets, not a liquidity guarantee.
function maxWithdraw(address owner) external view override returns (uint256 maxAssets) {
if (owner != arm) return 0;

uint256 shares = IERC4626(market).balanceOf(address(this));
maxAssets = IERC4626(market).convertToAssets(shares);
}

/// @notice Get this wrapper's full vault-share position.
/// @dev This intentionally reports every share even when downstream liquidity, gates, or pauses
/// would prevent redeeming all shares in the current transaction. A later redeem can revert.
/// @param owner The owner account, which must be the linked ARM.
/// @return maxShares The full vault-share balance, not a redeemability guarantee.
function maxRedeem(address owner) external view override returns (uint256 maxShares) {
if (owner != arm) return 0;

maxShares = IERC4626(market).balanceOf(address(this));
}
}
1 change: 1 addition & 0 deletions src/contracts/utils/Addresses.sol
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ library Mainnet {

// Morpho Vaults
address public constant MORPHO_WETH_VAULT = 0x3Dfe70B05657949A5dB340754aD664810ac63b21;
address public constant MORPHO_WINTERMUTE_USDC_PRIME_VAULT = 0x5dc53a23AdC9f2Bed98de6F59F7F309a7c71FF2B;

// Origin
address public constant OETH_VAULT = 0x39254033945AA2E4809Cc2977E7087BEE48bd7Ab;
Expand Down
43 changes: 40 additions & 3 deletions test/smoke/PaxosARMSmokeTest.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {PaxosAssetAdapter} from "contracts/adapters/PaxosAssetAdapter.sol";
import {CapManager} from "contracts/CapManager.sol";
import {Proxy} from "contracts/Proxy.sol";
import {Mainnet} from "contracts/utils/Addresses.sol";
import {MorphoVaultV2Market} from "contracts/markets/MorphoVaultV2Market.sol";

contract Fork_PaxosARM_Smoke_Test is AbstractSmokeTest {
IERC20 usdc;
Expand Down Expand Up @@ -77,9 +78,6 @@ contract Fork_PaxosARM_Smoke_Test is AbstractSmokeTest {
assertEq(capManager.arm(), address(usdcARM), "cap manager arm");
assertEq(capManager.totalAssetsCap(), 1_000_000e18, "total assets cap");
assertEq(capManager.accountCapEnabled(), true, "account cap enabled");
assertEq(
capManager.liquidityProviderCaps(Mainnet.TREASURY_LP), 1_000_000e18 - 100_000e6, "liquidity provider cap"
);
assertEq(capManager.operator(), operator, "cap manager operator");
assertEq(capManager.owner(), Mainnet.MULTISIG_2_OF_8, "cap manager owner");
}
Expand All @@ -89,6 +87,45 @@ contract Fork_PaxosARM_Smoke_Test is AbstractSmokeTest {
_assertBaseAssetConfig(Mainnet.USDG, address(usdgAdapter), "USDG");
}

function test_morphoMarketConfig() external view {
MorphoVaultV2Market morphoMarket = MorphoVaultV2Market(resolver.resolve("MORPHO_MARKET_USDC_ARM"));

assertEq(morphoMarket.arm(), address(usdcARM), "market arm");
assertEq(morphoMarket.asset(), Mainnet.USDC, "market asset");
assertEq(morphoMarket.market(), Mainnet.MORPHO_WINTERMUTE_USDC_PRIME_VAULT, "configured Morpho Vault V2");
assertEq(morphoMarket.owner(), Mainnet.MULTISIG_5_OF_8, "market owner");
assertEq(morphoMarket.harvester(), Mainnet.MULTISIG_2_OF_8, "market harvester");
assertEq(address(morphoMarket.merkleDistributor()), Mainnet.MERKLE_DISTRIBUTOR, "Merkle distributor");
assertTrue(usdcARM.supportedMarkets(address(morphoMarket)), "market supported");
assertEq(usdcARM.activeMarket(), address(0), "market not activated");
}

function test_morphoMarketDepositWithdraw() external {
MorphoVaultV2Market morphoMarket = MorphoVaultV2Market(resolver.resolve("MORPHO_MARKET_USDC_ARM"));
// This amount fits the Vault V2 cap headroom and downstream liquidity at the smoke-test fork.
// A successful round trip does not make maxWithdraw a general liquidity guarantee.
uint256 depositAmount = 10_000e6;

deal(address(usdc), address(usdcARM), depositAmount);
vm.startPrank(address(usdcARM));
usdc.approve(address(morphoMarket), depositAmount);
uint256 shares = morphoMarket.deposit(depositAmount, address(usdcARM));
vm.stopPrank();

assertGt(shares, 0, "vault shares minted");
assertEq(morphoMarket.balanceOf(address(usdcARM)), shares, "wrapper share balance");
assertEq(morphoMarket.maxRedeem(address(usdcARM)), shares, "max redeem exposes position");
uint256 maxAssets = morphoMarket.maxWithdraw(address(usdcARM));
assertApproxEqAbs(maxAssets, depositAmount, 1, "max withdraw exposes position");

uint256 balanceBefore = usdc.balanceOf(address(usdcARM));
vm.prank(address(usdcARM));
uint256 burnedShares = morphoMarket.withdraw(maxAssets, address(usdcARM), address(usdcARM));

assertGt(burnedShares, 0, "vault shares burned");
assertEq(usdc.balanceOf(address(usdcARM)), balanceBefore + maxAssets, "USDC returned to ARM");
}

function _assertBaseAssetConfig(address baseAsset, address expectedAdapter, string memory label) internal view {
(,,,,,, bool peggedToLiquidityAsset, uint8 baseAssetDecimals, address adapter) =
usdcARM.baseAssetConfigs(baseAsset);
Expand Down
Loading