From 70fd0ae97853910870377622a8566cd03e282b62 Mon Sep 17 00:00:00 2001 From: jayesh yadav Date: Thu, 25 Jun 2026 12:53:27 +0530 Subject: [PATCH 1/2] feat(deploy): wire ConstituentGovernor and a single top-level owner The deploy now builds and wires the ConstituentGovernor as part of the standard flow, closing the gap where the vault was left on the seed-only setConstituents with no governor. Seed constituents, deploy the governor (owned by the final OWNER from construction since it needs no deploy-time owner calls), call setGovernor (which locks the seed setter), then hand the five deployer-owned contracts to OWNER. Result: one governance authority over registry, oracle, methodology, vault, excluded-address registry, and governor, with GUARDIAN the single guardian for both the oracle pause and the governor veto. All contracts are Ownable2Step, so OWNER must acceptOwnership on each; documented in the header. Verified end-to-end on a mainnet fork. --- script/Deploy.s.sol | 62 ++++++++++++++++++++++++++++++++++++++------- 1 file changed, 53 insertions(+), 9 deletions(-) diff --git a/script/Deploy.s.sol b/script/Deploy.s.sol index 024d2b1..bf41519 100644 --- a/script/Deploy.s.sol +++ b/script/Deploy.s.sol @@ -13,6 +13,7 @@ import { SupplyOracle } from "src/oracle/SupplyOracle.sol"; import { ISupplyOracle } from "src/interfaces/ISupplyOracle.sol"; import { IMethodology } from "src/interfaces/IMethodology.sol"; import { Rebalancer } from "src/rebalancer/Rebalancer.sol"; +import { ConstituentGovernor } from "src/governance/ConstituentGovernor.sol"; /** * @title Deploy @@ -20,19 +21,26 @@ import { Rebalancer } from "src/rebalancer/Rebalancer.sol"; * oracle stack, the asset registry, the market-cap methodology, the async * vault, and the CoW rebalancer. * - * The deployer is the temporary owner of every Ownable contract so it can do - * the owner-gated wiring (feed registration, constituents, weight params, - * oracle params, rebalancer wiring, relayer approvals) inside the broadcast. - * Ownership is then transferred to OWNER (a multisig in production); the - * keeper, guardian, and reporter roles are set to their final holders directly. + * The deployer is the temporary owner of every contract that needs owner-gated + * wiring (feed registration, constituents, weight params, oracle params, + * rebalancer wiring, relayer approvals) inside the broadcast. Afterwards every + * such contract is handed to a single top-level OWNER (a multisig, or a + * TimelockController, in production), so there is one governance authority over + * the whole system. Every contract is Ownable2Step, so OWNER must call + * acceptOwnership() on each to complete the handoff. The ConstituentGovernor + * needs no deploy-time owner calls, so it is owned by OWNER from construction. + * + * GUARDIAN is the single guardian for both the supply oracle (pause) and the + * constituent governor (veto). KEEPER and REPORTER are operational roles set to + * their final holders directly. * * Required env: * PRIVATE_KEY deployer key (also the transient owner during wiring) * * Optional env (default to the deployer / mainnet canonical addresses): - * OWNER final owner / governance multisig (default: deployer) + * OWNER single top-level owner / governance multisig (default: deployer) * KEEPER settlement + scheduled-rebalance keeper (default: deployer) - * GUARDIAN supply-oracle pause guardian (default: deployer) + * GUARDIAN supply-oracle pause + governor veto guardian (default: deployer) * REPORTER initial free-float factor reporter (default: deployer) * USDC base asset (default: mainnet USDC) * SETTLEMENT GPv2Settlement (default: mainnet GPv2) @@ -85,6 +93,19 @@ contract Deploy is Script { uint256 internal constant MAX_FACTOR_DELTA_BPS = 1000; // 10% per-commit clamp uint256 internal constant MIN_COMMIT_INTERVAL = 1 hours; + // --- Constituent governance params ------------------------------------ + + uint256 internal constant ADD_DELAY = 2 days; + uint256 internal constant FORCED_REMOVE_DELAY = 6 hours; // fast: only on proven failure + uint256 internal constant DISCRETIONARY_REMOVE_DELAY = 7 days; // slow: healthy-asset removal + uint256 internal constant MIN_CONSTITUENT_USD = 100_000_000; // $100M whole-USD size floor + // Floor on the count after a removal. For a real index set this to + // ceil(WAD / capTarget) so capping stays feasible (e.g. 4 at a 25% cap) and + // seed at least that many constituents; the two-asset example relaxes it. + uint256 internal constant MIN_CONSTITUENT_COUNT = 2; + uint256 internal constant MAX_CHANGES_PER_WINDOW = 3; + uint256 internal constant CHANGE_WINDOW = 7 days; + struct Constituent { address token; address feed; @@ -98,6 +119,7 @@ contract Deploy is Script { MarketCapMethodology methodology; IndexVault vault; Rebalancer rebalancer; + ConstituentGovernor governor; } function run() external returns (Deployment memory d) { @@ -179,8 +201,29 @@ contract Deploy is Script { } d.vault.approveRelayer(usdc); - // 7. Hand every Ownable contract to the final owner. Done last so all - // owner-gated wiring above could run under the deployer. + // 7. Constituent governor. It needs no deploy-time owner calls, so it is + // owned by the final owner from construction. Wiring it into the vault + // locks setConstituents to its pre-governance seeding role above, so this + // must run after the seed. + d.governor = new ConstituentGovernor( + d.vault, + d.registry, + ISupplyOracle(address(d.supplyOracle)), + guardian, + owner, + ADD_DELAY, + FORCED_REMOVE_DELAY, + DISCRETIONARY_REMOVE_DELAY, + MIN_CONSTITUENT_USD, + MIN_CONSTITUENT_COUNT, + MAX_CHANGES_PER_WINDOW, + CHANGE_WINDOW + ); + d.vault.setGovernor(address(d.governor)); + + // 8. Hand every deployer-owned contract to the single top-level owner. + // Done last so all owner-gated wiring above could run under the deployer. + // Ownable2Step: the new owner must call acceptOwnership() on each. if (owner != deployer) { d.excluded.transferOwnership(owner); d.supplyOracle.transferOwnership(owner); @@ -198,6 +241,7 @@ contract Deploy is Script { console2.log("MarketCapMethodology ", address(d.methodology)); console2.log("IndexVault ", address(d.vault)); console2.log("Rebalancer ", address(d.rebalancer)); + console2.log("ConstituentGovernor ", address(d.governor)); } /// @dev The starting basket. Edit here (or fork this script per network) to From 2f1b614234673a982e4ef8b3133dbcc92f4d3774 Mon Sep 17 00:00:00 2001 From: jayesh yadav Date: Thu, 25 Jun 2026 19:12:06 +0530 Subject: [PATCH 2/2] feat(deploy): own methodology by a TimelockController for delayed weight params setWeightParams moves every weight in the index the instant it is called and had no delay. Deploy a dedicated OZ TimelockController (proposer + executor = OWNER, self-administered with admin = address(0)) and own the methodology by it from construction, so a weight-param change is a delayed, publicly visible operation the multisig schedules through the timelock. The methodology ships with the intended defaults (25% cap target, 30% trigger, 1bp floor), so no deploy-time owner call is needed and there is no Ownable2Step accept dance for it; to change them, schedule setWeightParams through the timelock. Operational knobs on the other contracts stay on the fast multisig path. Verified end-to-end on a mainnet fork. --- script/Deploy.s.sol | 51 +++++++++++++++++++++++++++++++++------------ 1 file changed, 38 insertions(+), 13 deletions(-) diff --git a/script/Deploy.s.sol b/script/Deploy.s.sol index bf41519..5210624 100644 --- a/script/Deploy.s.sol +++ b/script/Deploy.s.sol @@ -14,6 +14,7 @@ import { ISupplyOracle } from "src/interfaces/ISupplyOracle.sol"; import { IMethodology } from "src/interfaces/IMethodology.sol"; import { Rebalancer } from "src/rebalancer/Rebalancer.sol"; import { ConstituentGovernor } from "src/governance/ConstituentGovernor.sol"; +import { TimelockController } from "@openzeppelin/contracts/governance/TimelockController.sol"; /** * @title Deploy @@ -24,11 +25,18 @@ import { ConstituentGovernor } from "src/governance/ConstituentGovernor.sol"; * The deployer is the temporary owner of every contract that needs owner-gated * wiring (feed registration, constituents, weight params, oracle params, * rebalancer wiring, relayer approvals) inside the broadcast. Afterwards every - * such contract is handed to a single top-level OWNER (a multisig, or a - * TimelockController, in production), so there is one governance authority over - * the whole system. Every contract is Ownable2Step, so OWNER must call - * acceptOwnership() on each to complete the handoff. The ConstituentGovernor - * needs no deploy-time owner calls, so it is owned by OWNER from construction. + * such contract is handed to a single top-level OWNER (a multisig in + * production), so there is one governance authority over the whole system. + * Every contract is Ownable2Step, so OWNER must call acceptOwnership() on each + * to complete the handoff. The ConstituentGovernor needs no deploy-time owner + * calls, so it is owned by OWNER from construction. + * + * The MarketCapMethodology is the exception: its only privileged power is + * setWeightParams, which moves every weight, so it is owned by a dedicated + * TimelockController (proposer and executor = OWNER) rather than OWNER directly. + * A weight-param change is therefore a delayed, publicly visible operation the + * multisig schedules through the timelock, while operational knobs on the other + * contracts stay on the fast multisig path. * * GUARDIAN is the single guardian for both the supply oracle (pause) and the * constituent governor (veto). KEEPER and REPORTER are operational roles set to @@ -69,11 +77,13 @@ contract Deploy is Script { uint48 internal constant USDC_HEARTBEAT = 1 days; // USDC/USD ticks slowly uint48 internal constant ASSET_HEARTBEAT = 1 hours; // BTC/USD, ETH/USD - // --- Methodology params (WAD) ----------------------------------------- + // --- Methodology ----------------------------------------------------- - uint256 internal constant CAP_TARGET_WAD = 0.25e18; // cap to 25% - uint256 internal constant CAP_TRIGGER_WAD = 0.3e18; // recap only above 30% actual - uint256 internal constant FLOOR_WAD = 1e14; // prune sub-1bp dust positions + // The methodology ships with sensible weight-param defaults (25% cap + // target, 30% cap trigger, 1bp floor). It is owned by a TimelockController, + // so changing those params is a delayed, publicly visible operation the + // multisig schedules through the timelock rather than a silent setter call. + uint256 internal constant WEIGHT_PARAMS_TIMELOCK_DELAY = 2 days; // --- Rebalancer trigger params ---------------------------------------- @@ -120,6 +130,7 @@ contract Deploy is Script { IndexVault vault; Rebalancer rebalancer; ConstituentGovernor governor; + TimelockController weightTimelock; } function run() external returns (Deployment memory d) { @@ -145,6 +156,15 @@ contract Deploy is Script { vm.startBroadcast(pk); + // 0. Governance timelock for security-relevant config (the methodology's + // weight params). The owner multisig is both proposer and executor, and + // the timelock self-administers (admin = address(0)), so role changes + // themselves must pass through the delay. + address[] memory timelockRoles = new address[](1); + timelockRoles[0] = owner; + d.weightTimelock = + new TimelockController(WEIGHT_PARAMS_TIMELOCK_DELAY, timelockRoles, timelockRoles, address(0)); + // 1. Supply-oracle stack. The deployer is the transient owner. d.excluded = new ExcludedAddressRegistry(deployer, EXCLUDED_DELAY); d.supplyOracle = new SupplyOracle(d.excluded, guardian, deployer); @@ -166,9 +186,12 @@ contract Deploy is Script { d.registry.registerAsset(constituents[i].token, constituents[i].feed, constituents[i].heartbeat); } - // 3. Methodology over the registry + oracle. - d.methodology = new MarketCapMethodology(d.registry, ISupplyOracle(address(d.supplyOracle)), deployer); - d.methodology.setWeightParams(CAP_TARGET_WAD, CAP_TRIGGER_WAD, FLOOR_WAD); + // 3. Methodology over the registry + oracle, owned by the timelock from + // construction so weight-param changes carry a delay. No deploy-time + // owner call is needed because the built-in defaults are the intended + // values; to change them, schedule setWeightParams through the timelock. + d.methodology = + new MarketCapMethodology(d.registry, ISupplyOracle(address(d.supplyOracle)), address(d.weightTimelock)); // 4. Vault, then install the constituent set. d.vault = new IndexVault(IERC20(usdc), d.registry, keeper, deployer); @@ -224,11 +247,12 @@ contract Deploy is Script { // 8. Hand every deployer-owned contract to the single top-level owner. // Done last so all owner-gated wiring above could run under the deployer. // Ownable2Step: the new owner must call acceptOwnership() on each. + // The methodology is already owned by the timelock; the rest go to the + // operational multisig. if (owner != deployer) { d.excluded.transferOwnership(owner); d.supplyOracle.transferOwnership(owner); d.registry.transferOwnership(owner); - d.methodology.transferOwnership(owner); d.vault.transferOwnership(owner); } @@ -242,6 +266,7 @@ contract Deploy is Script { console2.log("IndexVault ", address(d.vault)); console2.log("Rebalancer ", address(d.rebalancer)); console2.log("ConstituentGovernor ", address(d.governor)); + console2.log("WeightTimelock ", address(d.weightTimelock)); } /// @dev The starting basket. Edit here (or fork this script per network) to