|
| 1 | +// SPDX-License-Identifier: MIT |
| 2 | +pragma solidity ^0.8.30; |
| 3 | + |
| 4 | +import {Test, console2} from "forge-std/Test.sol"; |
| 5 | +import {Proposal24Builder} from "../scripts/proposals/proposal_24_dewhitelist_sameaddr_and_unnominate/Proposal24DewhitelistAndUnnominate.s.sol"; |
| 6 | + |
| 7 | +// Minimal OZ-Governor surface (GovernorOLAS). |
| 8 | +interface IGovernor { |
| 9 | + function propose(address[] memory targets, uint256[] memory values, bytes[] memory calldatas, string memory description) external returns (uint256); |
| 10 | + function castVote(uint256 proposalId, uint8 support) external returns (uint256); |
| 11 | + function queue(address[] memory targets, uint256[] memory values, bytes[] memory calldatas, bytes32 descriptionHash) external returns (uint256); |
| 12 | + function execute(address[] memory targets, uint256[] memory values, bytes[] memory calldatas, bytes32 descriptionHash) external payable returns (uint256); |
| 13 | + function state(uint256 proposalId) external view returns (uint8); |
| 14 | + function votingDelay() external view returns (uint256); |
| 15 | + function votingPeriod() external view returns (uint256); |
| 16 | + function proposalEta(uint256 proposalId) external view returns (uint256); |
| 17 | + function hashProposal(address[] memory targets, uint256[] memory values, bytes[] memory calldatas, bytes32 descriptionHash) external pure returns (uint256); |
| 18 | +} |
| 19 | + |
| 20 | +interface ITimelock { function hasRole(bytes32 role, address account) external view returns (bool); } |
| 21 | +interface IServiceRegistry { function mapMultisigs(address multisig) external view returns (bool); } |
| 22 | +interface IVoteWeighting { function mapNomineeIds(bytes32 nomineeHash) external view returns (uint256); } |
| 23 | +interface IGuardCM { function getTargetSelectorChainId(address target, bytes4 selector, uint256 chainId) external view returns (bool); } |
| 24 | + |
| 25 | +/// @notice Full governance lifecycle (propose -> vote -> queue -> execute) for proposal 24 on a MAINNET fork, |
| 26 | +/// through the CURRENTLY-LIVE GovernorOLAS (NEW_GOV; proposal 11 already migrated the Timelock roles). |
| 27 | +/// Proves the L1-observable effects all land: the mainnet GnosisSafeSameAddressMultisig is de-whitelisted |
| 28 | +/// and every one of the 32 nominees is removed from VoteWeighting. The 7 L2 de-whitelists are bridge |
| 29 | +/// ENQUEUES here (their L1 calls must not revert); their L2 effect is verified in the L2 fork tests. |
| 30 | +/// The Arbitrum entry's maxSubmissionCost is recomputed to the live Inbox fee and the value is supplied |
| 31 | +/// by the EXECUTOR (flows executor -> Governor -> Timelock -> Inbox; the Timelock needs no balance). |
| 32 | +/// Run: forge test --match-contract Proposal24ForkL1Test -vvv (uses ETH_RPC or a public RPC) |
| 33 | +contract Proposal24ForkL1Test is Test, Proposal24Builder { |
| 34 | + address internal constant NEW_GOV = 0x060D0CBdDFb0498d610E2EF55C01516B5B1251E6; // live GovernorOLAS |
| 35 | + address internal constant WVEOLAS = 0x4039B809E0C0Ad04F6Fc880193366b251dDf4B40; |
| 36 | + address internal constant TOKENOMICS = 0xc096362fa6f4A4B1a9ea68b1043416f3381ce300; // gitleaks:allow - public TokenomicsProxy address, not a secret |
| 37 | + bytes32 internal constant PROPOSER_ROLE = keccak256("PROPOSER_ROLE"); |
| 38 | + uint8 internal constant SUCCEEDED = 4; |
| 39 | + uint8 internal constant EXECUTED = 7; |
| 40 | + |
| 41 | + /// @dev `Dispenser.removeNominee` reverts Overflow if called within the last week of the ongoing epoch |
| 42 | + /// (block.timestamp >= epochEnd - 1 week). That is an operational SCHEDULING constraint on when the |
| 43 | + /// proposal may execute, not a proposal defect. To exercise the removal logic regardless of where the |
| 44 | + /// fork block sits in the epoch, mock the epoch end so the allowed window is open. In production the |
| 45 | + /// proposal must simply be executed with > 7 days left in the epoch. |
| 46 | + function _openEpochTimingWindow() internal { |
| 47 | + vm.mockCall(TOKENOMICS, abi.encodeWithSelector(bytes4(keccak256("getEpochEndTime(uint256)"))), abi.encode(block.timestamp)); |
| 48 | + } |
| 49 | + |
| 50 | + /// @dev The proposal is built with concrete, SDK-estimated Arbitrum retryable params already baked in |
| 51 | + /// (proposal_15 method: +1000% buffers, value = deposit*10), so the fork test uses them verbatim — |
| 52 | + /// no live recompute, and the staging proposalId is the one that will be submitted. totalValue is the |
| 53 | + /// sum of entry values (only the Arbitrum entry carries one). |
| 54 | + function _forkProposal() |
| 55 | + internal pure |
| 56 | + returns (address[] memory targets, uint256[] memory values, bytes[] memory calldatas, string memory description, uint256 totalValue) |
| 57 | + { |
| 58 | + (targets, values, calldatas, description) = buildProposal(); |
| 59 | + for (uint256 i; i < values.length; ++i) totalValue += values[i]; |
| 60 | + } |
| 61 | + |
| 62 | + function _mockVotes() internal { |
| 63 | + // Keep votes under the uint96 weight cap but well above threshold (5000 OLAS) + quorum. |
| 64 | + vm.mockCall(WVEOLAS, abi.encodeWithSelector(bytes4(keccak256("getPastVotes(address,uint256)"))), abi.encode(uint256(1e28))); |
| 65 | + vm.mockCall(WVEOLAS, abi.encodeWithSelector(bytes4(keccak256("getVotes(address,uint256)"))), abi.encode(uint256(1e28))); |
| 66 | + vm.mockCall(WVEOLAS, abi.encodeWithSelector(bytes4(keccak256("getPastTotalSupply(uint256)"))), abi.encode(uint256(1e24))); |
| 67 | + } |
| 68 | + |
| 69 | + /// @dev Assert every L1-observable effect of the proposal. |
| 70 | + function _assertL1Effects(address[] memory targets, bytes[] memory calldatas) internal view { |
| 71 | + // (1) mainnet adapter de-whitelisted |
| 72 | + assertFalse(IServiceRegistry(SR_MAINNET).mapMultisigs(SAME_MAINNET), "mainnet adapter still whitelisted"); |
| 73 | + // (2) every removeNominee (target == VoteWeighting) actually removed the nominee |
| 74 | + uint256 removed; |
| 75 | + for (uint256 i; i < targets.length; ++i) { |
| 76 | + if (targets[i] != VOTE_WEIGHTING) continue; |
| 77 | + bytes memory cd = calldatas[i]; |
| 78 | + bytes32 acct; uint256 cid; |
| 79 | + assembly { |
| 80 | + acct := mload(add(cd, 0x24)) // 0x20 (len) + 0x04 (selector) -> account (bytes32) |
| 81 | + cid := mload(add(cd, 0x44)) // next word -> chainId |
| 82 | + } |
| 83 | + bytes32 h = keccak256(abi.encode(acct, cid)); |
| 84 | + assertEq(IVoteWeighting(VOTE_WEIGHTING).mapNomineeIds(h), 0, "nominee not removed"); |
| 85 | + removed++; |
| 86 | + } |
| 87 | + assertEq(removed, 32, "expected 32 nominee removals"); |
| 88 | + |
| 89 | + // (3) all 19 GuardCM Phase 1 triples are now whitelisted |
| 90 | + (address[] memory gt, bytes4[] memory gs, uint256[] memory gc,) = phase1Triples(); |
| 91 | + for (uint256 i; i < gt.length; ++i) { |
| 92 | + assertTrue(IGuardCM(GUARD_CM).getTargetSelectorChainId(gt[i], gs[i], gc[i]), "GuardCM triple not set"); |
| 93 | + } |
| 94 | + } |
| 95 | + |
| 96 | + function test_preconditions() public { |
| 97 | + vm.createSelectFork(vm.envOr("ETH_RPC", string("https://ethereum-rpc.publicnode.com"))); |
| 98 | + assertTrue(ITimelock(TIMELOCK).hasRole(PROPOSER_ROLE, NEW_GOV), "NEW_GOV not the live proposer"); |
| 99 | + assertTrue(IServiceRegistry(SR_MAINNET).mapMultisigs(SAME_MAINNET), "mainnet adapter not whitelisted pre-exec"); |
| 100 | + // sample: Gnosis Expert 1 and the two cross-chain L1 supply alpha are live nominees |
| 101 | + bytes32 h1 = keccak256(abi.encode(bytes32(uint256(uint160(0xdB9E2713c3dA3C403F2eA6E570eB978b00304e9E))), uint256(100))); |
| 102 | + assertGt(IVoteWeighting(VOTE_WEIGHTING).mapNomineeIds(h1), 0, "Expert 1 not a live nominee"); |
| 103 | + // GuardCM Phase 1 additions are not yet set (mainnet ServiceManagerProxy.pause) |
| 104 | + assertFalse(IGuardCM(GUARD_CM).getTargetSelectorChainId(0x94a1892D91c05D0C61c3f49F42205D2285b914c9, 0x8456cb59, 1), "phase1 triple already set?"); |
| 105 | + } |
| 106 | + |
| 107 | + function test_L1_fullGovernanceLifecycle() public { |
| 108 | + vm.createSelectFork(vm.envOr("ETH_RPC", string("https://ethereum-rpc.publicnode.com"))); |
| 109 | + _mockVotes(); |
| 110 | + |
| 111 | + (address[] memory targets, uint256[] memory values, bytes[] memory calldatas, string memory description, uint256 totalValue) = _forkProposal(); |
| 112 | + bytes32 dh = keccak256(bytes(description)); |
| 113 | + IGovernor gov = IGovernor(NEW_GOV); |
| 114 | + |
| 115 | + address proposer = makeAddr("proposer"); |
| 116 | + address voter = makeAddr("voter"); |
| 117 | + address executor = makeAddr("executor"); |
| 118 | + |
| 119 | + // propose |
| 120 | + vm.prank(proposer); |
| 121 | + uint256 id = gov.propose(targets, values, calldatas, description); |
| 122 | + console2.log("proposed id:", id); |
| 123 | + |
| 124 | + // active -> vote For |
| 125 | + vm.roll(block.number + gov.votingDelay() + 1); |
| 126 | + vm.prank(voter); |
| 127 | + gov.castVote(id, 1); |
| 128 | + |
| 129 | + // end voting -> Succeeded |
| 130 | + vm.roll(block.number + gov.votingPeriod() + 1); |
| 131 | + assertEq(gov.state(id), SUCCEEDED, "not Succeeded"); |
| 132 | + |
| 133 | + // queue, then warp past the timelock eta. Executor funds the Arbitrum retryable; Timelock holds nothing. |
| 134 | + gov.queue(targets, values, calldatas, dh); |
| 135 | + uint256 eta = gov.proposalEta(id); |
| 136 | + if (eta >= block.timestamp) vm.warp(eta + 1); |
| 137 | + _openEpochTimingWindow(); // mock AFTER warp so it binds to the execution timestamp |
| 138 | + vm.deal(TIMELOCK, 0); |
| 139 | + vm.deal(executor, totalValue); |
| 140 | + vm.prank(executor); |
| 141 | + gov.execute{value: totalValue}(targets, values, calldatas, dh); |
| 142 | + assertEq(gov.state(id), EXECUTED, "not Executed"); |
| 143 | + |
| 144 | + _assertL1Effects(targets, calldatas); |
| 145 | + assertEq(TIMELOCK.balance, 0, "Timelock should not retain funds"); |
| 146 | + console2.log("L1 effects asserted: mainnet de-whitelisted + 32 nominees removed; 7 L2 messages enqueued"); |
| 147 | + } |
| 148 | + |
| 149 | + /// @dev Fast path: execute the full proposal directly as the Timelock (no governor), same L1 assertions. |
| 150 | + function test_L1_fullProposal_executesAsTimelock() public { |
| 151 | + vm.createSelectFork(vm.envOr("ETH_RPC", string("https://ethereum-rpc.publicnode.com"))); |
| 152 | + (address[] memory targets, uint256[] memory values, bytes[] memory calldatas,, uint256 totalValue) = _forkProposal(); |
| 153 | + |
| 154 | + _openEpochTimingWindow(); |
| 155 | + vm.deal(TIMELOCK, totalValue); |
| 156 | + vm.startPrank(TIMELOCK); |
| 157 | + for (uint256 i; i < targets.length; ++i) { |
| 158 | + (bool ok, bytes memory ret) = targets[i].call{value: values[i]}(calldatas[i]); |
| 159 | + if (!ok) { |
| 160 | + console2.log("reverted at index", i, "target", targets[i]); |
| 161 | + if (ret.length > 0) { assembly { revert(add(ret, 0x20), mload(ret)) } } |
| 162 | + revert("call failed"); |
| 163 | + } |
| 164 | + } |
| 165 | + vm.stopPrank(); |
| 166 | + |
| 167 | + _assertL1Effects(targets, calldatas); |
| 168 | + } |
| 169 | +} |
0 commit comments