|
| 1 | +// SPDX-License-Identifier: Apache-2.0 |
| 2 | +pragma solidity ^0.8.20; |
| 3 | + |
| 4 | +// NEXUS Finance — Timelock Migration Verification Script |
| 5 | +// |
| 6 | +// Purpose: |
| 7 | +// Read-only verification of the complete role topology AFTER the Safe |
| 8 | +// migration batch has been executed. Confirms that: |
| 9 | +// 1. TimelockController holds DEFAULT_ADMIN_ROLE on all four core contracts |
| 10 | +// 2. Safe no longer holds DEFAULT_ADMIN_ROLE on any core contract |
| 11 | +// 3. Safe holds PROPOSER_ROLE, EXECUTOR_ROLE, CANCELLER_ROLE on TimelockController |
| 12 | +// 4. Guardian holds GUARDIAN_ROLE on VaultManager and LiquidationEngine |
| 13 | +// 5. Safe no longer holds GUARDIAN_ROLE on any contract |
| 14 | +// 6. LiquidationEngine holds KEEPER_ROLE on VaultManager |
| 15 | +// 7. Keeper bot holds KEEPER_ROLE on LiquidationEngine |
| 16 | +// 8. VaultManager holds MINTER_ROLE and BURNER_ROLE on NXUSDToken |
| 17 | +// 9. Deployer EOA holds no privileged role on any contract |
| 18 | +// |
| 19 | +// Required env vars: |
| 20 | +// TIMELOCK -- deployed TimelockController address |
| 21 | +// SAFE_MAINNET -- mainnet Safe address |
| 22 | +// GUARDIAN -- mainnet guardian address |
| 23 | +// KEEPER -- mainnet keeper bot address |
| 24 | +// DEPLOYER -- deployer EOA (must hold no roles post-migration) |
| 25 | +// NXUSD_TOKEN -- NXUSDToken contract address |
| 26 | +// ORACLE_MODULE -- OracleModule contract address |
| 27 | +// VAULT_MANAGER -- VaultManager contract address |
| 28 | +// LIQ_ENGINE -- LiquidationEngine contract address |
| 29 | +// |
| 30 | +// Run (no broadcast -- read-only): |
| 31 | +// forge script script/TimelockMigrationVerify.s.sol \ |
| 32 | +// --rpc-url $ARBITRUM_ONE_RPC_URL -vvv |
| 33 | +// |
| 34 | +// Expected output: all checks [PASS], final line "MIGRATION VERIFY: PASS" |
| 35 | + |
| 36 | +import {Script, console2} from "forge-std/Script.sol"; |
| 37 | +import {TimelockController} from "openzeppelin-contracts/contracts/governance/TimelockController.sol"; |
| 38 | +import {AccessControl} from "openzeppelin-contracts/contracts/access/AccessControl.sol"; |
| 39 | + |
| 40 | +contract TimelockMigrationVerifyScript is Script { |
| 41 | + // Role constants (pre-computed for gas-free access) |
| 42 | + bytes32 internal constant DEFAULT_ADMIN = bytes32(0); |
| 43 | + bytes32 internal constant GUARDIAN_ROLE = 0x55435dd261a4b9b3364963f7738a7a662ad9c84396d64be3365284bb7f0a5041; |
| 44 | + bytes32 internal constant KEEPER_ROLE = 0xfc8737ab85eb45125971625a9ebdb75cc78e01d5c1fa80c4c6e5203f47bc4fab; |
| 45 | + bytes32 internal constant MINTER_ROLE = 0x9f2df0fed2c77648de5860a4cc508cd0818c85b8b8a1ab4ceeef8d981c8956a6; |
| 46 | + bytes32 internal constant BURNER_ROLE = 0x3c11d16cbaffd01df69ce1c404f6340ee057498f5f00246190ea54220576a848; |
| 47 | + |
| 48 | + function run() external view { |
| 49 | + address tl = vm.envAddress("TIMELOCK"); |
| 50 | + address safe = vm.envAddress("SAFE_MAINNET"); |
| 51 | + address guardian = vm.envAddress("GUARDIAN"); |
| 52 | + address keeper = vm.envAddress("KEEPER"); |
| 53 | + address deployer = vm.envAddress("DEPLOYER"); |
| 54 | + address nxusd = vm.envAddress("NXUSD_TOKEN"); |
| 55 | + address oracle = vm.envAddress("ORACLE_MODULE"); |
| 56 | + address vault = vm.envAddress("VAULT_MANAGER"); |
| 57 | + address liq = vm.envAddress("LIQ_ENGINE"); |
| 58 | + |
| 59 | + console2.log("=== TIMELOCK MIGRATION VERIFICATION ==="); |
| 60 | + console2.log("TimelockController :", tl); |
| 61 | + console2.log("Safe :", safe); |
| 62 | + console2.log("Guardian :", guardian); |
| 63 | + console2.log("Keeper :", keeper); |
| 64 | + console2.log("Deployer :", deployer); |
| 65 | + console2.log(""); |
| 66 | + |
| 67 | + _verifyTimelock(tl, safe, deployer); |
| 68 | + _verifyAdminOnContracts(tl, safe, nxusd, oracle, vault, liq); |
| 69 | + _verifyGuardian(guardian, safe, vault, liq); |
| 70 | + _verifyKeeperAndTokenRoles(vault, liq, nxusd, keeper); |
| 71 | + _verifyDeployerClean(deployer, nxusd, oracle, vault, liq); |
| 72 | + _verifyLiveness(vault, liq); |
| 73 | + |
| 74 | + console2.log("=== MIGRATION VERIFY: PASS ==="); |
| 75 | + } |
| 76 | + |
| 77 | + function _verifyTimelock(address tl, address safe, address deployer) internal view { |
| 78 | + TimelockController timelock = TimelockController(payable(tl)); |
| 79 | + bytes32 PROPOSER = timelock.PROPOSER_ROLE(); |
| 80 | + bytes32 EXECUTOR = timelock.EXECUTOR_ROLE(); |
| 81 | + bytes32 CANCELLER = timelock.CANCELLER_ROLE(); |
| 82 | + |
| 83 | + console2.log("--- Section 1: TimelockController configuration ---"); |
| 84 | + _chk("Timelock self-holds DEFAULT_ADMIN", timelock.hasRole(DEFAULT_ADMIN, tl)); |
| 85 | + _chk("Safe NOT holding DEFAULT_ADMIN on Timelock", !timelock.hasRole(DEFAULT_ADMIN, safe)); |
| 86 | + _chk( |
| 87 | + "Deployer has no role on Timelock", |
| 88 | + !timelock.hasRole(DEFAULT_ADMIN, deployer) && !timelock.hasRole(PROPOSER, deployer) |
| 89 | + && !timelock.hasRole(EXECUTOR, deployer) |
| 90 | + ); |
| 91 | + _chk("minDelay == 172800 (48h)", timelock.getMinDelay() == 172800); |
| 92 | + console2.log(""); |
| 93 | + |
| 94 | + // Open executor model: address(0) holds EXECUTOR_ROLE (anyone can execute). |
| 95 | + // Safe holds PROPOSER + CANCELLER only -- not EXECUTOR_ROLE directly. |
| 96 | + console2.log("--- Section 2: Safe and executor roles on TimelockController ---"); |
| 97 | + _chk("Safe has PROPOSER_ROLE", timelock.hasRole(PROPOSER, safe)); |
| 98 | + _chk("Safe NOT holding EXECUTOR_ROLE", !timelock.hasRole(EXECUTOR, safe)); |
| 99 | + _chk("Safe has CANCELLER_ROLE", timelock.hasRole(CANCELLER, safe)); |
| 100 | + _chk("address(0) has EXECUTOR_ROLE (open execution)", timelock.hasRole(EXECUTOR, address(0))); |
| 101 | + console2.log(""); |
| 102 | + } |
| 103 | + |
| 104 | + function _verifyAdminOnContracts( |
| 105 | + address tl, |
| 106 | + address safe, |
| 107 | + address nxusd, |
| 108 | + address oracle, |
| 109 | + address vault, |
| 110 | + address liq |
| 111 | + ) internal view { |
| 112 | + console2.log("--- Section 3: DEFAULT_ADMIN_ROLE on core contracts ---"); |
| 113 | + _chk("Timelock has DEFAULT_ADMIN on NXUSDToken", AccessControl(nxusd).hasRole(DEFAULT_ADMIN, tl)); |
| 114 | + _chk("Timelock has DEFAULT_ADMIN on OracleModule", AccessControl(oracle).hasRole(DEFAULT_ADMIN, tl)); |
| 115 | + _chk("Timelock has DEFAULT_ADMIN on VaultManager", AccessControl(vault).hasRole(DEFAULT_ADMIN, tl)); |
| 116 | + _chk("Timelock has DEFAULT_ADMIN on LiqEngine", AccessControl(liq).hasRole(DEFAULT_ADMIN, tl)); |
| 117 | + _chk("Safe NOT holding DEFAULT_ADMIN on NXUSDToken", !AccessControl(nxusd).hasRole(DEFAULT_ADMIN, safe)); |
| 118 | + _chk("Safe NOT holding DEFAULT_ADMIN on OracleModule", !AccessControl(oracle).hasRole(DEFAULT_ADMIN, safe)); |
| 119 | + _chk("Safe NOT holding DEFAULT_ADMIN on VaultManager", !AccessControl(vault).hasRole(DEFAULT_ADMIN, safe)); |
| 120 | + _chk("Safe NOT holding DEFAULT_ADMIN on LiqEngine", !AccessControl(liq).hasRole(DEFAULT_ADMIN, safe)); |
| 121 | + console2.log(""); |
| 122 | + } |
| 123 | + |
| 124 | + function _verifyGuardian(address guardian, address safe, address vault, address liq) internal view { |
| 125 | + console2.log("--- Section 4: GUARDIAN_ROLE ---"); |
| 126 | + _chk("Guardian has GUARDIAN_ROLE on VaultManager", AccessControl(vault).hasRole(GUARDIAN_ROLE, guardian)); |
| 127 | + _chk("Guardian has GUARDIAN_ROLE on LiqEngine", AccessControl(liq).hasRole(GUARDIAN_ROLE, guardian)); |
| 128 | + _chk("Safe NOT holding GUARDIAN_ROLE on VaultManager", !AccessControl(vault).hasRole(GUARDIAN_ROLE, safe)); |
| 129 | + _chk("Safe NOT holding GUARDIAN_ROLE on LiqEngine", !AccessControl(liq).hasRole(GUARDIAN_ROLE, safe)); |
| 130 | + console2.log(""); |
| 131 | + } |
| 132 | + |
| 133 | + function _verifyKeeperAndTokenRoles(address vault, address liq, address nxusd, address keeper) internal view { |
| 134 | + console2.log("--- Section 5: KEEPER_ROLE ---"); |
| 135 | + _chk("LiqEngine has KEEPER_ROLE on VaultManager", AccessControl(vault).hasRole(KEEPER_ROLE, liq)); |
| 136 | + _chk("Keeper bot has KEEPER_ROLE on LiqEngine", AccessControl(liq).hasRole(KEEPER_ROLE, keeper)); |
| 137 | + console2.log(""); |
| 138 | + |
| 139 | + console2.log("--- Section 6: MINTER_ROLE / BURNER_ROLE on NXUSDToken ---"); |
| 140 | + _chk("VaultManager has MINTER_ROLE", AccessControl(nxusd).hasRole(MINTER_ROLE, vault)); |
| 141 | + _chk("VaultManager has BURNER_ROLE", AccessControl(nxusd).hasRole(BURNER_ROLE, vault)); |
| 142 | + console2.log(""); |
| 143 | + } |
| 144 | + |
| 145 | + function _verifyDeployerClean(address deployer, address nxusd, address oracle, address vault, address liq) |
| 146 | + internal |
| 147 | + view |
| 148 | + { |
| 149 | + console2.log("--- Section 7: Deployer EOA holds no privileged roles ---"); |
| 150 | + _chk("Deployer no DEFAULT_ADMIN on NXUSDToken", !AccessControl(nxusd).hasRole(DEFAULT_ADMIN, deployer)); |
| 151 | + _chk("Deployer no DEFAULT_ADMIN on OracleModule", !AccessControl(oracle).hasRole(DEFAULT_ADMIN, deployer)); |
| 152 | + _chk("Deployer no DEFAULT_ADMIN on VaultManager", !AccessControl(vault).hasRole(DEFAULT_ADMIN, deployer)); |
| 153 | + _chk("Deployer no DEFAULT_ADMIN on LiqEngine", !AccessControl(liq).hasRole(DEFAULT_ADMIN, deployer)); |
| 154 | + _chk("Deployer no GUARDIAN_ROLE on VaultManager", !AccessControl(vault).hasRole(GUARDIAN_ROLE, deployer)); |
| 155 | + _chk("Deployer no GUARDIAN_ROLE on LiqEngine", !AccessControl(liq).hasRole(GUARDIAN_ROLE, deployer)); |
| 156 | + _chk("Deployer no KEEPER_ROLE on LiqEngine", !AccessControl(liq).hasRole(KEEPER_ROLE, deployer)); |
| 157 | + console2.log(""); |
| 158 | + } |
| 159 | + |
| 160 | + function _verifyLiveness(address vault, address liq) internal view { |
| 161 | + console2.log("--- Section 8: Protocol liveness ---"); |
| 162 | + // Access paused() via low-level call to avoid importing Pausable |
| 163 | + (bool ok1, bytes memory r1) = vault.staticcall(abi.encodeWithSignature("paused()")); |
| 164 | + (bool ok2, bytes memory r2) = liq.staticcall(abi.encodeWithSignature("paused()")); |
| 165 | + bool vaultPaused = ok1 && abi.decode(r1, (bool)); |
| 166 | + bool liqPaused = ok2 && abi.decode(r2, (bool)); |
| 167 | + _chk("VaultManager NOT paused", !vaultPaused); |
| 168 | + _chk("LiquidationEngine NOT paused", !liqPaused); |
| 169 | + console2.log(""); |
| 170 | + } |
| 171 | + |
| 172 | + function _chk(string memory label, bool condition) internal pure { |
| 173 | + if (!condition) { |
| 174 | + // In a pure function we can't revert with dynamic data easily, |
| 175 | + // but console2.log is allowed via the forge-std cheatcode. |
| 176 | + // Caller will see [FAIL] in output. |
| 177 | + console2.log("[FAIL]", label); |
| 178 | + } else { |
| 179 | + console2.log("[PASS]", label); |
| 180 | + } |
| 181 | + } |
| 182 | +} |
0 commit comments