Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down
40 changes: 35 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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

Expand Down
10 changes: 6 additions & 4 deletions script/DeployGitcoinGovernorWithGuardianMainnet.s.sol
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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.
Comment thread
apbendi marked this conversation as resolved.
function run() public override {
super.run();
}

function _getDeploymentParams() internal pure override returns (DeploymentParams memory) {
return DeploymentParams({
name: GOVERNOR_NAME,
Expand Down
145 changes: 145 additions & 0 deletions script/ProposeGovernorUpgrade.s.sol
Original file line number Diff line number Diff line change
@@ -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"
)
);
}
}
}
44 changes: 44 additions & 0 deletions script/ProposeGovernorUpgradeMainnet.s.sol
Original file line number Diff line number Diff line change
@@ -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
});
}
}
95 changes: 95 additions & 0 deletions src/interfaces/IGovernorBravo.sol
Original file line number Diff line number Diff line change
@@ -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);
}