From 83af1e873e7e743827ca7207a7ae22252f8b24cc Mon Sep 17 00:00:00 2001 From: Ben DiFrancesco Date: Fri, 3 Jul 2026 12:54:44 -0400 Subject: [PATCH] Implement a script to submit the Governor upgrade proposal --- AGENTS.md | 12 +- README.md | 40 ++++- ...oyGitcoinGovernorWithGuardianMainnet.s.sol | 10 +- script/ProposeGovernorUpgrade.s.sol | 145 ++++++++++++++++++ script/ProposeGovernorUpgradeMainnet.s.sol | 44 ++++++ src/interfaces/IGovernorBravo.sol | 95 ++++++++++++ 6 files changed, 333 insertions(+), 13 deletions(-) create mode 100644 script/ProposeGovernorUpgrade.s.sol create mode 100644 script/ProposeGovernorUpgradeMainnet.s.sol create mode 100644 src/interfaces/IGovernorBravo.sol diff --git a/AGENTS.md b/AGENTS.md index 7854c9b..4ff55c3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -212,10 +212,14 @@ CI enforces. Mainnet fork tests will need an `ETH_RPC_URL` (e.g. via `--fork-url ## Current status -- `GitcoinGovernorWithGuardian` and its two custom extensions are written (one commit in). -- **No tests, deploy scripts, or proposal scripts yet.** No Franchiser code yet. -- Up next: deploy scripts, proposal scripts, and the mainnet-fork test suite (see - [Deliverables](#deliverables) and [Testing strategy](#testing-strategy)). +- `GitcoinGovernorWithGuardian` and its two custom extensions are written. +- The Governor **deploy script** (`DeployGitcoinGovernorWithGuardian[Mainnet].s.sol`) and the + **upgrade proposal script** (`ProposeGovernorUpgrade[Mainnet].s.sol`) are in place. Both carry + `TODO`s to confirm with stakeholders before running (Governor name, vote extension, proposal + guardian; new Governor address, proposer, proposal text). +- **No tests yet.** No Franchiser code yet. +- Up next: the mainnet-fork test suite (see [Deliverables](#deliverables) and + [Testing strategy](#testing-strategy)), then the Franchiser workstream. - CI runs `forge build`, `forge test`, and `scopelint check`. Coverage and Slither jobs are scaffolded but commented out in `.github/workflows/ci.yml`. diff --git a/README.md b/README.md index 4470cb7..72860ba 100644 --- a/README.md +++ b/README.md @@ -37,8 +37,8 @@ scopelint check # verify formatting and conventions (also run in CI) - `AGENTS.md` — project context, architecture, and conventions for contributors and coding agents. - `foundry.toml` — Foundry build profiles and formatting configuration. -- `script/` — deployment scripts (see [Scripts](#scripts)). The Governor deploy script is in place; - governance-proposal and Franchiser scripts are still being built. +- `script/` — deployment and governance-proposal scripts (see [Scripts](#scripts)). The Governor + deploy and upgrade-proposal scripts are in place; Franchiser scripts are still being built. The test suite (`test/`) is still being built. @@ -71,9 +71,39 @@ forge script script/DeployGitcoinGovernorWithGuardianMainnet.s.sol:DeployGitcoin --verify ``` -> 🚧 **Still under development.** The governance-proposal scripts — Governor adoption, Franchiser -> deployment, and Franchiser delegation — are not yet available. Usage instructions will be -> documented here as they land. +### Propose the Governor upgrade + +`script/ProposeGovernorUpgrade.s.sol` holds the reusable proposal mechanics, and +`script/ProposeGovernorUpgradeMainnet.s.sol` supplies the mainnet configuration. Run by a delegate, +it submits a two-action proposal to the currently active Governor: the Timelock names the new +Governor as its pending admin (`setPendingAdmin`), and the new Governor claims the role +(`__acceptAdmin`). Before broadcasting, the script validates that both Governors are wired to the +same Timelock, that the old Governor is the Timelock's current admin, and that the proposer's +voting weight meets the proposal threshold. + +The new Governor's address, the proposer, and the final proposal text carry `TODO`s. The script +reverts until the first two are set — the proposal cannot be submitted before the new Governor is +deployed and a proposer is confirmed. + +Dry-run first to simulate the proposal and review the transaction it would send: + +```sh +forge script script/ProposeGovernorUpgradeMainnet.s.sol:ProposeGovernorUpgradeMainnet \ + --rpc-url "$ETH_RPC_URL" +``` + +Then broadcast as the proposer (using an encrypted keystore account set up with +`cast wallet import`): + +```sh +forge script script/ProposeGovernorUpgradeMainnet.s.sol:ProposeGovernorUpgradeMainnet \ + --rpc-url "$ETH_RPC_URL" \ + --account proposer \ + --broadcast +``` + +> 🚧 **Still under development.** The Franchiser scripts — deployment and delegation — are not yet +> available. Usage instructions will be documented here as they land. ## License diff --git a/script/DeployGitcoinGovernorWithGuardianMainnet.s.sol b/script/DeployGitcoinGovernorWithGuardianMainnet.s.sol index 46a3c52..9323230 100644 --- a/script/DeployGitcoinGovernorWithGuardianMainnet.s.sol +++ b/script/DeployGitcoinGovernorWithGuardianMainnet.s.sol @@ -1,10 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-only pragma solidity ^0.8.35; -// `run()` is inherited from the abstract base; scopelint's per-file `script` rule does not resolve -// the inherited entrypoint, so this concrete config opts out of that check. -// scopelint: ignore-script-file - import {ICompoundTimelock} from "@openzeppelin/contracts/vendor/compound/ICompoundTimelock.sol"; import {DeployGitcoinGovernorWithGuardian} from "script/DeployGitcoinGovernorWithGuardian.s.sol"; import {IComp} from "src/interfaces/IComp.sol"; @@ -43,6 +39,12 @@ contract DeployGitcoinGovernorWithGuardianMainnet is DeployGitcoinGovernorWithGu // at any lifecycle stage). address constant INITIAL_PROPOSAL_GUARDIAN = 0x57a8865cfB1eCEf7253c27da6B4BC3dAEE5Be518; + // Boilerplate override so this file declares the public `run()` that scopelint's script rule + // looks for; the mechanics all live in the base. + function run() public override { + super.run(); + } + function _getDeploymentParams() internal pure override returns (DeploymentParams memory) { return DeploymentParams({ name: GOVERNOR_NAME, diff --git a/script/ProposeGovernorUpgrade.s.sol b/script/ProposeGovernorUpgrade.s.sol new file mode 100644 index 0000000..cff774e --- /dev/null +++ b/script/ProposeGovernorUpgrade.s.sol @@ -0,0 +1,145 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.35; + +import {Script} from "forge-std/Script.sol"; +import {console2} from "forge-std/console2.sol"; +import {ICompoundTimelock} from "@openzeppelin/contracts/vendor/compound/ICompoundTimelock.sol"; +import {GitcoinGovernorWithGuardian} from "src/GitcoinGovernorWithGuardian.sol"; +import {IGovernorBravo} from "src/interfaces/IGovernorBravo.sol"; + +/// @notice Abstract base that holds the mechanics of proposing the Governor upgrade: a proposal, +/// submitted to the old (currently active) Governor, that transfers admin control of the DAO's +/// Timelock to the new `GitcoinGovernorWithGuardian`. The proposal carries two actions: +/// +/// 1. `timelock.setPendingAdmin(newGovernor)` — the Timelock, executing the passed proposal, +/// names the new Governor as its pending admin. +/// 2. `newGovernor.__acceptAdmin()` — the new Governor claims the admin role from the Timelock. +/// +/// A concrete contract supplies the configuration for a specific proposal by implementing +/// `_getProposalParams`. +abstract contract ProposeGovernorUpgrade is Script { + struct ProposalParams { + IGovernorBravo oldGovernor; + GitcoinGovernorWithGuardian newGovernor; + address proposer; + string description; + } + + uint256 public proposalId; + bool internal isLogging = true; + + function run() public virtual { + ProposalParams memory _params = _getProposalParams(); + _revertIfProposalParamsAreInvalid(_params); + + (address[] memory _targets, uint256[] memory _values, bytes[] memory _calldatas) = + _buildProposalActions(_params); + + _log("Proposing the Governor upgrade with:"); + _log(string.concat(" oldGovernor: ", vm.toString(address(_params.oldGovernor)))); + _log(string.concat(" newGovernor: ", vm.toString(address(_params.newGovernor)))); + _log(string.concat(" timelock: ", vm.toString(_params.oldGovernor.timelock()))); + _log(string.concat(" proposer: ", vm.toString(_params.proposer))); + _log(string.concat(" description: ", _params.description)); + + vm.startBroadcast(_params.proposer); + // BROADCAST: submit the upgrade proposal to the old Governor + _log("[1/1] Submitting the upgrade proposal to the old Governor"); + proposalId = _params.oldGovernor.propose(_targets, _values, _calldatas, _params.description); + vm.stopBroadcast(); + + _log(string.concat("Upgrade proposal submitted with id ", vm.toString(proposalId))); + } + + function disableLogging() public { + isLogging = false; + } + + function _getProposalParams() internal view virtual returns (ProposalParams memory); + + function _buildProposalActions(ProposalParams memory _params) + internal + view + returns (address[] memory _targets, uint256[] memory _values, bytes[] memory _calldatas) + { + _targets = new address[](2); + _values = new uint256[](2); + _calldatas = new bytes[](2); + + _targets[0] = _params.oldGovernor.timelock(); + _calldatas[0] = + abi.encodeCall(ICompoundTimelock.setPendingAdmin, (address(_params.newGovernor))); + + _targets[1] = address(_params.newGovernor); + _calldatas[1] = abi.encodeCall(_params.newGovernor.__acceptAdmin, ()); + } + + function _log(string memory _msg) internal view { + if (isLogging) { + console2.log(_msg); + } + } + + function _revertIfProposalParamsAreInvalid(ProposalParams memory _params) internal view { + if (address(_params.oldGovernor) == address(0)) { + revert( + "ProposeGovernorUpgrade: oldGovernor is the zero address; " + "set it to the address of the currently active Governor" + ); + } + if (address(_params.newGovernor) == address(0)) { + revert( + "ProposeGovernorUpgrade: newGovernor is the zero address; " + "set it to the address of the deployed GitcoinGovernorWithGuardian" + ); + } + if (_params.proposer == address(0)) { + revert( + "ProposeGovernorUpgrade: proposer is the zero address; " + "set it to the delegate submitting the proposal" + ); + } + if (bytes(_params.description).length == 0) { + revert( + "ProposeGovernorUpgrade: description is empty; " + "set it to the text of the upgrade proposal" + ); + } + + address _timelock = _params.oldGovernor.timelock(); + if (_params.newGovernor.timelock() != _timelock) { + revert( + string.concat( + "ProposeGovernorUpgrade: the new governor's timelock is ", + vm.toString(_params.newGovernor.timelock()), + " but the old governor's timelock is ", + vm.toString(_timelock), + "; both governors must be wired to the same timelock" + ) + ); + } + if (ICompoundTimelock(payable(_timelock)).admin() != address(_params.oldGovernor)) { + revert( + string.concat( + "ProposeGovernorUpgrade: the timelock's admin is ", + vm.toString(ICompoundTimelock(payable(_timelock)).admin()), + " but expected the old governor ", + vm.toString(address(_params.oldGovernor)), + "; the upgrade proposal must be submitted to the governor that controls the timelock" + ) + ); + } + uint256 _proposerVotes = _params.oldGovernor.getVotes(_params.proposer, block.number - 1); + if (_proposerVotes < _params.oldGovernor.proposalThreshold()) { + revert( + string.concat( + "ProposeGovernorUpgrade: the proposer's voting weight of ", + vm.toString(_proposerVotes), + " is below the old governor's proposal threshold of ", + vm.toString(_params.oldGovernor.proposalThreshold()), + "; the proposer must hold or be delegated at least the threshold" + ) + ); + } + } +} diff --git a/script/ProposeGovernorUpgradeMainnet.s.sol b/script/ProposeGovernorUpgradeMainnet.s.sol new file mode 100644 index 0000000..5da0626 --- /dev/null +++ b/script/ProposeGovernorUpgradeMainnet.s.sol @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.35; + +import {GitcoinGovernorWithGuardian} from "src/GitcoinGovernorWithGuardian.sol"; +import {IGovernorBravo} from "src/interfaces/IGovernorBravo.sol"; +import {ProposeGovernorUpgrade} from "script/ProposeGovernorUpgrade.s.sol"; + +/// @notice Mainnet configuration for the Governor upgrade proposal, submitted to the active +/// "GTC Governor Bravo" to transfer Timelock control to the new `GitcoinGovernorWithGuardian`. +contract ProposeGovernorUpgradeMainnet is ProposeGovernorUpgrade { + IGovernorBravo constant OLD_GOVERNOR = IGovernorBravo(0x9D4C63565D5618310271bF3F3c01b2954C1D1639); + + // TODO: Set to the GitcoinGovernorWithGuardian address once it is deployed to mainnet. The zero + // address makes this script revert until then, so the proposal cannot be submitted before the + // new Governor exists. + GitcoinGovernorWithGuardian constant NEW_GOVERNOR = + GitcoinGovernorWithGuardian(payable(address(0))); + + // TODO: Set to the delegate who will submit the proposal. They must hold or be delegated voting + // weight of at least the old Governor's proposal threshold. The zero address makes this script + // revert until a proposer is confirmed. + address constant PROPOSER = address(0); + + // TODO: Finalize the proposal text with Gitcoin stakeholders before proposing. This description + // is stored on-chain (hashed) and displayed by governance UIs like Tally. + string constant DESCRIPTION = "Upgrade the Gitcoin Governor: transfer Timelock admin rights from" + " the current Governor to the new GitcoinGovernorWithGuardian, which adds a proposal guardian," + " late-quorum protection, and a DAO-settable quorum."; + + // Boilerplate override so this file declares the public `run()` that scopelint's script rule + // looks for; the mechanics all live in the base. + function run() public override { + super.run(); + } + + function _getProposalParams() internal pure override returns (ProposalParams memory) { + return ProposalParams({ + oldGovernor: OLD_GOVERNOR, + newGovernor: NEW_GOVERNOR, + proposer: PROPOSER, + description: DESCRIPTION + }); + } +} diff --git a/src/interfaces/IGovernorBravo.sol b/src/interfaces/IGovernorBravo.sol new file mode 100644 index 0000000..31e9096 --- /dev/null +++ b/src/interfaces/IGovernorBravo.sol @@ -0,0 +1,95 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.35; + +import {IGovernor} from "@openzeppelin/contracts/governance/IGovernor.sol"; + +/// @title IGovernorBravo +/// @author [ScopeLift](https://scopelift.co) +/// @notice The subset of the ABI of Gitcoin's active Governor ("GTC Governor Bravo") that this +/// project uses to propose, vote on, and execute the proposal transferring Timelock control to the +/// upgraded Governor. The active Governor was built on OpenZeppelin Contracts v4.8.0 during the +/// DAO's 2023 upgrade. +/// @dev The v4.8.0 Governor's `ProposalState` enum has an identical layout to the v5 one, so this +/// interface reuses `IGovernor.ProposalState` from the pinned OpenZeppelin v5 library rather than +/// redeclaring it. +interface IGovernorBravo { + /// @notice The address of the Compound Timelock through which this Governor executes passed + /// proposals, and whose admin it is. + function timelock() external view returns (address); + + /// @notice The number of votes required to become a proposer. + function proposalThreshold() external view returns (uint256); + + /// @notice The voting weight an account held at a past block. + /// @param _account The address whose voting weight is queried. + /// @param _blockNumber The block number at which the weight is read. + /// @return The number of votes the account held at the given block. + function getVotes(address _account, uint256 _blockNumber) external view returns (uint256); + + /// @notice The current lifecycle state of a proposal. + /// @param _proposalId The identifier of the proposal to query. + /// @return The proposal's current state. + function state(uint256 _proposalId) external view returns (IGovernor.ProposalState); + + /// @notice The block at which a proposal's voting weight is snapshotted, i.e. the block after + /// which voting opens. + /// @param _proposalId The identifier of the proposal to query. + /// @return The proposal's snapshot block number. + function proposalSnapshot(uint256 _proposalId) external view returns (uint256); + + /// @notice The block at which a proposal's voting period ends. + /// @param _proposalId The identifier of the proposal to query. + /// @return The proposal's deadline block number. + function proposalDeadline(uint256 _proposalId) external view returns (uint256); + + /// @notice The timestamp at which a queued proposal becomes executable in the Timelock. + /// @param _proposalId The identifier of the proposal to query. + /// @return The proposal's eta timestamp, or zero if it is not queued. + function proposalEta(uint256 _proposalId) external view returns (uint256); + + /// @notice Creates a new proposal. The caller must hold voting weight of at least + /// `proposalThreshold` at the previous block. + /// @param _targets The addresses the proposal's actions call. + /// @param _values The ETH values sent with each action. + /// @param _calldatas The calldata each action is called with. + /// @param _description A human-readable description of the proposal. + /// @return The identifier of the newly created proposal. + function propose( + address[] memory _targets, + uint256[] memory _values, + bytes[] memory _calldatas, + string memory _description + ) external returns (uint256); + + /// @notice Casts a vote on a proposal. + /// @param _proposalId The identifier of the proposal voted on. + /// @param _support The vote type: 0 = Against, 1 = For, 2 = Abstain. + /// @return The voting weight cast. + function castVote(uint256 _proposalId, uint8 _support) external returns (uint256); + + /// @notice Queues a succeeded proposal's actions in the Timelock. + /// @param _targets The addresses the proposal's actions call. + /// @param _values The ETH values sent with each action. + /// @param _calldatas The calldata each action is called with. + /// @param _descriptionHash The keccak256 hash of the proposal's description. + /// @return The identifier of the queued proposal. + function queue( + address[] memory _targets, + uint256[] memory _values, + bytes[] memory _calldatas, + bytes32 _descriptionHash + ) external returns (uint256); + + /// @notice Executes a queued proposal once its Timelock eta has passed. + /// @param _targets The addresses the proposal's actions call. + /// @param _values The ETH values sent with each action. + /// @param _calldatas The calldata each action is called with. + /// @param _descriptionHash The keccak256 hash of the proposal's description. + /// @return The identifier of the executed proposal. + function execute( + address[] memory _targets, + uint256[] memory _values, + bytes[] memory _calldatas, + bytes32 _descriptionHash + ) external payable returns (uint256); +}