diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..b3651e298 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +- Always set the id in @proposals/mips/mips.json to 0 when creating new proposals \ No newline at end of file diff --git a/chains/10.json b/chains/10.json index 52a1d6b14..99f56aae0 100644 --- a/chains/10.json +++ b/chains/10.json @@ -541,7 +541,7 @@ }, { "addr": "0x0738483Add6ab8620B731aEc0121d1d3A70BD6EA", - "name": "CHAINLINK_wrsETH_COMPOSITE_ORACLE", + "name": "CHAINLINK_wrsETH_COMPOSITE_ORACLE_DEPRECATED", "isContract": true }, { @@ -708,5 +708,15 @@ "addr": "0x39Bd42ce85CcC2f8792f13B2726369cD3F946D7c", "isContract": true, "name": "ANTHIAS_MULTISIG" + }, + { + "addr": "0x73b8BE3b653c5896BC34fC87cEBC8AcF4Fb7A545", + "isContract": true, + "name": "CHAINLINK_wrsETH_ETH_EXCHANGE_RATE" + }, + { + "addr": "0x5fddda4866db63685018faa1bfc9bfce7072014c", + "isContract": true, + "name": "CHAINLINK_wrsETH_COMPOSITE_ORACLE" } ] diff --git a/chains/8453.json b/chains/8453.json index 1c3236651..f15551769 100644 --- a/chains/8453.json +++ b/chains/8453.json @@ -771,7 +771,7 @@ }, { "addr": "0x79C613B4f07080963C3B0CA58Eb2745dD4C744A5", - "name": "CHAINLINK_wrsETH_COMPOSITE_ORACLE", + "name": "CHAINLINK_wrsETH_COMPOSITE_ORACLE_DEPRECATED", "isContract": true }, { @@ -1223,5 +1223,15 @@ "addr": "0x74Cbb1E8B68dDD13B28684ECA202a351afD45EAa", "isContract": true, "name": "F-DEVGRANT" + }, + { + "addr": "0xe8dD07CCf5BC4922424140E44Eb970F5950725ef", + "isContract": true, + "name": "CHAINLINK_wrsETH_ETH_EXCHANGE_RATE" + }, + { + "addr": "0xccC994a46c0d81c934fd6c82d89f626aee336ade", + "isContract": true, + "name": "CHAINLINK_wrsETH_COMPOSITE_ORACLE" } ] diff --git a/proposals/mips/mip-reserve-automation/10.json b/proposals/mips/mip-reserve-automation/10.json index 23504b2a6..36deb7453 100644 --- a/proposals/mips/mip-reserve-automation/10.json +++ b/proposals/mips/mip-reserve-automation/10.json @@ -40,7 +40,7 @@ "market": "MOONWELL_weETH" }, { - "chainlinkFeed": "CHAINLINK_wrsETH_COMPOSITE_ORACLE", + "chainlinkFeed": "CHAINLINK_wrsETH_COMPOSITE_ORACLE_DEPRECATED", "market": "MOONWELL_wrsETH" }, { diff --git a/proposals/mips/mip-reserve-automation/8453.json b/proposals/mips/mip-reserve-automation/8453.json index 2252236f7..e5f75bb0b 100644 --- a/proposals/mips/mip-reserve-automation/8453.json +++ b/proposals/mips/mip-reserve-automation/8453.json @@ -44,7 +44,7 @@ "market": "MOONWELL_EURC" }, { - "chainlinkFeed": "CHAINLINK_wrsETH_COMPOSITE_ORACLE", + "chainlinkFeed": "CHAINLINK_wrsETH_COMPOSITE_ORACLE_DEPRECATED", "market": "MOONWELL_wrsETH" }, { diff --git a/proposals/mips/mip-x36/mip-x36.sol b/proposals/mips/mip-x36/mip-x36.sol new file mode 100644 index 000000000..a59605d11 --- /dev/null +++ b/proposals/mips/mip-x36/mip-x36.sol @@ -0,0 +1,313 @@ +//SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity 0.8.19; + +import "@forge-std/Test.sol"; + +import {MToken} from "@protocol/MToken.sol"; +import {Comptroller} from "@protocol/Comptroller.sol"; +import {MErc20} from "@protocol/MErc20.sol"; +import {MErc20Delegator} from "@protocol/MErc20Delegator.sol"; +import {IERC20} from "@openzeppelin-contracts/contracts/token/ERC20/IERC20.sol"; +import {ChainlinkOracle} from "@protocol/oracles/ChainlinkOracle.sol"; +import {ChainlinkCompositeOracle} from "@protocol/oracles/ChainlinkCompositeOracle.sol"; +import {AggregatorV3Interface} from "@protocol/oracles/AggregatorV3Interface.sol"; + +import {HybridProposal, ActionType} from "@proposals/proposalTypes/HybridProposal.sol"; +import {AllChainAddresses as Addresses} from "@proposals/Addresses.sol"; +import {MOONBEAM_FORK_ID, BASE_FORK_ID, OPTIMISM_FORK_ID} from "@utils/ChainIds.sol"; +import {ProposalActions} from "@proposals/utils/ProposalActions.sol"; +import {ChainIds} from "@utils/ChainIds.sol"; + +/// @title MIP-X36: Disable Mint/Borrow in wrsETH Markets and Transition to Exchange-Rate Oracle +/// @author Moonwell Contributors +/// @notice Proposal to disable new positions in wrsETH markets on Base and Optimism by: +/// 1. Pausing minting operations for wrsETH markets +/// 2. Pausing borrowing operations for wrsETH markets +/// 3. Deploying new ChainlinkCompositeOracle contracts using exchange rate feeds +/// 4. Updating oracle addresses for wrsETH markets to use exchange rate feeds +contract mipx36 is HybridProposal { + using ProposalActions for *; + using ChainIds for uint256; + + string public constant override name = "MIP-X36"; + + // Storage for deployed oracles + ChainlinkCompositeOracle public baseWrsethOracle; + ChainlinkCompositeOracle public optimismWrsethOracle; + + constructor() { + bytes memory proposalDescription = abi.encodePacked( + vm.readFile("./proposals/mips/mip-x36/x36.md") + ); + _setProposalDescription(proposalDescription); + } + + function run() public override { + primaryForkId().createForksAndSelect(); + + Addresses addresses = new Addresses(); + vm.makePersistent(address(addresses)); + + initProposal(addresses); + + (, address deployerAddress, ) = vm.readCallers(); + + if (DO_DEPLOY) deploy(addresses, deployerAddress); + if (DO_AFTER_DEPLOY) afterDeploy(addresses, deployerAddress); + + if (DO_BUILD) build(addresses); + if (DO_RUN) run(addresses, deployerAddress); + if (DO_TEARDOWN) teardown(addresses, deployerAddress); + if (DO_VALIDATE) { + validate(addresses, deployerAddress); + console.log("Validation completed for proposal ", this.name()); + } + if (DO_PRINT) { + printProposalActionSteps(); + + addresses.removeAllRestrictions(); + printCalldata(addresses); + + _printAddressesChanges(addresses); + } + } + + + function primaryForkId() public pure override returns (uint256) { + return BASE_FORK_ID; + } + + function deploy(Addresses addresses, address) public override { + // Deploy new ChainlinkCompositeOracle for Base wrsETH + vm.selectFork(BASE_FORK_ID); + + if(!addresses.isAddressSet("CHAINLINK_wrsETH_COMPOSITE_ORACLE")) { + vm.startBroadcast(); + + address baseEthUsdFeed = addresses.getAddress("CHAINLINK_ETH_USD"); + address baseWrsethEthExchangeRateFeed = addresses.getAddress("CHAINLINK_wrsETH_ETH_EXCHANGE_RATE"); + + baseWrsethOracle = new ChainlinkCompositeOracle( + baseEthUsdFeed, + baseWrsethEthExchangeRateFeed, + address(0) + ); + + vm.stopBroadcast(); + + addresses.addAddress("CHAINLINK_wrsETH_COMPOSITE_ORACLE", address(baseWrsethOracle)); + } else { + baseWrsethOracle = ChainlinkCompositeOracle(addresses.getAddress("CHAINLINK_wrsETH_COMPOSITE_ORACLE")); + } + + // Deploy new ChainlinkCompositeOracle for Optimism wrsETH + vm.selectFork(OPTIMISM_FORK_ID); + + if(!addresses.isAddressSet("CHAINLINK_wrsETH_COMPOSITE_ORACLE")) { + vm.startBroadcast(); + + address optimismEthUsdFeed = addresses.getAddress("CHAINLINK_ETH_USD"); + address optimismWrsethEthExchangeRateFeed = addresses.getAddress("CHAINLINK_wrsETH_ETH_EXCHANGE_RATE"); + + optimismWrsethOracle = new ChainlinkCompositeOracle( + optimismEthUsdFeed, + optimismWrsethEthExchangeRateFeed, + address(0) + ); + + vm.stopBroadcast(); + + addresses.addAddress("CHAINLINK_wrsETH_COMPOSITE_ORACLE", address(optimismWrsethOracle)); + } else { + optimismWrsethOracle = ChainlinkCompositeOracle(addresses.getAddress("CHAINLINK_wrsETH_COMPOSITE_ORACLE")); + } + } + + function build(Addresses addresses) public override { + // ============ BASE CHAIN ACTIONS ============ + vm.selectFork(BASE_FORK_ID); + + address baseComptroller = addresses.getAddress("UNITROLLER"); + address baseWrsethMToken = addresses.getAddress("MOONWELL_wrsETH"); + + // Pause minting on Base + _pushAction( + baseComptroller, + abi.encodeWithSignature( + "_setMintPaused(address,bool)", + baseWrsethMToken, + true + ), + "Pause minting for wrsETH on Base", + ActionType.Base + ); + + // Pause borrowing on Base + _pushAction( + baseComptroller, + abi.encodeWithSignature( + "_setBorrowPaused(address,bool)", + baseWrsethMToken, + true + ), + "Pause borrowing for wrsETH on Base", + ActionType.Base + ); + + // Update oracle price feed on Base + address baseChainlinkOracle = addresses.getAddress("CHAINLINK_ORACLE"); + _pushAction( + baseChainlinkOracle, + abi.encodeWithSignature( + "setFeed(string,address)", + "wrsETH", + address(baseWrsethOracle) + ), + "Update wrsETH oracle to exchange rate feed on Base", + ActionType.Base + ); + + // ============ OPTIMISM CHAIN ACTIONS ============ + vm.selectFork(OPTIMISM_FORK_ID); + + address optimismComptroller = addresses.getAddress("UNITROLLER"); + address optimismWrsethMToken = addresses.getAddress("MOONWELL_wrsETH"); + + // Pause minting on Optimism + _pushAction( + optimismComptroller, + abi.encodeWithSignature( + "_setMintPaused(address,bool)", + optimismWrsethMToken, + true + ), + "Pause minting for wrsETH on Optimism", + ActionType.Optimism + ); + + // Pause borrowing on Optimism + _pushAction( + optimismComptroller, + abi.encodeWithSignature( + "_setBorrowPaused(address,bool)", + optimismWrsethMToken, + true + ), + "Pause borrowing for wrsETH on Optimism", + ActionType.Optimism + ); + + // Update oracle price feed on Optimism + address optimismChainlinkOracle = addresses.getAddress("CHAINLINK_ORACLE"); + _pushAction( + optimismChainlinkOracle, + abi.encodeWithSignature( + "setFeed(string,address)", + "wrsETH", + address(optimismWrsethOracle) + ), + "Update wrsETH oracle to exchange rate feed on Optimism", + ActionType.Optimism + ); + } + + function teardown(Addresses addresses, address) public pure override {} + + function _testMintPaused( + address mToken, + address underlying + ) internal { + MErc20Delegator mTokenDelegator = MErc20Delegator(payable(mToken)); + + uint256 mintAmount = 1e18; + + deal(underlying, address(this), mintAmount); + + IERC20(underlying).approve(mToken, mintAmount); + + vm.expectRevert("mint is paused"); + mTokenDelegator.mint(mintAmount); + } + + function validate(Addresses addresses, address) public override { + // ============ VALIDATE BASE CHAIN ============ + vm.selectFork(BASE_FORK_ID); + + Comptroller baseComptroller = Comptroller(addresses.getAddress("UNITROLLER")); + address baseWrsethMToken = addresses.getAddress("MOONWELL_wrsETH"); + + // Validate minting is paused + assertTrue( + baseComptroller.mintGuardianPaused(baseWrsethMToken), + "Base wrsETH minting not paused" + ); + + // Validate borrowing is paused + assertTrue( + baseComptroller.borrowGuardianPaused(baseWrsethMToken), + "Base wrsETH borrowing not paused" + ); + + // Validate oracle is updated + ChainlinkOracle baseChainlinkOracle = ChainlinkOracle( + addresses.getAddress("CHAINLINK_ORACLE") + ); + AggregatorV3Interface baseFeed = baseChainlinkOracle.getFeed("wrsETH"); + assertEq( + address(baseFeed), + address(baseWrsethOracle), + "Base wrsETH oracle not updated" + ); + + // Validate price can be fetched + (, int256 basePrice, , , ) = baseFeed.latestRoundData(); + assertGt(uint256(basePrice), 0, "Base wrsETH price check failed"); + + // Test that minting is actually paused + address baseWrsethUnderlying = MErc20(baseWrsethMToken).underlying(); + _testMintPaused( + baseWrsethMToken, + baseWrsethUnderlying + ); + + // ============ VALIDATE OPTIMISM CHAIN ============ + vm.selectFork(OPTIMISM_FORK_ID); + + Comptroller optimismComptroller = Comptroller(addresses.getAddress("UNITROLLER")); + address optimismWrsethMToken = addresses.getAddress("MOONWELL_wrsETH"); + + // Validate minting is paused + assertTrue( + optimismComptroller.mintGuardianPaused(optimismWrsethMToken), + "Optimism wrsETH minting not paused" + ); + + // Validate borrowing is paused + assertTrue( + optimismComptroller.borrowGuardianPaused(optimismWrsethMToken), + "Optimism wrsETH borrowing not paused" + ); + + // Validate oracle is updated + ChainlinkOracle optimismChainlinkOracle = ChainlinkOracle( + addresses.getAddress("CHAINLINK_ORACLE") + ); + AggregatorV3Interface optimismFeed = optimismChainlinkOracle.getFeed("wrsETH"); + assertEq( + address(optimismFeed), + address(optimismWrsethOracle), + "Optimism wrsETH oracle not updated" + ); + + // Validate price can be fetched + (, int256 optimismPrice, , , ) = optimismFeed.latestRoundData(); + assertGt(uint256(optimismPrice), 0, "Optimism wrsETH price check failed"); + + // Test that minting is actually paused + address optimismWrsethUnderlying = MErc20(optimismWrsethMToken).underlying(); + _testMintPaused( + optimismWrsethMToken, + optimismWrsethUnderlying + ); + } +} diff --git a/proposals/mips/mip-x36/x36.md b/proposals/mips/mip-x36/x36.md new file mode 100644 index 000000000..44b7c7c08 --- /dev/null +++ b/proposals/mips/mip-x36/x36.md @@ -0,0 +1,59 @@ +## MIP-X36: Disable Mint/Borrow in wrsETH Markets and Transition to Exchange-Rate Oracle + +### **Summary** + +Following the oracle malfunction involving the wrsETH/ETH feed on November 4, 2025, this proposal seeks to formally disable minting and borrowing in the wrsETH markets on both Base and OP Mainnet and transition these markets to use an exchange-rate feed rather than a market price oracle. + +These steps reduce further risk exposure and set the foundation for a gradual, orderly deprecation of the wrsETH markets. + +### **Background** + +At approximately **5:44 AM UTC on November 4**, an oracle malfunction caused the **wrsETH/ETH feed** to report a faulty value, drastically overpricing wrsETH and enabling an attacker to borrow multiple assets using minimal collateral. + +The wrsETH/USD price is derived by multiplying the Chainlink ETH/USD oracle with the wrsETH/ETH oracle. The wrsETH/ETH oracle erroneously reported **1 wrsETH = 1,649,934.6 ETH**, valuing each token at roughly **$5.8 billion**. This led to approximately **$3.7 million** in bad debt across the Moonwell protocol. + +Immediately following the mispricing event: + +* Supply and borrow caps for wrsETH were set to effectively zero. + +* Borrow caps for all Core Markets on Base and Optimism were temporarily reduced to **0.1** to prevent additional over-borrowing. + +* All deposits and withdrawals remained open, allowing suppliers to withdraw funds where liquidity was available. + +### **Proposal** + +To maintain a risk-off stance and begin the safe wind-down of wrsETH exposure, this proposal will: + +#### 1. Disable minting and borrowing in wrsETH markets on Base and OP Mainnet. + +No new wrsETH can be supplied or borrowed. Supply and borrow caps will be set to a near-zero value (as setting caps to zero represents infinity at a contract level). Repayments and withdrawals will remain enabled for existing users. + +#### 2. Transition to exchange-rate feeds, replacing the existing oracle. + +This ensures accurate value representation based on wrsETH's exchange-rate mechanics rather than rely on market rate pricing. + +The exchange-rate feeds can be found here: + +**Base** +- [Chainlink Exchange-Rate Feed](https://data.chain.link/feeds/base/base/wrseth-eth-exchange-rate) +- [wrsETH Contract (BaseScan)](https://basescan.org/address/0xe8dD07CCf5BC4922424140E44Eb970F5950725ef) + +**OP Mainnet** +- [Chainlink Exchange-Rate Feed](https://data.chain.link/feeds/optimism/mainnet/wrseth-eth-exchange-rate) +- [wrsETH Contract (Optimism Etherscan)](https://optimistic.etherscan.io/address/0x73b8BE3b653c5896BC34fC87cEBC8AcF4Fb7A545) + +#### 3. **Prepare the markets for deprecation.** + +Once minting and borrowing are disabled and the oracle is transitioned, the community can proceed with a gradual deprecation of wrsETH markets on Base and OP Mainnet. Users will receive advance notice and be encouraged to repay outstanding loans and withdraw wrsETH to avoid liquidation risk during the wind-down process. + +### **Rationale** + +* Disabling mint and borrow eliminates the primary vectors for further exploitation. +* Transitioning to exchange-rate feeds ensures stable and accurate valuation until the market can be fully deprecated. +* The measured approach, risk-off first, gradual deprecation later, balances user protection with operational continuity. + +### **Voting Options** + +Yes: Disable mint/borrow for wrsETH on Base and OP Mainnet and transition to exchange-rate feeds +No: Maintain the status quo. +Abstain: No preference diff --git a/proposals/mips/mips.json b/proposals/mips/mips.json index e14330fbe..ddb3ba1b3 100755 --- a/proposals/mips/mips.json +++ b/proposals/mips/mips.json @@ -1,4 +1,11 @@ [ + { + "envpath": "", + "governor": "MultichainGovernor", + "id": 129, + "path": "mip-x36.sol/mipx36.json", + "proposalType": "HybridProposal" + }, { "envpath": "proposals/mips/mip-x34/x34.sh", "governor": "MultichainGovernor", diff --git a/test/integration/CrossChainPublishMessageIntegration.t.sol b/test/integration/CrossChainPublishMessageIntegration.t.sol index 8c429bd21..03bde03b7 100644 --- a/test/integration/CrossChainPublishMessageIntegration.t.sol +++ b/test/integration/CrossChainPublishMessageIntegration.t.sol @@ -1,3 +1,4 @@ + // SPDX-License-Identifier: GPL-3.0-or-later pragma solidity 0.8.19; @@ -13,6 +14,7 @@ import {String} from "@utils/String.sol"; import {HybridProposal, ActionType} from "@proposals/proposalTypes/HybridProposal.sol"; import {IArtemisGovernor as MoonwellArtemisGovernor} from "@protocol/interfaces/IArtemisGovernor.sol"; import {PostProposalCheck} from "@test/integration/PostProposalCheck.sol"; +import {ProposalAction} from "@proposals/proposalTypes/IProposal.sol"; /// @notice run this on a chainforked moonbeam node. /// then switch over to base network to generate the calldata, @@ -127,44 +129,101 @@ contract CrossChainPublishMessageTest is Test, PostProposalCheck { address(governor) ); + bytes memory temporalGovExecDataBase; if (proposal.getActionsByType(ActionType.Base).length != 0) { - bytes memory temporalGovExecDataBase = proposal - .getTemporalGovPayloadByChain( - addresses, - block.chainid.toBaseChainId() - ); - - /// expect emitting of events to Wormhole Core on Moonbeam if Base actions exist - vm.expectEmit(true, true, true, true, wormholeCore); - - emit LogMessagePublished( - address(governor), - nextSequence++, - 0, - temporalGovExecDataBase, - 200 + temporalGovExecDataBase = proposal.getTemporalGovPayloadByChain( + addresses, + block.chainid.toBaseChainId() ); } + bytes memory temporalGovExecDataOptimism; if (proposal.getActionsByType(ActionType.Optimism).length != 0) { - bytes memory temporalGovExecDataOptimism = proposal + temporalGovExecDataOptimism = proposal .getTemporalGovPayloadByChain( addresses, block.chainid.toOptimismChainId() ); - /// expect emitting of events to Wormhole Core on Moonbeam if Optimism actions exist - vm.expectEmit(true, true, true, true, wormholeCore); - - emit LogMessagePublished( - address(governor), - nextSequence, - 0, - temporalGovExecDataOptimism, - 200 - ); } + vm.recordLogs(); governor.execute(proposalId); + Vm.Log[] memory logs = vm.getRecordedLogs(); + + bytes32 sig = keccak256( + "LogMessagePublished(address,uint64,uint32,bytes,uint8)" + ); + if (temporalGovExecDataBase.length != 0) { + bool seenBase = false; + for (uint256 k = 0; k < logs.length; k++) { + if ( + logs[k].emitter == wormholeCore && + logs[k].topics.length > 0 && + logs[k].topics[0] == sig + ) { + ( + uint64 sequence, + uint32 nonce2, + bytes memory payload, + uint8 cl + ) = abi.decode( + logs[k].data, + (uint64, uint32, bytes, uint8) + ); + sequence; + nonce2; + cl; + + if ( + keccak256(payload) == + keccak256(temporalGovExecDataBase) + ) { + seenBase = true; + break; + } + } + } + assertTrue( + seenBase, + "Missing LogMessagePublished event on Base" + ); + } + + if (temporalGovExecDataOptimism.length != 0) { + bool seenOptimism = false; + for (uint256 k = 0; k < logs.length; k++) { + if ( + logs[k].emitter == wormholeCore && + logs[k].topics.length > 0 && + logs[k].topics[0] == sig + ) { + ( + uint64 sequence, + uint32 nonce2, + bytes memory payload, + uint8 cl + ) = abi.decode( + logs[k].data, + (uint64, uint32, bytes, uint8) + ); + sequence; + nonce2; + cl; + + if ( + keccak256(payload) == + keccak256(temporalGovExecDataOptimism) + ) { + seenOptimism = true; + break; + } + } + } + assertTrue( + seenOptimism, + "Missing LogMessagePublished event on Optimism" + ); + } } } @@ -174,7 +233,7 @@ contract CrossChainPublishMessageTest is Test, PostProposalCheck { for (uint256 j = 0; j < proposals.length; j++) { HybridProposal proposal = HybridProposal(address(proposals[j])); - // only run tests against a base proposal + // Only run tests against non-moonbeam proposals if (uint256(proposal.primaryForkId()) == MOONBEAM_FORK_ID) { return; }