diff --git a/script/mantle/4_DeployModule.s.sol b/script/mantle/4_DeployModule.s.sol new file mode 100644 index 0000000..299491f --- /dev/null +++ b/script/mantle/4_DeployModule.s.sol @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "forge-std/Script.sol"; + +import "./Parameters.sol"; +import "./Addresses.sol"; + +import "../../src/safe-module/MoeRegulator.sol"; + +contract DeployModuleScript is Script { + function run() public returns (MoeRegulator moeRegulator) { + // add the custom chain + setChain( + Parameters.chainAlias, + StdChains.ChainData({name: Parameters.chainName, chainId: Parameters.chainId, rpcUrl: Parameters.rpcUrl}) + ); + + vm.createSelectFork(StdChains.getChain(Parameters.chainAlias).rpcUrl); + + uint256 pk = vm.envUint("DEPLOYER_PRIVATE_KEY"); + + vm.startBroadcast(pk); + + moeRegulator = new MoeRegulator(Addresses.devMultisig, Addresses.masterChefProxy); + + vm.stopBroadcast(); + } +} diff --git a/script/mantle/Addresses.sol b/script/mantle/Addresses.sol index 50dc9a8..0d214a6 100644 --- a/script/mantle/Addresses.sol +++ b/script/mantle/Addresses.sol @@ -6,6 +6,7 @@ library Addresses { address public constant moeFactory = 0x5bEf015CA9424A7C07B68490616a4C1F094BEdEc; address public constant moePairImplementation = 0x08477e01A19d44C31E4C11Dc2aC86E3BBE69c28B; address public constant moeRouter = 0xeaEE7EE68874218c3558b40063c42B82D3E7232a; + address public constant moeRegulator = 0x9E1a469Ba8aD682F4adcFe4cC5db7D36f9B3a7b9; // Tokens address public constant wmantle = 0x78c1b0C915c4FAA5FffA6CAbf0219DA63d7f4cb8; diff --git a/src/interfaces/IMoeRegulator.sol b/src/interfaces/IMoeRegulator.sol new file mode 100644 index 0000000..8d06ab8 --- /dev/null +++ b/src/interfaces/IMoeRegulator.sol @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +interface IMoeRegulator { + error MoeRegulator__NoUpdateNeeded(); + error MoeRegulator__NoRateFound(); + error MoeRegulator__UpdateFailed(); + + function SAFE() external view returns (address); + + function MASTERCHEF() external view returns (address); + + function MOE_RATES() external view returns (bytes memory); + + function getScheduledRate(uint256 timestamp) external pure returns (uint32 date, uint64 moePerSecond); + + function canUpdateMoePerSecond() external view returns (bool updateNeeded); + + function updateMoePerSecond() external; +} diff --git a/src/interfaces/ISafe.sol b/src/interfaces/ISafe.sol new file mode 100644 index 0000000..2eb09d9 --- /dev/null +++ b/src/interfaces/ISafe.sol @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +interface ISafe { + enum Operation { + Call, + DelegateCall + } + + function execTransactionFromModule(address to, uint256 value, bytes calldata data, Operation operation) + external + returns (bool success); +} diff --git a/src/safe-module/MoeRegulator.sol b/src/safe-module/MoeRegulator.sol new file mode 100644 index 0000000..3ab915b --- /dev/null +++ b/src/safe-module/MoeRegulator.sol @@ -0,0 +1,121 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {IMasterChef} from "../interfaces/IMasterChef.sol"; +import {ISafe} from "../interfaces/ISafe.sol"; +import {IMoeRegulator} from "../interfaces/IMoeRegulator.sol"; + +/** + * @title Moe Regulator Contract + * @dev Safe module that steps the MasterChef MOE per second down along a fixed schedule. + * It is enabled as a module on the Safe that owns the MasterChef, so that anyone can call + * updateMoePerSecond to apply the rate scheduled for the current timestamp. + */ +contract MoeRegulator is IMoeRegulator { + address public immutable override SAFE; + address public immutable override MASTERCHEF; + + uint256 constant DATE_SIZE = 4; + uint256 constant ENTRY_SIZE = 12; + uint256 constant COUNT = 19; + + /** + * @dev Emission schedule ordered newest date first. Each 12-byte entry packs a 4-byte timestamp + * followed by the 8-byte MOE per second that applies from that timestamp onwards. + */ + bytes public constant override MOE_RATES = hex"6cf95d000e92596fd6290000" // 8-Dec-2027 4:00 PM UTC, 1.05e18 + hex"6cd1d0000eb5e06245ea0000" // 8-Nov-2027 4:00 PM UTC, 1.06e18 + hex"6ca8f1800efcee47256c0000" // 8-Oct-2027 4:00 PM UTC, 1.08e18 + hex"6c8164800f67831e74af0000" // 8-Sep-2027 4:00 PM UTC, 1.11e18 + hex"6c5886000fd217f5c3f20000" // 8-Aug-2027 4:00 PM UTC, 1.14e18 + hex"6c2fa780103caccd13350000" // 8-Jul-2027 4:00 PM UTC, 1.17e18 + hex"6c081a8010a741a462780000" // 8-Jun-2027 4:00 PM UTC, 1.20e18 + hex"6bdf3c001111d67bb1bb0000" // 8-May-2027 4:00 PM UTC, 1.23e18 + hex"6bb7af00117c6b5300fe0000" // 8-Apr-2027 4:00 PM UTC, 1.26e18 + hex"6b8ed08011e7002a50410000" // 8-Mar-2027 4:00 PM UTC, 1.29e18 + hex"6b69e680125195019f840000" // 8-Feb-2027 4:00 PM UTC, 1.32e18 + hex"6b41080012bc29d8eec70000" // 8-Jan-2027 4:00 PM UTC, 1.35e18 + hex"6b1829801326beb03e0a0000" // 8-Dec-2026 4:00 PM UTC, 1.38e18 + hex"6af09c80139153878d4d0000" // 8-Nov-2026 4:00 PM UTC, 1.41e18 + hex"6ac7be0013fbe85edc900000" // 8-Oct-2026 4:00 PM UTC, 1.44e18 + hex"6aa0310014667d362bd30000" // 8-Sep-2026 4:00 PM UTC, 1.47e18 + hex"6a77528014d1120d7b160000" // 8-Aug-2026 4:00 PM UTC, 1.50e18 + hex"6a4e7400153ba6e4ca590000" // 8-Jul-2026 4:00 PM UTC, 1.53e18 + hex"6a26e7001582b4c9a9db0000"; // 8-Jun-2026 4:00 PM UTC, 1.55e18 + + /** + * @dev Constructor for the MoeRegulator contract. + * @param safe The Safe that owns the MasterChef and enables this contract as a module. + * @param masterchef The MasterChef whose MOE per second is regulated. + */ + constructor(address safe, address masterchef) { + SAFE = safe; + MASTERCHEF = masterchef; + } + + /** + * @dev Returns the scheduled entry active at a timestamp, i.e. the most recent one whose date has passed. + * @param timestamp The timestamp to look up, in seconds. + * @return The date the returned rate became active. + * @return The MOE per second scheduled from that date. + */ + function getScheduledRate(uint256 timestamp) public pure override returns (uint32, uint64) { + bytes memory moeRates = MOE_RATES; + for (uint256 i; i < COUNT; i++) { + (uint32 date, uint64 moePerSecond) = _get(moeRates, i); + if (timestamp >= date) { + return (date, moePerSecond); + } + } + revert MoeRegulator__NoRateFound(); + } + + /** + * @dev Returns whether updateMoePerSecond would lower the rate at the current block timestamp. + */ + function canUpdateMoePerSecond() external view override returns (bool updateNeeded) { + (updateNeeded,) = _canUpdateMoePerSecond(); + } + + /** + * @dev Applies the rate scheduled for the current block timestamp to the MasterChef through the Safe module. + * Reverts when the scheduled rate already matches the current one, and only ever lowers the rate. + */ + function updateMoePerSecond() external override { + (bool needed, uint64 moePerSecond) = _canUpdateMoePerSecond(); + if (!needed) revert MoeRegulator__NoUpdateNeeded(); + + bool success = ISafe(SAFE) + .execTransactionFromModule( + MASTERCHEF, 0, abi.encodeCall(IMasterChef.setMoePerSecond, (moePerSecond)), ISafe.Operation.Call + ); + if (!success) revert MoeRegulator__UpdateFailed(); + } + + /** + * @dev Decodes the entry at index from a packed schedule. + * @param rates The packed schedule (see MOE_RATES). + * @param index The entry index, from 0 (newest) to COUNT - 1 (oldest). + * @return date The entry's 4-byte timestamp. + * @return rate The entry's 8-byte MOE per second. + */ + function _get(bytes memory rates, uint256 index) internal pure returns (uint32 date, uint64 rate) { + assembly ("memory-safe") { + date := shr(224, mload(add(add(rates, 0x20), mul(index, ENTRY_SIZE)))) + rate := shr(192, mload(add(add(rates, add(0x20, DATE_SIZE)), mul(index, ENTRY_SIZE)))) + } + } + + /** + * @dev Shared precondition for canUpdateMoePerSecond and updateMoePerSecond. + * @return updateNeeded True when the scheduled rate is a strict decrease from the MasterChef's current rate. + * @return moePerSecond The rate scheduled for the current block timestamp. + */ + function _canUpdateMoePerSecond() internal view returns (bool updateNeeded, uint64 moePerSecond) { + uint32 date; + (date, moePerSecond) = getScheduledRate(block.timestamp); + uint256 currentMoePerSecond = IMasterChef(MASTERCHEF).getMoePerSecond(); + + updateNeeded = moePerSecond < currentMoePerSecond; + } +} diff --git a/test/safe-module/MoeRegulator.t.sol b/test/safe-module/MoeRegulator.t.sol new file mode 100644 index 0000000..677f3dd --- /dev/null +++ b/test/safe-module/MoeRegulator.t.sol @@ -0,0 +1,223 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "forge-std/Test.sol"; + +import {MoeRegulator} from "../../src/safe-module/MoeRegulator.sol"; +import {IMoeRegulator} from "../../src/interfaces/IMoeRegulator.sol"; +import {IMasterChef} from "../../src/interfaces/IMasterChef.sol"; +import {IMoe} from "../../src/interfaces/IMoe.sol"; + +interface ISafeModuleManager { + function enableModule(address module) external; + function isModuleEnabled(address module) external view returns (bool); +} + +interface IOwnable { + function owner() external view returns (address); +} + +/// forge-config: default.evm_version = "shanghai" +contract MoeRegulatorTest is Test { + uint256 constant FORK_BLOCK = 96522935; + + address constant MULTISIG = 0x244305969310527b29d8Ff3Aa263f686dB61Df6f; + address constant MASTERCHEF = 0xA756f7D419e1A5cbd656A438443011a7dE1955b5; + + MoeRegulator regulator; + + struct Rate { + uint256 date; + uint256 rate; + } + + function setUp() public { + vm.createSelectFork(vm.rpcUrl("mantle"), FORK_BLOCK); + + regulator = new MoeRegulator(MULTISIG, MASTERCHEF); + + vm.prank(MULTISIG); + ISafeModuleManager(MULTISIG).enableModule(address(regulator)); + + assertTrue(ISafeModuleManager(MULTISIG).isModuleEnabled(address(regulator)), "setUp::1"); + assertEq(IOwnable(MASTERCHEF).owner(), MULTISIG, "setUp::2"); + } + + function _schedule() internal pure returns (Rate[] memory s) { + s = new Rate[](19); + s[0] = Rate(1780934400, 1.55e18); // 8-Jun-2026 4:00 PM UTC + s[1] = Rate(1783526400, 1.53e18); // 8-Jul-2026 4:00 PM UTC + s[2] = Rate(1786204800, 1.5e18); // 8-Aug-2026 4:00 PM UTC + s[3] = Rate(1788883200, 1.47e18); // 8-Sep-2026 4:00 PM UTC + s[4] = Rate(1791475200, 1.44e18); // 8-Oct-2026 4:00 PM UTC + s[5] = Rate(1794153600, 1.41e18); // 8-Nov-2026 4:00 PM UTC + s[6] = Rate(1796745600, 1.38e18); // 8-Dec-2026 4:00 PM UTC + s[7] = Rate(1799424000, 1.35e18); // 8-Jan-2027 4:00 PM UTC + s[8] = Rate(1802102400, 1.32e18); // 8-Feb-2027 4:00 PM UTC + s[9] = Rate(1804521600, 1.29e18); // 8-Mar-2027 4:00 PM UTC + s[10] = Rate(1807200000, 1.26e18); // 8-Apr-2027 4:00 PM UTC + s[11] = Rate(1809792000, 1.23e18); // 8-May-2027 4:00 PM UTC + s[12] = Rate(1812470400, 1.2e18); // 8-Jun-2027 4:00 PM UTC + s[13] = Rate(1815062400, 1.17e18); // 8-Jul-2027 4:00 PM UTC + s[14] = Rate(1817740800, 1.14e18); // 8-Aug-2027 4:00 PM UTC + s[15] = Rate(1820419200, 1.11e18); // 8-Sep-2027 4:00 PM UTC + s[16] = Rate(1823011200, 1.08e18); // 8-Oct-2027 4:00 PM UTC + s[17] = Rate(1825689600, 1.06e18); // 8-Nov-2027 4:00 PM UTC + s[18] = Rate(1828281600, 1.05e18); // 8-Dec-2027 4:00 PM UTC + } + + function test_GetScheduledRate_Boundaries() public { + Rate[] memory s = _schedule(); + + (uint32 date, uint64 rate) = regulator.getScheduledRate(s[0].date); + assertEq(date, uint32(s[0].date), "test_GetScheduledRate_Boundaries::1"); + assertEq(rate, uint64(s[0].rate), "test_GetScheduledRate_Boundaries::2"); + + (date, rate) = regulator.getScheduledRate(s[1].date); + assertEq(date, uint32(s[1].date), "test_GetScheduledRate_Boundaries::3"); + assertEq(rate, uint64(s[1].rate), "test_GetScheduledRate_Boundaries::4"); + + (date, rate) = regulator.getScheduledRate(s[18].date + 365 days); + assertEq(date, uint32(s[18].date), "test_GetScheduledRate_Boundaries::5"); + assertEq(rate, uint64(s[18].rate), "test_GetScheduledRate_Boundaries::6"); + } + + function test_GetScheduledRate_RevertsBeforeFirstDate() public { + Rate[] memory s = _schedule(); + + vm.expectRevert(IMoeRegulator.MoeRegulator__NoRateFound.selector); + regulator.getScheduledRate(s[0].date - 1); + } + + function test_UpdateMoePerSecond() public { + Rate[] memory s = _schedule(); + + vm.warp(s[1].date); + regulator.updateMoePerSecond(); + + assertEq(IMasterChef(MASTERCHEF).getMoePerSecond(), s[1].rate, "test_UpdateMoePerSecond::1"); + } + + /// @dev Active entry for a timestamp: the latest scheduled date that is <= ts. found=false before the first date. + function _active(uint256 ts) internal pure returns (bool found, uint256 date, uint256 rate) { + Rate[] memory s = _schedule(); + for (uint256 i; i < s.length; i++) { + if (ts >= s[i].date) { + (found, date, rate) = (true, s[i].date, s[i].rate); + } + } + } + + function testFuzz_GetScheduledRate(uint32 ts) public { + (bool found, uint256 date, uint256 rate) = _active(ts); + + if (!found) { + vm.expectRevert(IMoeRegulator.MoeRegulator__NoRateFound.selector); + regulator.getScheduledRate(ts); + return; + } + + (uint32 actualDate, uint64 actualRate) = regulator.getScheduledRate(ts); + assertEq(actualDate, uint32(date), "testFuzz_GetScheduledRate::1"); + assertEq(actualRate, uint64(rate), "testFuzz_GetScheduledRate::2"); + } + + function testFuzz_UpdateMoePerSecond(uint256 ts) public { + Rate[] memory s = _schedule(); + ts = bound(ts, s[0].date, s[18].date); + + (,, uint256 expectedRate) = _active(ts); + uint256 currentRate = IMasterChef(MASTERCHEF).getMoePerSecond(); + + vm.warp(ts); + + if (expectedRate == currentRate) { + vm.expectRevert(IMoeRegulator.MoeRegulator__NoUpdateNeeded.selector); + regulator.updateMoePerSecond(); + } else { + regulator.updateMoePerSecond(); + } + + assertEq(IMasterChef(MASTERCHEF).getMoePerSecond(), expectedRate, "testFuzz_UpdateMoePerSecond::1"); + } + + function test_UpdateRevertsWhenRateWouldNotDecrease() public { + Rate[] memory s = _schedule(); + vm.warp(s[5].date); // scheduled rate 1.41e18 + + vm.prank(MULTISIG); + IMasterChef(MASTERCHEF).setMoePerSecond(uint96(1e18)); + + assertFalse(regulator.canUpdateMoePerSecond(), "test_UpdateRevertsWhenRateWouldNotDecrease::1"); + + vm.expectRevert(IMoeRegulator.MoeRegulator__NoUpdateNeeded.selector); + regulator.updateMoePerSecond(); + } + + function test_CanUpdateMoePerSecond() public { + Rate[] memory s = _schedule(); + + vm.warp(s[0].date); + assertFalse(regulator.canUpdateMoePerSecond(), "test_CanUpdateMoePerSecond::1"); + + vm.warp(s[1].date); + assertTrue(regulator.canUpdateMoePerSecond(), "test_CanUpdateMoePerSecond::2"); + + regulator.updateMoePerSecond(); + assertFalse(regulator.canUpdateMoePerSecond(), "test_CanUpdateMoePerSecond::3"); + } + + function test_CanUpdateMoePerSecond_RevertsBeforeFirstDate() public { + Rate[] memory s = _schedule(); + + vm.warp(s[0].date - 1); + vm.expectRevert(IMoeRegulator.MoeRegulator__NoRateFound.selector); + regulator.canUpdateMoePerSecond(); + } + + function testFuzz_CanUpdateMoePerSecond(uint256 ts) public { + Rate[] memory s = _schedule(); + ts = bound(ts, s[0].date, s[18].date); + + (,, uint256 expectedRate) = _active(ts); + uint256 currentRate = IMasterChef(MASTERCHEF).getMoePerSecond(); + + vm.warp(ts); + + assertEq( + regulator.canUpdateMoePerSecond(), expectedRate < currentRate, "testFuzz_CanUpdateMoePerSecond::1" + ); + } + + function test_UpdateAcrossAllDates() public { + Rate[] memory s = _schedule(); + + for (uint256 i; i < s.length; i++) { + uint256 currentRate = IMasterChef(MASTERCHEF).getMoePerSecond(); + + vm.warp(s[i].date); + + if (s[i].rate == currentRate) { + vm.expectRevert(IMoeRegulator.MoeRegulator__NoUpdateNeeded.selector); + regulator.updateMoePerSecond(); + } else { + regulator.updateMoePerSecond(); + } + + assertEq(IMasterChef(MASTERCHEF).getMoePerSecond(), s[i].rate, "test_UpdateAcrossAllDates::1"); + } + + IMoe moe = IMoe(IMasterChef(MASTERCHEF).getMoe()); + assertLt(moe.totalSupply(), moe.getMaxSupply(), "test_UpdateAcrossAllDates::2"); + + vm.warp(block.timestamp + 60 days); + vm.expectRevert(IMoeRegulator.MoeRegulator__NoUpdateNeeded.selector); + regulator.updateMoePerSecond(); + + uint256[] memory pids = new uint256[](1); + pids[0] = 57; + IMasterChef(MASTERCHEF).updateAll(pids); + + assertEq(moe.totalSupply(), moe.getMaxSupply(), "test_UpdateAcrossAllDates::3"); + } +}