diff --git a/proposals/mips/mip-b53/b53.md b/proposals/mips/mip-b53/b53.md new file mode 100644 index 000000000..3944c9afd --- /dev/null +++ b/proposals/mips/mip-b53/b53.md @@ -0,0 +1,99 @@ +## MIP-B53: WETH Market Ownership Wrapper + +### **Summary** + +This proposal deploys and migrates the WETH market admin to a new owner wrapper +contract that can reliably receive native ETH. This solves a critical issue +preventing the protocol from reducing WETH market reserves due to execution +context limitations when TEMPORAL_GOVERNOR receives ETH during proposal +execution. + +### **Background** + +The WETH market on Base uses an unwrapper contract that converts WETH to native +ETH when reducing reserves. While TEMPORAL_GOVERNOR has a receive() function, +there have been persistent issues when receiving ETH during proposal execution, +causing reserve reduction operations to fail. + +This issue was previously identified in MIP-X37, which attempted to reduce 347 +WETH in reserves but had to be reverted due to these ETH receiving problems. The +WETH reserves were also removed from MIP-B52 for the same reason. + +### **Proposal** + +This proposal implements a solution by deploying an MWethOwnerWrapper contract +that: + +1. **Acts as the new admin for the WETH market** - The wrapper becomes the + admin, replacing TEMPORAL_GOVERNOR as the direct admin. + +2. **Automatically wraps received ETH to WETH** - When the wrapper receives + native ETH (from reserve reductions), it automatically wraps it to WETH, + avoiding any receive() execution context issues. + +3. **Delegates all admin functions** - The wrapper forwards all admin function + calls to the underlying WETH market, including: + + - `_reduceReserves(uint256)` - Reduce reserves + - `_setPendingAdmin(address)` - Admin transfers + - `_setReserveFactor(uint256)` - Economic parameters + - `_setInterestRateModel(address)` - Rate model updates + - `_setProtocolSeizeShare(uint256)` - Protocol parameters + - `_addReserves(uint256)` - Reserve additions + +4. **Remains controlled by TEMPORAL_GOVERNOR** - The wrapper itself is owned by + TEMPORAL_GOVERNOR, maintaining governance control. + +5. **Enables token extraction** - The wrapper includes a `withdrawToken()` + function to extract WETH after reserve reductions, allowing the protocol to + use the funds. + +### **Technical Implementation** + +The proposal executes the following actions: + +1. Deploy MWethOwnerWrapper implementation contract +2. Deploy TransparentUpgradeableProxy for the wrapper +3. Initialize the wrapper with: + - MOONWELL_WETH as the target mToken + - WETH token address + - TEMPORAL_GOVERNOR as the owner +4. Call `MOONWELL_WETH._setPendingAdmin(wrapperProxy)` to set the wrapper as + pending admin +5. Call `wrapperProxy._acceptAdmin()` to complete the admin transfer + +### **Rationale** + +This approach provides several benefits: + +1. **Reliability** - The automatic ETH-to-WETH wrapping in the receive() + function ensures no execution context issues when receiving funds from the + unwrapper. + +2. **Governance Control** - TEMPORAL_GOVERNOR remains in control by owning the + wrapper, maintaining the existing governance security model. + +3. **Flexibility** - The wrapper delegates all admin functions, so future + parameter updates and reserve management can be performed normally. + +4. **Upgradeability** - The wrapper uses the TransparentUpgradeableProxy + pattern, allowing future upgrades if needed (controlled by MRD_PROXY_ADMIN). + +5. **Enables Future Reserve Reductions** - Once deployed, the protocol can + successfully reduce WETH reserves and use them for bad debt remediation or + other purposes. + +### **Next Steps** + +After this proposal passes, a follow-up proposal can be created to: + +- Reduce WETH reserves (previously attempted in MIP-X37) +- Call `wrapperProxy.withdrawToken()` to extract the WETH +- Use the WETH for bad debt repayment or other protocol needs + +### **Voting Options** + +- **Yes** — Deploy the MWethOwnerWrapper and migrate WETH market admin to enable + reserve reductions. +- **No** — Reject the wrapper deployment; WETH reserves remain inaccessible. +- **Abstain** — No preference. diff --git a/proposals/mips/mip-b53/mip-b53.sol b/proposals/mips/mip-b53/mip-b53.sol new file mode 100644 index 000000000..9dc020943 --- /dev/null +++ b/proposals/mips/mip-b53/mip-b53.sol @@ -0,0 +1,127 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity 0.8.19; + +import "@forge-std/Test.sol"; + +import {TransparentUpgradeableProxy} from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; + +import {MTokenInterface} from "@protocol/MTokenInterfaces.sol"; +import {MWethOwnerWrapper} from "@protocol/MWethOwnerWrapper.sol"; + +import {HybridProposal, ActionType} from "@proposals/proposalTypes/HybridProposal.sol"; +import {AllChainAddresses as Addresses} from "@proposals/Addresses.sol"; +import {BASE_FORK_ID} from "@utils/ChainIds.sol"; +import {ProposalActions} from "@proposals/utils/ProposalActions.sol"; + +import {DeployMWethOwnerWrapper} from "@script/DeployMWethOwnerWrapper.s.sol"; + +/// @title MIP-B53: WETH Market Ownership Wrapper +/// @notice Proposal to deploy and migrate WETH market admin to a wrapper contract +/// that can reliably receive native ETH, enabling reserve reductions. +/// @dev This proposal: +/// 1. Deploys MWethOwnerWrapper implementation and proxy +/// 2. Transfers WETH market admin from TEMPORAL_GOVERNOR to the wrapper +/// 3. Wrapper is owned by TEMPORAL_GOVERNOR, maintaining governance control +/// 4. Enables future WETH reserve reductions via the wrapper +contract mipb53 is HybridProposal { + using ProposalActions for *; + + string public constant override name = "MIP-B53"; + + constructor() { + bytes memory proposalDescription = abi.encodePacked( + vm.readFile("./proposals/mips/mip-b53/b53.md") + ); + _setProposalDescription(proposalDescription); + } + + function primaryForkId() public pure override returns (uint256) { + return BASE_FORK_ID; + } + + function deploy(Addresses addresses, address) public override { + vm.selectFork(BASE_FORK_ID); + + // Deploy the MWethOwnerWrapper implementation and proxy + DeployMWethOwnerWrapper deployer = new DeployMWethOwnerWrapper(); + (TransparentUpgradeableProxy proxy, ) = deployer.deploy(addresses); + + console.log("MWethOwnerWrapper deployed at:", address(proxy)); + } + + function build(Addresses addresses) public override { + vm.selectFork(BASE_FORK_ID); + + address wrapperProxy = addresses.getAddress("MWETH_OWNER_WRAPPER"); + address moonwellWeth = addresses.getAddress("MOONWELL_WETH"); + + // Step 1: Set the wrapper as pending admin of the WETH market + _pushAction( + moonwellWeth, + abi.encodeWithSignature( + "_setPendingAdmin(address)", + payable(wrapperProxy) + ), + "Set MWethOwnerWrapper as pending admin of MOONWELL_WETH", + ActionType.Base + ); + + // Step 2: Accept admin role from the wrapper + _pushAction( + wrapperProxy, + abi.encodeWithSignature("_acceptAdmin()"), + "MWethOwnerWrapper accepts admin role for MOONWELL_WETH", + ActionType.Base + ); + } + + function validate(Addresses addresses, address) public override { + vm.selectFork(BASE_FORK_ID); + + address wrapperProxy = addresses.getAddress("MWETH_OWNER_WRAPPER"); + address moonwellWeth = addresses.getAddress("MOONWELL_WETH"); + address temporalGovernor = addresses.getAddress("TEMPORAL_GOVERNOR"); + address weth = addresses.getAddress("WETH"); + + // Validate wrapper configuration + MWethOwnerWrapper wrapper = MWethOwnerWrapper(payable(wrapperProxy)); + + assertEq( + wrapper.owner(), + temporalGovernor, + "Wrapper owner should be TEMPORAL_GOVERNOR" + ); + + assertEq( + address(wrapper.mToken()), + moonwellWeth, + "Wrapper mToken should be MOONWELL_WETH" + ); + + assertEq( + address(wrapper.weth()), + weth, + "Wrapper WETH address should be correct" + ); + + // Validate admin transfer + MTokenInterface mToken = MTokenInterface(moonwellWeth); + + assertEq( + mToken.admin(), + wrapperProxy, + "MOONWELL_WETH admin should be the wrapper" + ); + + assertEq( + mToken.pendingAdmin(), + address(0), + "MOONWELL_WETH pendingAdmin should be zero after accepting" + ); + + console.log("✓ MWethOwnerWrapper successfully deployed and configured"); + console.log("✓ MOONWELL_WETH admin transferred to wrapper"); + console.log("✓ Wrapper owned by TEMPORAL_GOVERNOR"); + console.log("✓ Future WETH reserve reductions now enabled"); + } +} diff --git a/proposals/mips/mips.json b/proposals/mips/mips.json index a14d3e762..dfb828839 100755 --- a/proposals/mips/mips.json +++ b/proposals/mips/mips.json @@ -1,4 +1,11 @@ [ + { + "envpath": "", + "governor": "MultichainGovernor", + "id": 0, + "path": "mip-b53.sol/mipb53.json", + "proposalType": "HybridProposal" + }, { "envpath": "", "governor": "MultichainGovernor", diff --git a/script/DeployMWethOwnerWrapper.s.sol b/script/DeployMWethOwnerWrapper.s.sol new file mode 100644 index 000000000..3e42f049b --- /dev/null +++ b/script/DeployMWethOwnerWrapper.s.sol @@ -0,0 +1,127 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity 0.8.19; + +import {Script} from "@forge-std/Script.sol"; + +import {TransparentUpgradeableProxy, ITransparentUpgradeableProxy} from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; +import {ProxyAdmin} from "@openzeppelin/contracts/proxy/transparent/ProxyAdmin.sol"; + +import {MWethOwnerWrapper} from "@protocol/MWethOwnerWrapper.sol"; +import {AllChainAddresses as Addresses} from "@proposals/Addresses.sol"; + +/* +How to use: +forge script script/DeployMWethOwnerWrapper.s.sol:DeployMWethOwnerWrapper \ + -vvvv \ + --rpc-url base \ + --broadcast + +Remove --broadcast if you want to try locally first, without paying any gas. +*/ + +contract DeployMWethOwnerWrapper is Script { + function deploy( + Addresses addresses + ) public returns (TransparentUpgradeableProxy, MWethOwnerWrapper) { + vm.startBroadcast(); + + // Deploy the implementation contract + MWethOwnerWrapper implementation = new MWethOwnerWrapper(); + + // Get required addresses + address proxyAdmin = addresses.getAddress("MRD_PROXY_ADMIN"); + address mToken = addresses.getAddress("MOONWELL_WETH"); + address weth = addresses.getAddress("WETH"); + address temporalGovernor = addresses.getAddress("TEMPORAL_GOVERNOR"); + + // Prepare initialization data + bytes memory initData = abi.encodeWithSelector( + MWethOwnerWrapper.initialize.selector, + mToken, + weth, + temporalGovernor + ); + + // Deploy the TransparentUpgradeableProxy + TransparentUpgradeableProxy proxy = new TransparentUpgradeableProxy( + address(implementation), + proxyAdmin, + initData + ); + + vm.stopBroadcast(); + + // Record deployed contracts + addresses.addAddress( + "MWETH_OWNER_WRAPPER_IMPL", + address(implementation) + ); + addresses.addAddress("MWETH_OWNER_WRAPPER", address(proxy)); + + return (proxy, implementation); + } + + function validate( + Addresses addresses, + TransparentUpgradeableProxy proxy, + MWethOwnerWrapper implementation + ) public view { + // Get proxy admin contract + ProxyAdmin proxyAdmin = ProxyAdmin( + addresses.getAddress("MRD_PROXY_ADMIN") + ); + + // Validate proxy configuration + address actualImplementation = proxyAdmin.getProxyImplementation( + ITransparentUpgradeableProxy(address(proxy)) + ); + address actualProxyAdmin = proxyAdmin.getProxyAdmin( + ITransparentUpgradeableProxy(address(proxy)) + ); + + require( + actualImplementation == address(implementation), + "DeployMWethOwnerWrapper: proxy implementation mismatch" + ); + + require( + actualProxyAdmin == address(proxyAdmin), + "DeployMWethOwnerWrapper: proxy admin mismatch" + ); + + // Validate wrapper configuration + MWethOwnerWrapper wrapperInstance = MWethOwnerWrapper( + payable(address(proxy)) + ); + + require( + wrapperInstance.owner() == + addresses.getAddress("TEMPORAL_GOVERNOR"), + "DeployMWethOwnerWrapper: owner mismatch" + ); + + require( + address(wrapperInstance.mToken()) == + addresses.getAddress("MOONWELL_WETH"), + "DeployMWethOwnerWrapper: mToken address mismatch" + ); + + require( + address(wrapperInstance.weth()) == addresses.getAddress("WETH"), + "DeployMWethOwnerWrapper: weth address mismatch" + ); + } + + function run() + public + returns (TransparentUpgradeableProxy, MWethOwnerWrapper) + { + Addresses addresses = new Addresses(); + ( + TransparentUpgradeableProxy proxy, + MWethOwnerWrapper implementation + ) = deploy(addresses); + validate(addresses, proxy, implementation); + return (proxy, implementation); + } +} diff --git a/src/MWethOwnerWrapper.sol b/src/MWethOwnerWrapper.sol new file mode 100644 index 000000000..23c37eb22 --- /dev/null +++ b/src/MWethOwnerWrapper.sol @@ -0,0 +1,232 @@ +// SPDX-License-Identifier: BSD-3-Clause +pragma solidity 0.8.19; + +import {WETH9} from "@protocol/router/IWETH.sol"; +import {MTokenInterface} from "@protocol/MTokenInterfaces.sol"; +import {MErc20Interface} from "@protocol/MTokenInterfaces.sol"; +import {InterestRateModel} from "@protocol/irm/InterestRateModel.sol"; +import {ComptrollerInterface} from "@protocol/ComptrollerInterface.sol"; +import "@openzeppelin-contracts-upgradeable/contracts/proxy/utils/Initializable.sol"; +import "@openzeppelin-contracts-upgradeable/contracts/access/OwnableUpgradeable.sol"; + +/** + * @title MWethOwnerWrapper + * @notice A wrapper contract that acts as the admin for the WETH market, + * enabling it to receive native ETH through the WETH unwrapping process. + * This solves the issue where TEMPORAL_GOVERNOR cannot reliably receive ETH + * during proposal execution when reducing WETH market reserves. + * + * @dev This contract: + * - Automatically wraps any received ETH into WETH + * - Delegates all admin functions to the underlying WETH market + * - Is owned by TEMPORAL_GOVERNOR for governance control + * - Allows extracting tokens (primarily WETH) after reserve reductions + */ +contract MWethOwnerWrapper is Initializable, OwnableUpgradeable { + /// @notice The WETH market this wrapper administers + MTokenInterface public mToken; + + /// @notice The WETH token contract + WETH9 public weth; + + /// @notice Emitted when ETH is received and wrapped to WETH + event EthWrapped(uint256 amount); + + /// @notice Emitted when tokens are withdrawn from the wrapper + event TokenWithdrawn( + address indexed token, + address indexed to, + uint256 amount + ); + + /// @custom:oz-upgrades-unsafe-allow constructor + constructor() { + _disableInitializers(); + } + + /** + * @notice Initialize the wrapper contract + * @param _mToken Address of the WETH market to administer + * @param _weth Address of the WETH token contract + * @param _owner Address that will own this contract (should be TEMPORAL_GOVERNOR) + */ + function initialize( + address _mToken, + address _weth, + address _owner + ) public initializer { + require( + _mToken != address(0), + "MWethOwnerWrapper: mToken cannot be zero address" + ); + require( + _weth != address(0), + "MWethOwnerWrapper: weth cannot be zero address" + ); + require( + _owner != address(0), + "MWethOwnerWrapper: owner cannot be zero address" + ); + + __Ownable_init(); + + mToken = MTokenInterface(_mToken); + weth = WETH9(_weth); + _transferOwnership(_owner); + } + + /** + * @notice Fallback function to receive ETH and automatically wrap it to WETH + * @dev This is critical for receiving ETH from the WETH unwrapper during reserve reductions + */ + receive() external payable { + if (msg.value > 0) { + weth.deposit{value: msg.value}(); + emit EthWrapped(msg.value); + } + } + + // ======================================== + // Admin Functions - Delegate to MToken + // ======================================== + + /** + * @notice Reduce reserves of the WETH market + * @dev The reduced reserves will be sent to this wrapper as ETH, then auto-wrapped to WETH + * @param reduceAmount The amount of reserves to reduce + * @return uint 0=success, otherwise a failure (see ErrorReporter.sol for details) + */ + function _reduceReserves( + uint256 reduceAmount + ) external onlyOwner returns (uint) { + return mToken._reduceReserves(reduceAmount); + } + + /** + * @notice Set pending admin of the WETH market + * @param newPendingAdmin The new pending admin address + * @return uint 0=success, otherwise a failure + */ + function _setPendingAdmin( + address payable newPendingAdmin + ) external onlyOwner returns (uint) { + return mToken._setPendingAdmin(newPendingAdmin); + } + + /** + * @notice Accept admin role for the WETH market + * @dev Call this after the market's current admin has called _setPendingAdmin + * @return uint 0=success, otherwise a failure + */ + function _acceptAdmin() external onlyOwner returns (uint) { + return mToken._acceptAdmin(); + } + + /** + * @notice Set the comptroller for the WETH market + * @param newComptroller The new comptroller address + * @return uint 0=success, otherwise a failure + */ + function _setComptroller( + ComptrollerInterface newComptroller + ) external onlyOwner returns (uint) { + return mToken._setComptroller(newComptroller); + } + + /** + * @notice Set the reserve factor for the WETH market + * @param newReserveFactorMantissa The new reserve factor (scaled by 1e18) + * @return uint 0=success, otherwise a failure + */ + function _setReserveFactor( + uint256 newReserveFactorMantissa + ) external onlyOwner returns (uint) { + return mToken._setReserveFactor(newReserveFactorMantissa); + } + + /** + * @notice Set the interest rate model for the WETH market + * @param newInterestRateModel The new interest rate model address + * @return uint 0=success, otherwise a failure + */ + function _setInterestRateModel( + InterestRateModel newInterestRateModel + ) external onlyOwner returns (uint) { + return mToken._setInterestRateModel(newInterestRateModel); + } + + /** + * @notice Set the protocol seize share for the WETH market + * @param newProtocolSeizeShareMantissa The new protocol seize share (scaled by 1e18) + * @return uint 0=success, otherwise a failure + */ + function _setProtocolSeizeShare( + uint256 newProtocolSeizeShareMantissa + ) external onlyOwner returns (uint) { + return mToken._setProtocolSeizeShare(newProtocolSeizeShareMantissa); + } + + /** + * @notice Add reserves to the WETH market + * @param addAmount The amount of reserves to add + * @return uint 0=success, otherwise a failure + */ + function _addReserves(uint256 addAmount) external onlyOwner returns (uint) { + // First approve the mToken to spend WETH from this wrapper + require( + weth.approve(address(mToken), addAmount), + "MWethOwnerWrapper: WETH approval failed" + ); + return MErc20Interface(address(mToken))._addReserves(addAmount); + } + + // ======================================== + // Token Management Functions + // ======================================== + + /** + * @notice Withdraw ERC20 tokens from this wrapper + * @dev Primarily used to extract WETH after reserve reductions + * @param token The token address to withdraw + * @param to The recipient address + * @param amount The amount to withdraw + */ + function withdrawToken( + address token, + address to, + uint256 amount + ) external onlyOwner { + require( + to != address(0), + "MWethOwnerWrapper: cannot withdraw to zero address" + ); + require( + amount > 0, + "MWethOwnerWrapper: amount must be greater than zero" + ); + + require( + WETH9(token).transfer(to, amount), + "MWethOwnerWrapper: token transfer failed" + ); + + emit TokenWithdrawn(token, to, amount); + } + + /** + * @notice Get the balance of a token held by this wrapper + * @param token The token address to query + * @return The balance of the token + */ + function getTokenBalance(address token) external view returns (uint256) { + return WETH9(token).balanceOf(address(this)); + } + + /** + * @notice Get the ETH balance held by this wrapper + * @return The ETH balance + */ + function getEthBalance() external view returns (uint256) { + return address(this).balance; + } +}