Skip to content

Commit 935eb34

Browse files
feat: add timelock scripts and protocol hardening updates
1 parent 03be84e commit 935eb34

6 files changed

Lines changed: 583 additions & 7 deletions

File tree

constitution/constitution_engine.sol

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,31 @@
22
pragma solidity ^0.8.20;
33

44
/*
5-
NEXUS ECONOMIC CONSTITUTION ENGINE
6-
SOV-006
7-
8-
Defines protocol invariants that governance must not violate.
9-
This contract does not control the system directly.
10-
It exposes invariant checks for auditors and governance verification.
11-
*/
5+
* ============================================================================
6+
* AUDIT SCOPE: EXCLUDED — NOT DEPLOYED
7+
* ============================================================================
8+
*
9+
* This contract has NOT been deployed to any network and is NOT part of the
10+
* live protocol. It is a reference document expressing invariant intent.
11+
*
12+
* INTERFACE MISMATCH (blocking deployment):
13+
* NexusEconomicConstitution.assertProtocolInvariant() calls
14+
* IVaultManager.collateralRatio() — this function does not exist on the
15+
* deployed VaultManager contract. VaultManager exposes per-user state as
16+
* collateralOf[address] and debtOf[address] mappings; there is no
17+
* protocol-level collateralRatio() view function.
18+
*
19+
* INVARIANT STATUS IN LIVE SYSTEM:
20+
* - MIN_COLLATERAL_RATIO (150%): enforced per-position by VaultManager._isSafe()
21+
* at every mint and withdraw operation. See invariant I-01, I-02 in audit doc.
22+
* - MAX_NXUSD_SUPPLY: NOT enforced on-chain. Known gap, see L-05 in audit doc.
23+
*
24+
* DO NOT AUDIT THIS FILE. Exclude from all automated tools and scope definitions.
25+
*
26+
* NEXUS ECONOMIC CONSTITUTION ENGINE — SOV-006
27+
* Defines protocol invariants that governance must not violate.
28+
* ============================================================================
29+
*/
1230

1331
interface IVaultManager {
1432
function collateralRatio() external view returns (uint256);
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
pragma solidity ^0.8.20;
3+
4+
// NEXUS Finance -- TimelockController Deploy Script (v1.1 -- Hardened)
5+
//
6+
// Purpose:
7+
// Deploy the OZ v5.6.0 TimelockController that will hold DEFAULT_ADMIN_ROLE
8+
// on all four NEXUS core contracts (NXUSDToken, OracleModule, VaultManager,
9+
// LiquidationEngine). The deployer EOA does not receive any role on the
10+
// timelock or on any core contract.
11+
//
12+
// Executor model -- OPEN EXECUTION:
13+
// executors = [address(0)]
14+
// Meaning: ANY address may call execute() on a proposal that has already
15+
// waited >= minDelay. This does NOT weaken the security model because:
16+
// - Only the Safe (PROPOSER_ROLE) can schedule proposals.
17+
// - The proposal content is locked at schedule time (cannot be modified).
18+
// - An executor cannot change WHAT executes -- only WHEN it executes.
19+
// - Open execution ensures execution liveness: if the Safe is temporarily
20+
// unavailable at execution time, any address (keeper bot, protocol team,
21+
// community member) can call execute() after the delay elapses.
22+
// - Without open execution, a single Safe unavailability event blocks
23+
// all governance actions indefinitely.
24+
// Residual risk: none beyond liveness improvement.
25+
//
26+
// Required env vars:
27+
// PRIVATE_KEY -- deployer EOA private key (needs ETH for gas only)
28+
// SAFE_MAINNET -- mainnet Safe 4-of-7 address (proposer + canceller only)
29+
//
30+
// What this script does:
31+
// 1. Deploys TimelockController with minDelay = 48 hours
32+
// 2. Wires Safe as PROPOSER_ROLE + CANCELLER_ROLE (not EXECUTOR_ROLE)
33+
// 3. Wires address(0) as EXECUTOR_ROLE (open execution)
34+
// 4. Sets admin = address(0) -- timelock is self-administered
35+
// 5. Asserts post-deploy role topology
36+
// 6. Logs the timelock address for runbook recording
37+
//
38+
// What this script does NOT do:
39+
// - Does not migrate DEFAULT_ADMIN_ROLE from Safe to Timelock on core contracts.
40+
// - Does not revoke any roles from the Safe.
41+
// Migration is a separate Safe MultiSend batch (see TIMELOCK_IMPLEMENTATION_PLAN.md).
42+
//
43+
// Run (dry-run -- ALWAYS run this first):
44+
// forge script script/DeployTimelockController.s.sol \
45+
// --rpc-url $ARBITRUM_ONE_RPC_URL --fork-block-number <N> --dry-run -vvv
46+
//
47+
// Confirm output: "POST-DEPLOY ASSERT : PASS", no reverts, gas < 2M.
48+
//
49+
// Run (live):
50+
// forge script script/DeployTimelockController.s.sol \
51+
// --rpc-url $ARBITRUM_ONE_RPC_URL --broadcast --verify -vvv
52+
53+
import {Script, console2} from "forge-std/Script.sol";
54+
import {TimelockController} from "openzeppelin-contracts/contracts/governance/TimelockController.sol";
55+
56+
contract DeployTimelockControllerScript is Script {
57+
/// @notice Global minimum delay for all governance operations: 48 hours.
58+
/// Covers the highest-risk function class (oracle change, minter grant,
59+
/// vault replacement). 24h-class functions (setRatios, setMaxDelay,
60+
/// setMaxSupply) inherit the same delay -- extra conservatism accepted.
61+
/// Per-function delays require a custom TimelockController (post-audit scope).
62+
uint256 public constant MIN_DELAY = 48 * 3600; // 172800 seconds
63+
64+
function run() external {
65+
address safe = vm.envAddress("SAFE_MAINNET");
66+
uint256 deployerPk = vm.envUint("PRIVATE_KEY");
67+
address deployer = vm.addr(deployerPk);
68+
69+
require(safe != address(0), "DEPLOY: SAFE_MAINNET is zero");
70+
require(deployer != address(0), "DEPLOY: deployer is zero");
71+
require(safe != deployer, "DEPLOY: safe == deployer - misconfiguration");
72+
73+
console2.log("=== NEXUS TIMELOCK PRE-DEPLOY ===");
74+
console2.log("Deployer EOA :", deployer);
75+
console2.log("Safe (proposer) :", safe);
76+
console2.log("Executor model : OPEN (address(0))");
77+
console2.log("minDelay (seconds) :", MIN_DELAY);
78+
console2.log("minDelay (hours) :", MIN_DELAY / 3600);
79+
80+
// Safe is sole PROPOSER (and auto-granted CANCELLER by OZ v5 constructor).
81+
// Safe does NOT hold EXECUTOR_ROLE.
82+
address[] memory proposers = new address[](1);
83+
proposers[0] = safe;
84+
85+
// address(0) in executors = open execution.
86+
// Any address can call execute() on a Ready proposal.
87+
address[] memory executors = new address[](1);
88+
executors[0] = address(0);
89+
90+
// admin = address(0): self-administered only. No external party holds
91+
// DEFAULT_ADMIN_ROLE on the TimelockController itself. Changing minDelay
92+
// or adding proposers/executors requires a 48h proposal through the timelock.
93+
address admin = address(0);
94+
95+
vm.startBroadcast(deployerPk);
96+
TimelockController timelock = new TimelockController(MIN_DELAY, proposers, executors, admin);
97+
vm.stopBroadcast();
98+
99+
// ---- Post-deploy assertions ------------------------------------------
100+
bytes32 PROPOSER_ROLE = timelock.PROPOSER_ROLE();
101+
bytes32 EXECUTOR_ROLE = timelock.EXECUTOR_ROLE();
102+
bytes32 CANCELLER_ROLE = timelock.CANCELLER_ROLE();
103+
bytes32 adminRole = bytes32(0); // DEFAULT_ADMIN_ROLE
104+
105+
// Safe: PROPOSER + CANCELLER only (not EXECUTOR)
106+
require(timelock.hasRole(PROPOSER_ROLE, safe), "ASSERT: Safe missing PROPOSER_ROLE");
107+
require(!timelock.hasRole(EXECUTOR_ROLE, safe), "ASSERT: Safe has EXECUTOR_ROLE -- unexpected");
108+
require(timelock.hasRole(CANCELLER_ROLE, safe), "ASSERT: Safe missing CANCELLER_ROLE");
109+
110+
// Open execution: address(0) holds EXECUTOR_ROLE
111+
require(timelock.hasRole(EXECUTOR_ROLE, address(0)), "ASSERT: address(0) missing EXECUTOR_ROLE");
112+
113+
// Self-administered: timelock holds its own DEFAULT_ADMIN_ROLE; no external admin
114+
require(timelock.hasRole(adminRole, address(timelock)), "ASSERT: timelock not self-holding DEFAULT_ADMIN_ROLE");
115+
require(!timelock.hasRole(adminRole, safe), "ASSERT: Safe has DEFAULT_ADMIN_ROLE on timelock");
116+
require(!timelock.hasRole(adminRole, deployer), "ASSERT: deployer has DEFAULT_ADMIN_ROLE on timelock");
117+
118+
// Deployer must NOT have any operational role
119+
require(!timelock.hasRole(PROPOSER_ROLE, deployer), "ASSERT: deployer has PROPOSER_ROLE");
120+
require(!timelock.hasRole(EXECUTOR_ROLE, deployer), "ASSERT: deployer has EXECUTOR_ROLE");
121+
122+
// Delay is correct
123+
require(timelock.getMinDelay() == MIN_DELAY, "ASSERT: wrong minDelay");
124+
125+
// ---- Output --------------------------------------------------------
126+
console2.log("=== NEXUS TIMELOCK DEPLOYED ===");
127+
console2.log("TimelockController :", address(timelock));
128+
console2.log("Safe (proposer) :", safe);
129+
console2.log("minDelay (seconds) :", timelock.getMinDelay());
130+
console2.log("minDelay (hours) :", timelock.getMinDelay() / 3600);
131+
console2.log("Safe PROPOSER :", timelock.hasRole(PROPOSER_ROLE, safe));
132+
console2.log("Safe EXECUTOR :", timelock.hasRole(EXECUTOR_ROLE, safe));
133+
console2.log("Safe CANCELLER :", timelock.hasRole(CANCELLER_ROLE, safe));
134+
console2.log("Open EXECUTOR (0x0) :", timelock.hasRole(EXECUTOR_ROLE, address(0)));
135+
console2.log("Self ADMIN :", timelock.hasRole(adminRole, address(timelock)));
136+
console2.log("Safe ADMIN :", timelock.hasRole(adminRole, safe));
137+
console2.log("Deployer ADMIN :", timelock.hasRole(adminRole, deployer));
138+
console2.log("POST-DEPLOY ASSERT :", "PASS");
139+
console2.log("");
140+
console2.log("NEXT: record address in MAINNET_DEPLOY_RUNBOOK.md");
141+
console2.log("NEXT: simulate Phase B migration batch on Tenderly fork");
142+
console2.log("NEXT: execute Safe migration batch (12 ops)");
143+
console2.log("NEXT: run TimelockMigrationVerify.s.sol to confirm");
144+
}
145+
}
Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
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

Comments
 (0)