diff --git a/.env.template b/.env.template new file mode 100644 index 0000000..3812034 --- /dev/null +++ b/.env.template @@ -0,0 +1,4 @@ +# RPC endpoint for an Ethereum mainnet archive node, used by the mainnet fork tests and for +# running the deploy and proposal scripts. Copy this file to `.env` and fill in the value; `.env` +# is gitignored and the URL (which typically embeds an API key) must be kept secret. +MAINNET_RPC_URL= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c74ff20..2de8e0e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,6 +26,10 @@ jobs: test: runs-on: ubuntu-latest + env: + # The mainnet fork tests read this RPC URL via the `mainnet` alias in foundry.toml. Set the + # MAINNET_RPC_URL secret in the repository settings. + MAINNET_RPC_URL: ${{ secrets.MAINNET_RPC_URL }} steps: - uses: actions/checkout@v3 @@ -35,51 +39,50 @@ jobs: - name: Run tests run: forge test - # TODO: turn back on when the tests are implemented - # coverage: - # runs-on: ubuntu-latest - # env: - # FOUNDRY_PROFILE: coverage - # steps: - # - uses: actions/checkout@v3 + coverage: + runs-on: ubuntu-latest + env: + FOUNDRY_PROFILE: coverage + MAINNET_RPC_URL: ${{ secrets.MAINNET_RPC_URL }} + steps: + - uses: actions/checkout@v3 - # - name: Install Foundry - # uses: foundry-rs/foundry-toolchain@v1 - - # - name: Run coverage - # run: forge coverage --report summary --report lcov - - # # To ignore coverage for certain directories modify the paths in this step as needed. The - # # below default ignores coverage results for the test and script directories. Alternatively, - # # to include coverage in all directories, comment out this step. Note that because this - # # filtering applies to the lcov file, the summary table generated in the previous step will - # # still include all files and directories. - # # The `--rc lcov_branch_coverage=1` part keeps branch info in the filtered report, since lcov - # # defaults to removing branch info. - # - name: Filter directories - # run: | - # sudo apt update && sudo apt install -y lcov - # lcov --remove lcov.info 'test/*' 'script/*' --output-file lcov.info --rc lcov_branch_coverage=1 - - # # This step posts a detailed coverage report as a comment and deletes previous comments on - # # each push. The below step is used to fail coverage if the specified coverage threshold is - # # not met. The below step can post a comment (when it's `github-token` is specified) but it's - # # not as useful, and this action cannot fail CI based on a minimum coverage threshold, which - # # is why we use both in this way. - # - name: Post coverage report - # if: github.event_name == 'pull_request' # This action fails when ran outside of a pull request. - # uses: romeovs/lcov-reporter-action@v0.3.1 - # with: - # delete-old-comments: true - # lcov-file: ./lcov.info - # github-token: ${{ secrets.GITHUB_TOKEN }} # Adds a coverage summary comment to the PR. + - name: Install Foundry + uses: foundry-rs/foundry-toolchain@v1 - # - name: Verify minimum coverage - # uses: zgosalvez/github-actions-report-lcov@v2 - # with: - # coverage-files: ./lcov.info - # # TODO: bump this back up once tests are implemented. - # minimum-coverage: 0 # Set coverage threshold. + - name: Run coverage + run: forge coverage --report summary --report lcov + + # To ignore coverage for certain directories modify the paths in this step as needed. The + # below default ignores coverage results for the test and script directories. Alternatively, + # to include coverage in all directories, comment out this step. Note that because this + # filtering applies to the lcov file, the summary table generated in the previous step will + # still include all files and directories. + # The `--rc lcov_branch_coverage=1` part keeps branch info in the filtered report, since lcov + # defaults to removing branch info. + - name: Filter directories + run: | + sudo apt update && sudo apt install -y lcov + lcov --remove lcov.info 'test/*' 'script/*' --output-file lcov.info --rc lcov_branch_coverage=1 + + # This step posts a detailed coverage report as a comment and deletes previous comments on + # each push. The below step is used to fail coverage if the specified coverage threshold is + # not met. The below step can post a comment (when it's `github-token` is specified) but it's + # not as useful, and this action cannot fail CI based on a minimum coverage threshold, which + # is why we use both in this way. + - name: Post coverage report + if: github.event_name == 'pull_request' # This action fails when ran outside of a pull request. + uses: romeovs/lcov-reporter-action@v0.3.1 + with: + delete-old-comments: true + lcov-file: ./lcov.info + github-token: ${{ secrets.GITHUB_TOKEN }} # Adds a coverage summary comment to the PR. + + - name: Verify minimum coverage + uses: zgosalvez/github-actions-report-lcov@v2 + with: + coverage-files: ./lcov.info + minimum-coverage: 100 # Set coverage threshold. lint: runs-on: ubuntu-latest diff --git a/AGENTS.md b/AGENTS.md index 4ff55c3..df77b5b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -186,7 +186,9 @@ Profiles (see `foundry.toml`): Keep the **default** and **ci** solc settings in sync — they are the production build settings; never change solc config for `ci` alone. Use `scopelint`, not bare `forge fmt`; it's a superset and is what -CI enforces. Mainnet fork tests will need an `ETH_RPC_URL` (e.g. via `--fork-url`). +CI enforces. The mainnet fork tests read the `mainnet` RPC alias from `foundry.toml`, backed by +`MAINNET_RPC_URL` in `.env` (copy `.env.template`; the URL embeds an API key and must stay +secret). CI supplies it via the `MAINNET_RPC_URL` repository secret. ## Conventions @@ -217,9 +219,19 @@ CI enforces. Mainnet fork tests will need an `ETH_RPC_URL` (e.g. via `--fork-url **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. +- The **mainnet fork integration suite** (`test/*.integration.t.sol`) is in place: it deploys the + new Governor with the real deploy script, submits the upgrade proposal with the real proposal + script, and exercises the upgrade lifecycle, post-upgrade governance, quorum behavior + (settable + late-quorum), and the Proposal Guardian. Shared helpers live in `test/helpers/`; + each suite has a `…MainnetScript` provenance concrete, with room for a `…MainnetDeployed` + concrete after the real deployment. Proposals are voted through by an electorate of **real + delegates** whose live weights are read from the fork in `setUp`. The suite pins `FORK_BLOCK` + in `test/helpers/GitcoinGovernorUpgradeTestBase.sol` — when bumping it, re-verify the + `PROPOSER` delegate still clears the proposal threshold and the electorate still clears quorum + (`setUp` asserts both weights loudly, and quorum-boundary tests assert their own weight + preconditions). +- No Franchiser code yet. +- Up next: the Franchiser workstream (see [Deliverables](#deliverables)). - 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 72860ba..0fdcb7e 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,10 @@ forge test # run the test suite FOUNDRY_PROFILE=lite forge test # faster local iteration (optimizer off) ``` +The tests fork Ethereum mainnet, so they need an archive-node RPC endpoint: copy `.env.template` +to `.env` and set `MAINNET_RPC_URL`. The URL typically embeds an API key — keep it secret (`.env` +is gitignored). CI supplies it through the `MAINNET_RPC_URL` repository secret. + Formatting and linting use [scopelint](https://github.com/ScopeLift/scopelint), a superset of `forge fmt`: @@ -39,8 +43,8 @@ scopelint check # verify formatting and conventions (also run in CI) - `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. +- `test/` — mainnet fork integration tests simulating the full upgrade and exercising the upgraded + Governor (see [Testing](#testing)). ## Scripts @@ -58,14 +62,14 @@ before broadcasting: ```sh forge script script/DeployGitcoinGovernorWithGuardianMainnet.s.sol:DeployGitcoinGovernorWithGuardianMainnet \ - --rpc-url "$ETH_RPC_URL" + --rpc-url "$MAINNET_RPC_URL" ``` Then broadcast and verify (using an encrypted keystore account set up with `cast wallet import`): ```sh forge script script/DeployGitcoinGovernorWithGuardianMainnet.s.sol:DeployGitcoinGovernorWithGuardianMainnet \ - --rpc-url "$ETH_RPC_URL" \ + --rpc-url "$MAINNET_RPC_URL" \ --account deployer \ --broadcast \ --verify @@ -89,7 +93,7 @@ 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" + --rpc-url "$MAINNET_RPC_URL" ``` Then broadcast as the proposer (using an encrypted keystore account set up with @@ -97,7 +101,7 @@ Then broadcast as the proposer (using an encrypted keystore account set up with ```sh forge script script/ProposeGovernorUpgradeMainnet.s.sol:ProposeGovernorUpgradeMainnet \ - --rpc-url "$ETH_RPC_URL" \ + --rpc-url "$MAINNET_RPC_URL" \ --account proposer \ --broadcast ``` @@ -105,6 +109,30 @@ forge script script/ProposeGovernorUpgradeMainnet.s.sol:ProposeGovernorUpgradeMa > 🚧 **Still under development.** The Franchiser scripts — deployment and delegation — are not yet > available. Usage instructions will be documented here as they land. +## Testing + +The integration tests (`test/*.integration.t.sol`) run against a fork of Ethereum mainnet pinned +to a fixed block, and simulate the entire upgrade the way it will actually happen: the real deploy +script deploys the new Governor onto the fork, the real proposal script submits the upgrade +proposal to the currently active Governor, and delegates vote it through to execution — after +which the suites exercise the upgraded Governor in place: + +- `GovernorUpgradeProposal` — the upgrade proposal's lifecycle on the active Governor: passing it + hands the Timelock to the new Governor; defeating it leaves the current Governor in control. +- `PostUpgradeGovernance` — day-to-day governance after adoption: proposals moving ETH and tokens + held by the Timelock, updating the Governor's own settings, fractional voting, and Timelock + expiry. +- `PostUpgradeQuorumBehavior` — the DAO adjusting its own quorum, and the late-quorum protection + extending voting when quorum is reached near the deadline. +- `PostUpgradeProposalGuardian` — the Proposal Guardian cancelling proposals at every cancelable + lifecycle stage, the limits of that power, and the DAO replacing the guardian. + +Each suite is written against an abstract base that leaves *how the system comes into being* to a +small concrete contract at the bottom of the file. Today each file has a `…MainnetScript` concrete +that deploys via the real deploy script; once the new Governor is live on mainnet, a +`…MainnetDeployed` concrete pointing at the deployed address can rerun the same suites as a +post-deployment acceptance check. + ## License This project is licensed under the [GNU Affero General Public License v3.0](./LICENSE), with the diff --git a/foundry.toml b/foundry.toml index bffe1f4..aae3860 100644 --- a/foundry.toml +++ b/foundry.toml @@ -24,6 +24,9 @@ # Speed up compilation and tests during development. optimizer = false +[rpc_endpoints] + mainnet = "${MAINNET_RPC_URL}" + [fmt] bracket_spacing = false int_types = "long" diff --git a/test/GovernorUpgradeProposal.integration.t.sol b/test/GovernorUpgradeProposal.integration.t.sol new file mode 100644 index 0000000..2ef09e7 --- /dev/null +++ b/test/GovernorUpgradeProposal.integration.t.sol @@ -0,0 +1,159 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.35; + +import {IGovernor} from "@openzeppelin/contracts/governance/IGovernor.sol"; +import {GitcoinGovernorWithGuardian} from "src/GitcoinGovernorWithGuardian.sol"; +import {GitcoinGovernorUpgradeTestBase} from "test/helpers/GitcoinGovernorUpgradeTestBase.sol"; + +// Exercises the upgrade itself: the new Governor is deployed by the real deploy script, and the +// upgrade proposal — submitted to the old Governor by the real proposal script — is walked +// through passing, failing, and post-upgrade outcomes for control of the Timelock. +abstract contract GovernorUpgradeProposalTest is GitcoinGovernorUpgradeTestBase { + function test_DeploysTheNewGovernorWithTheMainnetConfiguration() external view { + assertEq(governor.name(), "GTC Governor Bravo"); + assertEq(address(governor.token()), address(GTC_TOKEN)); + assertEq(governor.timelock(), address(TIMELOCK)); + // These values mirror the active Governor, read from mainnet when the tests were written. + assertEq(governor.votingDelay(), 13_140); + assertEq(governor.votingPeriod(), 40_320); + assertEq(governor.proposalThreshold(), 150_000e18); + assertEq(governor.quorum(block.number), QUORUM); + assertEq(governor.lateQuorumVoteExtension(), VOTE_EXTENSION); + // The placeholder guardian from the mainnet deploy config; update this assertion when the + // security-council guardian address is confirmed. + assertEq(governor.proposalGuardian(), address(TIMELOCK)); + assertEq( + governor.COUNTING_MODE(), "support=bravo,fractional&quorum=for,abstain¶ms=fractional" + ); + assertEq(governor.CLOCK_MODE(), "mode=blocknumber&from=default"); + assertEq(governor.clock(), block.number); + } + + function test_GivenProposalRequiresQueuingThroughTheTimelock() external { + // The new Governor executes exclusively through the Compound Timelock, so every proposal + // reports that it needs queuing. The Governor's proposalNeedsQueuing override exists only to + // resolve inheritance ambiguity; this pins the behavior it preserves. + ProposalDetails memory _proposal = + _buildGtcSendProposal(makeAddr("receiver"), 1000e18, "A proposal that must be queued"); + _submitProposal(_proposal); + assertTrue(governor.proposalNeedsQueuing(_proposal.id)); + + // The answer is structural rather than per-proposal: it holds even for ids no proposal has. + assertTrue(governor.proposalNeedsQueuing(type(uint256).max)); + } + + function test_SubmitsTheUpgradeProposalWithTheExpectedActions() external { + _submitUpgradeProposal(); + + // The id the old Governor assigned matches the id computed from the actions the proposal is + // expected to carry, proving the script proposed exactly the setPendingAdmin + __acceptAdmin + // pair targeting this deployment. + assertEq(upgradeProposalId, _upgradeProposalDetails().id); + assertEq(OLD_GOVERNOR.state(upgradeProposalId), IGovernor.ProposalState.Pending); + assertGt(OLD_GOVERNOR.proposalSnapshot(upgradeProposalId), block.number); + } + + function test_PassedUpgradeProposalTransfersTimelockControlToTheNewGovernor() external { + _submitUpgradeProposal(); + + // The electorate passes the proposal. + _passUpgradeProposal(); + assertEq(OLD_GOVERNOR.state(upgradeProposalId), IGovernor.ProposalState.Succeeded); + + // Queuing places both upgrade actions in the Timelock. + _queueUpgradeProposal(); + assertEq(OLD_GOVERNOR.state(upgradeProposalId), IGovernor.ProposalState.Queued); + ProposalDetails memory _proposal = _upgradeProposalDetails(); + uint256 _eta = OLD_GOVERNOR.proposalEta(upgradeProposalId); + for (uint256 _index = 0; _index < _proposal.targets.length; _index += 1) { + assertTrue( + TIMELOCK.queuedTransactions( + _timelockTransactionHash( + _proposal.targets[_index], _proposal.values[_index], _proposal.calldatas[_index], _eta + ) + ) + ); + } + + // After the Timelock delay elapses, execution hands the admin role to the new Governor. + _jumpPastUpgradeProposalEta(); + _executeUpgradeProposal(); + assertEq(OLD_GOVERNOR.state(upgradeProposalId), IGovernor.ProposalState.Executed); + assertEq(TIMELOCK.admin(), address(governor)); + assertEq(TIMELOCK.pendingAdmin(), address(0)); + } + + function test_DefeatedUpgradeProposalLeavesTheOldGovernorGoverning() external { + _submitUpgradeProposal(); + + // The electorate votes the upgrade down. + _defeatUpgradeProposal(); + assertEq(OLD_GOVERNOR.state(upgradeProposalId), IGovernor.ProposalState.Defeated); + vm.expectRevert("Governor: proposal not successful"); + _queueUpgradeProposal(); + assertEq(TIMELOCK.admin(), address(OLD_GOVERNOR)); + + // The old Governor still governs: a follow-up proposal moves ETH the Timelock really holds. + address _receiver = makeAddr("receiver"); + uint256 _amount = 1 ether; + uint256 _initialTimelockBalance = address(TIMELOCK).balance; + ProposalDetails memory _ethSend = + _buildProposal(_receiver, _amount, "", "Send ETH via the old Governor"); + _submitAndPassProposalOnOldGovernor(_ethSend); + _queueAndExecuteProposalOnOldGovernor(_ethSend); + assertEq(_receiver.balance, _amount); + assertEq(address(TIMELOCK).balance, _initialTimelockBalance - _amount); + } + + function test_OldGovernorCannotQueueProposalsAfterTheUpgradeExecutes() external { + _upgradeToNewGovernor(); + + // A proposal still passes a vote on the old Governor, but the Timelock no longer accepts its + // instructions. + ProposalDetails memory _proposal = + _buildGtcSendProposal(makeAddr("receiver"), 1000e18, "Send GTC via the deposed Governor"); + _submitAndPassProposalOnOldGovernor(_proposal); + vm.expectRevert("Timelock::queueTransaction: Call must come from admin."); + OLD_GOVERNOR.queue( + _proposal.targets, + _proposal.values, + _proposal.calldatas, + keccak256(bytes(_proposal.description)) + ); + } + + function test_VotesOnTheOldGovernorDoNotCarryToTheNewGovernor() external { + _upgradeToNewGovernor(); + + // The same action is proposed on both governors. Their proposal ids are computed identically, + // so this exercises that state is fully separate between the two contracts. + address _receiver = makeAddr("receiver"); + ProposalDetails memory _proposal = + _buildGtcSendProposal(_receiver, 1000e18, "Send GTC after the upgrade"); + _submitProposalToOldGovernor(_proposal); + _submitProposal(_proposal); + + // Pass the proposal on the old Governor while defeating it on the new one. + _jumpToProposalActive(_proposal.id); + _delegatesCastVotesOnOldGovernor(_proposal.id, FOR); + _delegatesCastVotes(_proposal.id, AGAINST); + _jumpPastProposalDeadline(_proposal.id); + + // The new Governor's proposal is defeated by its own tally, unaffected by the old one's. + assertEq(OLD_GOVERNOR.state(_proposal.id), IGovernor.ProposalState.Succeeded); + assertEq(governor.state(_proposal.id), IGovernor.ProposalState.Defeated); + vm.expectPartialRevert(IGovernor.GovernorUnexpectedProposalState.selector); + _queueProposal(_proposal); + assertEq(GTC_TOKEN.balanceOf(_receiver), 0); + } +} + +contract GovernorUpgradeProposalMainnetScript is GovernorUpgradeProposalTest { + function _setUpNetwork() internal override { + _createMainnetFork(); + } + + function _fetchOrDeploySystem() internal override returns (GitcoinGovernorWithGuardian) { + return _deployGovernorWithMainnetScript(); + } +} diff --git a/test/PostUpgradeGovernance.integration.t.sol b/test/PostUpgradeGovernance.integration.t.sol new file mode 100644 index 0000000..3c3e63f --- /dev/null +++ b/test/PostUpgradeGovernance.integration.t.sol @@ -0,0 +1,301 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.35; + +import {IGovernor} from "@openzeppelin/contracts/governance/IGovernor.sol"; +import {GitcoinGovernorWithGuardian} from "src/GitcoinGovernorWithGuardian.sol"; +import {GitcoinGovernorPostUpgradeTestBase} from "test/helpers/GitcoinGovernorUpgradeTestBase.sol"; + +// Exercises day-to-day governance on the new Governor after the upgrade has executed: passing +// and defeating proposals that move treasury assets held by the Timelock, updating the +// Governor's own settings, fractional and by-signature voting, and Timelock expiry. +abstract contract PostUpgradeGovernanceTest is GitcoinGovernorPostUpgradeTestBase { + /// forge-config: default.fuzz.runs = 25 + /// forge-config: ci.fuzz.runs = 25 + /// forge-config: lite.fuzz.runs = 5 + function testFuzz_PassedProposalSendsGtcHeldByTheTimelock(uint256 _amount) external { + // The proposal draws on the GTC the Timelock genuinely holds at the fork block. + uint256 _initialTimelockBalance = GTC_TOKEN.balanceOf(address(TIMELOCK)); + _amount = bound(_amount, 1, _initialTimelockBalance); + address _receiver = makeAddr("receiver"); + + ProposalDetails memory _proposal = + _buildGtcSendProposal(_receiver, _amount, "Send GTC from the Timelock"); + _submitPassQueueAndExecuteProposal(_proposal); + + assertEq(GTC_TOKEN.balanceOf(_receiver), _amount); + assertEq(GTC_TOKEN.balanceOf(address(TIMELOCK)), _initialTimelockBalance - _amount); + } + + /// forge-config: default.fuzz.runs = 25 + /// forge-config: ci.fuzz.runs = 25 + /// forge-config: lite.fuzz.runs = 5 + function testFuzz_PassedProposalSendsEthHeldByTheTimelock(uint256 _amount) external { + // The proposal draws on the ETH the Timelock genuinely holds at the fork block. + uint256 _initialTimelockBalance = address(TIMELOCK).balance; + _amount = bound(_amount, 1, _initialTimelockBalance); + address _receiver = makeAddr("receiver"); + + ProposalDetails memory _proposal = + _buildProposal(_receiver, _amount, "", "Send ETH from the Timelock"); + _submitPassQueueAndExecuteProposal(_proposal); + + assertEq(_receiver.balance, _amount); + assertEq(address(TIMELOCK).balance, _initialTimelockBalance - _amount); + } + + function test_PassedProposalSendsEthAndGtcTogether() external { + address _receiver = makeAddr("receiver"); + uint256 _gtcAmount = 5000e18; + uint256 _ethAmount = 5 ether; + uint256 _initialTimelockGtcBalance = GTC_TOKEN.balanceOf(address(TIMELOCK)); + uint256 _initialTimelockEthBalance = address(TIMELOCK).balance; + + ProposalDetails memory _proposal; + _proposal.targets = new address[](2); + _proposal.values = new uint256[](2); + _proposal.calldatas = new bytes[](2); + _proposal.targets[0] = address(GTC_TOKEN); + _proposal.calldatas[0] = abi.encodeCall(GTC_TOKEN.transfer, (_receiver, _gtcAmount)); + _proposal.targets[1] = _receiver; + _proposal.values[1] = _ethAmount; + _proposal.description = "Send GTC and ETH from the Timelock"; + _proposal.id = _hashProposal(_proposal); + + _submitPassQueueAndExecuteProposal(_proposal); + + assertEq(GTC_TOKEN.balanceOf(_receiver), _gtcAmount); + assertEq(_receiver.balance, _ethAmount); + assertEq(GTC_TOKEN.balanceOf(address(TIMELOCK)), _initialTimelockGtcBalance - _gtcAmount); + assertEq(address(TIMELOCK).balance, _initialTimelockEthBalance - _ethAmount); + } + + function test_DefeatedProposalCanNeitherBeQueuedNorExecuted() external { + address _receiver = makeAddr("receiver"); + uint256 _initialTimelockBalance = GTC_TOKEN.balanceOf(address(TIMELOCK)); + + ProposalDetails memory _proposal = + _buildGtcSendProposal(_receiver, 1000e18, "Send GTC the DAO does not want to send"); + _submitProposal(_proposal); + _jumpToProposalActive(_proposal.id); + _delegatesCastVotes(_proposal.id, AGAINST); + _jumpPastProposalDeadline(_proposal.id); + assertEq(governor.state(_proposal.id), IGovernor.ProposalState.Defeated); + + vm.expectPartialRevert(IGovernor.GovernorUnexpectedProposalState.selector); + _queueProposal(_proposal); + vm.expectPartialRevert(IGovernor.GovernorUnexpectedProposalState.selector); + _executeProposal(_proposal); + assertEq(GTC_TOKEN.balanceOf(_receiver), 0); + assertEq(GTC_TOKEN.balanceOf(address(TIMELOCK)), _initialTimelockBalance); + } + + function test_ProposalWithOnlyAbstainVotesReachesQuorumButIsDefeated() external { + ProposalDetails memory _proposal = + _buildGtcSendProposal(makeAddr("receiver"), 1000e18, "A proposal everyone abstains on"); + _submitProposal(_proposal); + _jumpToProposalActive(_proposal.id); + _delegatesCastVotes(_proposal.id, ABSTAIN); + _jumpPastProposalDeadline(_proposal.id); + + // Abstentions count toward quorum under the Governor's counting mode, but with no FOR + // majority the proposal still fails. + (uint256 _against, uint256 _for, uint256 _abstain) = governor.proposalVotes(_proposal.id); + assertEq(_against, 0); + assertEq(_for, 0); + assertGe(_abstain, QUORUM); + assertEq(governor.state(_proposal.id), IGovernor.ProposalState.Defeated); + } + + function test_GovernanceUpdatesItsOwnSettingsViaProposal() external { + uint48 _newVotingDelay = 7200; + uint32 _newVotingPeriod = 21_600; + uint256 _newProposalThreshold = 100_000e18; + uint48 _newVoteExtension = 7200; + + ProposalDetails memory _proposal; + _proposal.targets = new address[](4); + _proposal.values = new uint256[](4); + _proposal.calldatas = new bytes[](4); + for (uint256 _index = 0; _index < 4; _index += 1) { + _proposal.targets[_index] = address(governor); + } + _proposal.calldatas[0] = abi.encodeCall(governor.setVotingDelay, (_newVotingDelay)); + _proposal.calldatas[1] = abi.encodeCall(governor.setVotingPeriod, (_newVotingPeriod)); + _proposal.calldatas[2] = abi.encodeCall(governor.setProposalThreshold, (_newProposalThreshold)); + _proposal.calldatas[3] = + abi.encodeCall(governor.setLateQuorumVoteExtension, (_newVoteExtension)); + _proposal.description = "Update the Governor's own settings"; + _proposal.id = _hashProposal(_proposal); + + _submitPassQueueAndExecuteProposal(_proposal); + + assertEq(governor.votingDelay(), _newVotingDelay); + assertEq(governor.votingPeriod(), _newVotingPeriod); + assertEq(governor.proposalThreshold(), _newProposalThreshold); + assertEq(governor.lateQuorumVoteExtension(), _newVoteExtension); + } + + function test_VoterSplitsWeightAcrossForAgainstAndAbstainWithAFractionalVote() external { + ProposalDetails memory _proposal = + _buildGtcSendProposal(makeAddr("receiver"), 1000e18, "A proposal with split votes"); + _submitProposal(_proposal); + _jumpToProposalActive(_proposal.id); + + // kev.eth splits their weight: 10% against, 60% for, 30% abstain. The weight is a uint96, so + // the splits always fit a fractional vote's uint128 fields. + uint128 _weight = _votingWeightOf(KEV); + uint128 _againstVotes = _weight / 10; + uint128 _abstainVotes = _weight * 3 / 10; + uint128 _forVotes = _weight - _againstVotes - _abstainVotes; + vm.prank(KEV); + governor.castVoteWithReasonAndParams( + _proposal.id, + VOTE_TYPE_FRACTIONAL, + "I contain multitudes", + abi.encodePacked(_againstVotes, _forVotes, _abstainVotes) + ); + (uint256 _against, uint256 _for, uint256 _abstain) = governor.proposalVotes(_proposal.id); + assertEq(_against, _againstVotes); + assertEq(_for, _forVotes); + assertEq(_abstain, _abstainVotes); + + // Nominal bravo-style votes from the rest of the electorate coexist with the fractional + // tally. + _delegatesCastVotesExcept(_proposal.id, FOR, KEV); + (_against, _for, _abstain) = governor.proposalVotes(_proposal.id); + assertEq(_for, _forVotes + totalDelegateWeight - _weight); + // Ensure the combined result reaches quorum and passes regardless of the fork block. + if (((_for + _abstain) < QUORUM) || (_for < _against)) { + revert( + "Delegate votes at current for vote insufficient to pass proposal in fraction voting tests." + "Adjust delegates available or change fork block." + ); + } + + _jumpPastProposalDeadline(_proposal.id); + assertEq(governor.state(_proposal.id), IGovernor.ProposalState.Succeeded); + _queueProposal(_proposal); + _jumpPastProposalEta(_proposal.id); + _executeProposal(_proposal); + assertEq(governor.state(_proposal.id), IGovernor.ProposalState.Executed); + } + + function test_VoterCastsMultiplePartialFractionalVotes() external { + ProposalDetails memory _proposal = + _buildGtcSendProposal(makeAddr("receiver"), 1000e18, "A proposal with partial votes"); + _submitProposal(_proposal); + _jumpToProposalActive(_proposal.id); + + // First cast: kev.eth commits half their weight, split across all three options. + uint128 _weight = _votingWeightOf(KEV); + uint128 _firstAgainst = _weight / 8; + uint128 _firstFor = _weight / 4; + uint128 _firstAbstain = _weight / 8; + vm.prank(KEV); + governor.castVoteWithReasonAndParams( + _proposal.id, + VOTE_TYPE_FRACTIONAL, + "A first, partial vote", + abi.encodePacked(_firstAgainst, _firstFor, _firstAbstain) + ); + (uint256 _against, uint256 _for, uint256 _abstain) = governor.proposalVotes(_proposal.id); + assertEq(_against, _firstAgainst); + assertEq(_for, _firstFor); + assertEq(_abstain, _firstAbstain); + assertEq(governor.usedVotes(_proposal.id, KEV), _firstAgainst + _firstFor + _firstAbstain); + + // Second cast: the rest of their weight, all FOR. Tallies accumulate across the casts until + // the voter's full weight is spent. + uint128 _remaining = _weight - _firstAgainst - _firstFor - _firstAbstain; + vm.prank(KEV); + governor.castVoteWithReasonAndParams( + _proposal.id, + VOTE_TYPE_FRACTIONAL, + "A second vote with the rest of my weight", + abi.encodePacked(uint128(0), _remaining, uint128(0)) + ); + (_against, _for, _abstain) = governor.proposalVotes(_proposal.id); + assertEq(_against, _firstAgainst); + assertEq(_for, _firstFor + _remaining); + assertEq(_abstain, _firstAbstain); + assertEq(governor.usedVotes(_proposal.id, KEV), _weight); + + // The rest of the electorate pushes the proposal over quorum, and it passes. + _delegatesCastVotesExcept(_proposal.id, FOR, KEV); + _jumpPastProposalDeadline(_proposal.id); + assertEq(governor.state(_proposal.id), IGovernor.ProposalState.Succeeded); + } + + function test_VoterCastsAVoteBySignatureThatARelayerSubmits() external { + // A voter whose key the test controls, given real voting weight before the snapshot. + (address _signer, uint256 _signerKey) = makeAddrAndKey("gaslessVoter"); + uint256 _signerWeight = 250_000e18; + deal(address(GTC_TOKEN), _signer, _signerWeight); + vm.prank(_signer); + GTC_TOKEN.delegate(_signer); + + ProposalDetails memory _proposal = + _buildGtcSendProposal(makeAddr("receiver"), 1000e18, "A proposal voted on by signature"); + _submitProposal(_proposal); + _jumpToProposalActive(_proposal.id); + + // The voter signs an EIP-712 ballot off-chain, against the domain wallets will derive from + // the Governor's name and version. + bytes32 _domainSeparator = keccak256( + abi.encode( + keccak256( + "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)" + ), + keccak256(bytes(governor.name())), + keccak256(bytes(governor.version())), + block.chainid, + address(governor) + ) + ); + bytes32 _structHash = keccak256( + abi.encode(governor.BALLOT_TYPEHASH(), _proposal.id, FOR, _signer, governor.nonces(_signer)) + ); + bytes32 _digest = keccak256(abi.encodePacked("\x19\x01", _domainSeparator, _structHash)); + (uint8 _v, bytes32 _r, bytes32 _s) = vm.sign(_signerKey, _digest); + + // A relayer submits the ballot, paying the gas; the vote is counted for the signer. + vm.prank(makeAddr("relayer")); + governor.castVoteBySig(_proposal.id, FOR, _signer, abi.encodePacked(_r, _s, _v)); + assertTrue(governor.hasVoted(_proposal.id, _signer)); + (, uint256 _for,) = governor.proposalVotes(_proposal.id); + assertEq(_for, _signerWeight); + + // The rest of the electorate votes normally alongside, and the proposal passes. + _delegatesCastVotes(_proposal.id, FOR); + _jumpPastProposalDeadline(_proposal.id); + assertEq(governor.state(_proposal.id), IGovernor.ProposalState.Succeeded); + } + + function test_QueuedProposalExpiresOnceTheTimelockGracePeriodPasses() external { + address _receiver = makeAddr("receiver"); + + ProposalDetails memory _proposal = + _buildGtcSendProposal(_receiver, 1000e18, "A proposal nobody executes in time"); + _submitAndPassProposal(_proposal); + _queueProposal(_proposal); + + // Nobody executes the proposal within the Timelock's grace period. + vm.warp(governor.proposalEta(_proposal.id) + TIMELOCK.GRACE_PERIOD() + 1); + vm.roll(block.number + 1); + assertEq(governor.state(_proposal.id), IGovernor.ProposalState.Expired); + + vm.expectPartialRevert(IGovernor.GovernorUnexpectedProposalState.selector); + _executeProposal(_proposal); + assertEq(GTC_TOKEN.balanceOf(_receiver), 0); + } +} + +contract PostUpgradeGovernanceMainnetScript is PostUpgradeGovernanceTest { + function _setUpNetwork() internal override { + _createMainnetFork(); + } + + function _fetchOrDeploySystem() internal override returns (GitcoinGovernorWithGuardian) { + return _deployGovernorWithMainnetScript(); + } +} diff --git a/test/PostUpgradeProposalGuardian.integration.t.sol b/test/PostUpgradeProposalGuardian.integration.t.sol new file mode 100644 index 0000000..e23b61d --- /dev/null +++ b/test/PostUpgradeProposalGuardian.integration.t.sol @@ -0,0 +1,221 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.35; + +import {IGovernor} from "@openzeppelin/contracts/governance/IGovernor.sol"; +import {GitcoinGovernorWithGuardian} from "src/GitcoinGovernorWithGuardian.sol"; +import {GitcoinGovernorPostUpgradeTestBase} from "test/helpers/GitcoinGovernorUpgradeTestBase.sol"; + +// Exercises the Proposal Guardian after the upgrade: the guardian cancelling proposals at every +// cancelable stage of their lifecycle, the boundaries of that power, and the DAO replacing the +// guardian by governance. +abstract contract PostUpgradeProposalGuardianTest is GitcoinGovernorPostUpgradeTestBase { + address receiver; + + function setUp() public virtual override { + super.setUp(); + receiver = makeAddr("receiver"); + } + + // The send amount draws on GTC the Timelock genuinely holds (floor-guarded in the base setUp). + function _submitGtcSendProposal() internal returns (ProposalDetails memory _proposal) { + _proposal = _buildGtcSendProposal(receiver, 1000e18, "A proposal the guardian assesses"); + _submitProposal(_proposal); + } + + function _guardianCancels(ProposalDetails memory _proposal) internal { + vm.prank(governor.proposalGuardian()); + governor.cancel( + _proposal.targets, + _proposal.values, + _proposal.calldatas, + keccak256(bytes(_proposal.description)) + ); + } + + function test_GuardianCancelsAPendingProposal() external { + ProposalDetails memory _proposal = _submitGtcSendProposal(); + + _guardianCancels(_proposal); + + assertEq(governor.state(_proposal.id), IGovernor.ProposalState.Canceled); + // The canceled proposal is dead: voting never opens and it cannot be queued. + vm.roll(governor.proposalSnapshot(_proposal.id) + 1); + vm.prank(delegates[0]); + vm.expectPartialRevert(IGovernor.GovernorUnexpectedProposalState.selector); + governor.castVote(_proposal.id, FOR); + vm.expectPartialRevert(IGovernor.GovernorUnexpectedProposalState.selector); + _queueProposal(_proposal); + } + + function test_GuardianCancelsAnActiveProposalMidVote() external { + ProposalDetails memory _proposal = _submitGtcSendProposal(); + _jumpToProposalActive(_proposal.id); + _delegatesCastVotes(_proposal.id, FOR); + + _guardianCancels(_proposal); + + assertEq(governor.state(_proposal.id), IGovernor.ProposalState.Canceled); + _jumpPastProposalDeadline(_proposal.id); + assertEq(governor.state(_proposal.id), IGovernor.ProposalState.Canceled); + vm.expectPartialRevert(IGovernor.GovernorUnexpectedProposalState.selector); + _queueProposal(_proposal); + } + + function test_GuardianCancelsASucceededProposalBeforeItIsQueued() external { + ProposalDetails memory _proposal = _submitGtcSendProposal(); + _passSubmittedProposal(_proposal); + + _guardianCancels(_proposal); + + assertEq(governor.state(_proposal.id), IGovernor.ProposalState.Canceled); + vm.expectPartialRevert(IGovernor.GovernorUnexpectedProposalState.selector); + _queueProposal(_proposal); + } + + function test_GuardianCancelsADefeatedProposal() external { + ProposalDetails memory _proposal = _submitGtcSendProposal(); + _jumpToProposalActive(_proposal.id); + _delegatesCastVotes(_proposal.id, AGAINST); + _jumpPastProposalDeadline(_proposal.id); + assertEq(governor.state(_proposal.id), IGovernor.ProposalState.Defeated); + + _guardianCancels(_proposal); + + assertEq(governor.state(_proposal.id), IGovernor.ProposalState.Canceled); + } + + function test_GuardianCancelsAQueuedProposalAndItsTimelockTransactionsAreRemoved() external { + ProposalDetails memory _proposal = _submitGtcSendProposal(); + _passSubmittedProposal(_proposal); + _queueProposal(_proposal); + uint256 _eta = governor.proposalEta(_proposal.id); + bytes32 _timelockHash = _timelockTransactionHash( + _proposal.targets[0], _proposal.values[0], _proposal.calldatas[0], _eta + ); + assertTrue(TIMELOCK.queuedTransactions(_timelockHash)); + + _guardianCancels(_proposal); + + // Cancellation reaches through the Governor into the Timelock: the queued transaction is + // gone and the proposal can no longer be executed. + assertEq(governor.state(_proposal.id), IGovernor.ProposalState.Canceled); + assertFalse(TIMELOCK.queuedTransactions(_timelockHash)); + _jumpPastProposalEta(_proposal.id); + vm.expectPartialRevert(IGovernor.GovernorUnexpectedProposalState.selector); + _executeProposal(_proposal); + assertEq(GTC_TOKEN.balanceOf(receiver), 0); + } + + function test_GuardianCannotCancelAnExecutedProposal() external { + ProposalDetails memory _proposal = _submitGtcSendProposal(); + _passSubmittedProposal(_proposal); + _queueProposal(_proposal); + _jumpPastProposalEta(_proposal.id); + _executeProposal(_proposal); + assertEq(governor.state(_proposal.id), IGovernor.ProposalState.Executed); + + vm.prank(governor.proposalGuardian()); + vm.expectPartialRevert(IGovernor.GovernorUnexpectedProposalState.selector); + governor.cancel( + _proposal.targets, + _proposal.values, + _proposal.calldatas, + keccak256(bytes(_proposal.description)) + ); + assertEq(GTC_TOKEN.balanceOf(receiver), 1000e18); + } + + function test_AccountThatIsNeitherGuardianNorProposerCannotCancel() external { + ProposalDetails memory _proposal = _submitGtcSendProposal(); + + vm.prank(makeAddr("rando")); + vm.expectPartialRevert(IGovernor.GovernorUnableToCancel.selector); + governor.cancel( + _proposal.targets, + _proposal.values, + _proposal.calldatas, + keccak256(bytes(_proposal.description)) + ); + } + + function test_ProposerCancelsTheirOwnProposalWhilePending() external { + ProposalDetails memory _proposal = _submitGtcSendProposal(); + + vm.prank(PROPOSER); + governor.cancel( + _proposal.targets, + _proposal.values, + _proposal.calldatas, + keccak256(bytes(_proposal.description)) + ); + + assertEq(governor.state(_proposal.id), IGovernor.ProposalState.Canceled); + } + + function test_ProposerCannotCancelTheirOwnProposalOnceVotingStarts() external { + ProposalDetails memory _proposal = _submitGtcSendProposal(); + _jumpToProposalActive(_proposal.id); + + // With a guardian configured, the proposer's cancel power is limited to the Pending state. + vm.prank(PROPOSER); + vm.expectPartialRevert(IGovernor.GovernorUnableToCancel.selector); + governor.cancel( + _proposal.targets, + _proposal.values, + _proposal.calldatas, + keccak256(bytes(_proposal.description)) + ); + } + + function test_DaoReplacesTheProposalGuardianViaProposal() external { + address _oldGuardian = governor.proposalGuardian(); + address _newGuardian = makeAddr("newGuardian"); + ProposalDetails memory _replaceGuardian = _buildProposal( + address(governor), + 0, + abi.encodeCall(governor.setProposalGuardian, (_newGuardian)), + "Hand the guardian role to a new address" + ); + _submitPassQueueAndExecuteProposal(_replaceGuardian); + assertEq(governor.proposalGuardian(), _newGuardian); + + // The deposed guardian has no cancel power over new proposals; the new guardian does. + ProposalDetails memory _proposal = _submitGtcSendProposal(); + vm.prank(_oldGuardian); + vm.expectPartialRevert(IGovernor.GovernorUnableToCancel.selector); + governor.cancel( + _proposal.targets, + _proposal.values, + _proposal.calldatas, + keccak256(bytes(_proposal.description)) + ); + + vm.prank(_newGuardian); + governor.cancel( + _proposal.targets, + _proposal.values, + _proposal.calldatas, + keccak256(bytes(_proposal.description)) + ); + assertEq(governor.state(_proposal.id), IGovernor.ProposalState.Canceled); + } + + // Advances an already-submitted proposal to Succeeded (the base's _submitAndPassProposal + // helper both submits and passes, which would double-submit here). + function _passSubmittedProposal(ProposalDetails memory _proposal) internal { + _jumpToProposalActive(_proposal.id); + _delegatesCastVotes(_proposal.id, FOR); + _jumpPastProposalDeadline(_proposal.id); + assertEq(governor.state(_proposal.id), IGovernor.ProposalState.Succeeded); + } +} + +contract PostUpgradeProposalGuardianMainnetScript is PostUpgradeProposalGuardianTest { + function _setUpNetwork() internal override { + _createMainnetFork(); + } + + function _fetchOrDeploySystem() internal override returns (GitcoinGovernorWithGuardian) { + return _deployGovernorWithMainnetScript(); + } +} diff --git a/test/PostUpgradeQuorumBehavior.integration.t.sol b/test/PostUpgradeQuorumBehavior.integration.t.sol new file mode 100644 index 0000000..95ccadc --- /dev/null +++ b/test/PostUpgradeQuorumBehavior.integration.t.sol @@ -0,0 +1,245 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.35; + +import {IGovernor} from "@openzeppelin/contracts/governance/IGovernor.sol"; +import {GitcoinGovernorWithGuardian} from "src/GitcoinGovernorWithGuardian.sol"; +import {GitcoinGovernorPostUpgradeTestBase} from "test/helpers/GitcoinGovernorUpgradeTestBase.sol"; + +// Exercises the new Governor's quorum machinery after the upgrade: the DAO adjusting its own +// quorum via proposal (GovernorSettableFixedQuorum) and the late-quorum voting extension +// (GovernorPreventLateQuorum). Quorum targets are derived from the electorate's live weights so +// the scenarios stay valid as delegate weights drift across fork-block bumps. +abstract contract PostUpgradeQuorumBehaviorTest is GitcoinGovernorPostUpgradeTestBase { + uint256 constant LOWERED_QUORUM = 1_000_000e18; + + function setUp() public virtual override { + super.setUp(); + + // The weight-shape assumptions behind this suite's quorum-boundary scenarios. These revert + // rather than assert: a failure here means a fork-block bump reshaped delegate weights and + // the scenarios need rebalancing — not that Governor behavior regressed. Each message points + // the future developer at the scenario to fix. + if (totalDelegateWeight - _votingWeightOf(CERV1) < QUORUM) { + revert( + "The electorate minus cerv1 no longer clears the original quorum; " + "rebalance the raised-quorum scenarios" + ); + } + if (_votingWeightOf(KEV) < LOWERED_QUORUM) { + revert("kev.eth no longer clears the lowered quorum; rebalance the lowered-quorum scenario"); + } + if (_votingWeightOf(KEV) >= QUORUM) { + revert( + "kev.eth now single-handedly clears the original quorum; " + "rebalance the lowered-quorum scenario" + ); + } + uint256 _blocWeight = _votingWeightOf(PROPOSER) + _votingWeightOf(ANON_GNOSIS_SAFE) + + _votingWeightOf(EVENT_HORIZON); + if (_blocWeight < LOWERED_QUORUM) { + revert( + "The sniping bloc no longer clears the lowered quorum; rebalance the late-quorum scenario" + ); + } + if (_votingWeightOf(KEV) <= _blocWeight) { + revert("kev.eth no longer out-weighs the sniping bloc; rebalance the late-quorum scenario"); + } + } + + // Walks a setQuorum proposal through the full governance journey. + function _setQuorumViaProposal(uint256 _newQuorum) internal { + ProposalDetails memory _proposal = _buildProposal( + address(governor), + 0, + abi.encodeCall(governor.setQuorum, (_newQuorum)), + string.concat("Set the quorum to ", vm.toString(_newQuorum)) + ); + _submitPassQueueAndExecuteProposal(_proposal); + assertEq(governor.quorum(block.number), _newQuorum); + } + + function test_ProposalMeetingOnlyTheOldQuorumIsDefeatedAfterTheQuorumIsRaised() external { + // The DAO raises the quorum to the electorate's full combined weight. + _setQuorumViaProposal(totalDelegateWeight); + + // Everyone but cerv1 votes FOR: a tally that clears the original quorum (guarded in setUp) + // but falls short of the raised one. + ProposalDetails memory _proposal = + _buildGtcSendProposal(makeAddr("receiver"), 1000e18, "Send GTC under the raised quorum"); + _submitProposal(_proposal); + _jumpToProposalActive(_proposal.id); + _delegatesCastVotesExcept(_proposal.id, FOR, CERV1); + _jumpPastProposalDeadline(_proposal.id); + + assertEq(governor.state(_proposal.id), IGovernor.ProposalState.Defeated); + vm.expectPartialRevert(IGovernor.GovernorUnexpectedProposalState.selector); + _queueProposal(_proposal); + } + + function test_ProposalMeetingTheRaisedQuorumSucceedsAndExecutes() external { + _setQuorumViaProposal(totalDelegateWeight); + + address _receiver = makeAddr("receiver"); + ProposalDetails memory _proposal = + _buildGtcSendProposal(_receiver, 1000e18, "Send GTC meeting the raised quorum"); + _submitProposal(_proposal); + _jumpToProposalActive(_proposal.id); + // The full electorate votes FOR, meeting the raised quorum exactly. + _delegatesCastVotes(_proposal.id, FOR); + _jumpPastProposalDeadline(_proposal.id); + + assertEq(governor.state(_proposal.id), IGovernor.ProposalState.Succeeded); + _queueProposal(_proposal); + _jumpPastProposalEta(_proposal.id); + _executeProposal(_proposal); + assertEq(GTC_TOKEN.balanceOf(_receiver), 1000e18); + } + + function test_ProposalMeetingOnlyTheLoweredQuorumSucceedsAndExecutes() external { + _setQuorumViaProposal(LOWERED_QUORUM); + + // kev.eth alone clears the lowered quorum but would have fallen short of the original one + // (guarded in setUp). + address _receiver = makeAddr("receiver"); + ProposalDetails memory _proposal = + _buildGtcSendProposal(_receiver, 1000e18, "Send GTC under the lowered quorum"); + _submitProposal(_proposal); + _jumpToProposalActive(_proposal.id); + _castVote(KEV, _proposal.id, FOR); + _jumpPastProposalDeadline(_proposal.id); + + assertEq(governor.state(_proposal.id), IGovernor.ProposalState.Succeeded); + _queueProposal(_proposal); + _jumpPastProposalEta(_proposal.id); + _executeProposal(_proposal); + assertEq(GTC_TOKEN.balanceOf(_receiver), 1000e18); + } + + function test_InFlightProposalKeepsTheQuorumCheckpointedAtItsSnapshot() external { + // A quorum-raise proposal passes and sits queued in the Timelock. + ProposalDetails memory _quorumRaise = _buildProposal( + address(governor), + 0, + abi.encodeCall(governor.setQuorum, (totalDelegateWeight)), + "Raise the quorum" + ); + _submitAndPassProposal(_quorumRaise); + _queueProposal(_quorumRaise); + + // Meanwhile a send proposal is created and voted on with a tally (everyone but cerv1) that + // clears the current quorum but not the pending raised one. + address _receiver = makeAddr("receiver"); + ProposalDetails memory _inFlight = + _buildGtcSendProposal(_receiver, 1000e18, "Send GTC while the quorum changes"); + _submitProposal(_inFlight); + _jumpToProposalActive(_inFlight.id); + _delegatesCastVotesExcept(_inFlight.id, FOR, CERV1); + + // The quorum raise executes while the send proposal is still in flight. + _jumpPastProposalEta(_quorumRaise.id); + _executeProposal(_quorumRaise); + assertEq(governor.quorum(block.number), totalDelegateWeight); + + // The in-flight proposal is still judged by the quorum checkpointed at its snapshot. + assertEq(governor.quorum(governor.proposalSnapshot(_inFlight.id)), QUORUM); + _jumpPastProposalDeadline(_inFlight.id); + assertEq(governor.state(_inFlight.id), IGovernor.ProposalState.Succeeded); + _queueProposal(_inFlight); + _jumpPastProposalEta(_inFlight.id); + _executeProposal(_inFlight); + assertEq(GTC_TOKEN.balanceOf(_receiver), 1000e18); + } + + function test_QuorumReachedNearTheDeadlineExtendsTheVotingPeriod() external { + ProposalDetails memory _proposal = + _buildGtcSendProposal(makeAddr("receiver"), 1000e18, "A proposal reaching quorum late"); + _submitProposal(_proposal); + _jumpToProposalActive(_proposal.id); + uint256 _originalDeadline = governor.proposalDeadline(_proposal.id); + + // The electorate pushes the proposal over quorum 100 blocks before the deadline — within + // the vote extension window — so the deadline extends to a full extension period from the + // quorum-reaching vote. + vm.roll(_originalDeadline - 100); + _delegatesCastVotes(_proposal.id, FOR); + assertEq(governor.proposalDeadline(_proposal.id), block.number + VOTE_EXTENSION); + assertGt(governor.proposalDeadline(_proposal.id), _originalDeadline); + } + + function test_LateOpposersDefeatTheProposalDuringTheQuorumExtension() external { + // Under a lowered quorum, a minority bloc can trigger quorum at the last minute — exactly + // the sniping scenario the extension defends against. kev.eth out-weighs the bloc (guarded + // in setUp), and the extension gives them time to respond. + _setQuorumViaProposal(LOWERED_QUORUM); + + ProposalDetails memory _proposal = + _buildGtcSendProposal(makeAddr("receiver"), 1000e18, "A quorum-sniping attempt"); + _submitProposal(_proposal); + _jumpToProposalActive(_proposal.id); + uint256 _originalDeadline = governor.proposalDeadline(_proposal.id); + + // The bloc pushes the proposal over quorum just before voting would have closed. + vm.roll(_originalDeadline - 100); + _castVote(PROPOSER, _proposal.id, FOR); + _castVote(ANON_GNOSIS_SAFE, _proposal.id, FOR); + _castVote(EVENT_HORIZON, _proposal.id, FOR); + assertEq(governor.proposalDeadline(_proposal.id), block.number + VOTE_EXTENSION); + + // Thanks to the extension, opposition arriving after the original deadline still counts. + vm.roll(_originalDeadline + 10); + assertEq(governor.state(_proposal.id), IGovernor.ProposalState.Active); + _castVote(KEV, _proposal.id, AGAINST); + + vm.roll(governor.proposalDeadline(_proposal.id) + 1); + assertEq(governor.state(_proposal.id), IGovernor.ProposalState.Defeated); + } + + function test_ProposalSurvivingTheQuorumExtensionSucceedsAndExecutes() external { + address _receiver = makeAddr("receiver"); + ProposalDetails memory _proposal = + _buildGtcSendProposal(_receiver, 1000e18, "A proposal surviving its extension"); + _submitProposal(_proposal); + _jumpToProposalActive(_proposal.id); + uint256 _originalDeadline = governor.proposalDeadline(_proposal.id); + + vm.roll(_originalDeadline - 100); + _delegatesCastVotes(_proposal.id, FOR); + + // During the extension the proposal is still Active, so it cannot be queued early. + vm.roll(_originalDeadline + 10); + assertEq(governor.state(_proposal.id), IGovernor.ProposalState.Active); + vm.expectPartialRevert(IGovernor.GovernorUnexpectedProposalState.selector); + _queueProposal(_proposal); + + // No opposition materializes, and the proposal completes its journey. + vm.roll(governor.proposalDeadline(_proposal.id) + 1); + assertEq(governor.state(_proposal.id), IGovernor.ProposalState.Succeeded); + _queueProposal(_proposal); + _jumpPastProposalEta(_proposal.id); + _executeProposal(_proposal); + assertEq(GTC_TOKEN.balanceOf(_receiver), 1000e18); + } + + function test_QuorumReachedEarlyDoesNotExtendTheDeadline() external { + ProposalDetails memory _proposal = + _buildGtcSendProposal(makeAddr("receiver"), 1000e18, "A proposal reaching quorum early"); + _submitProposal(_proposal); + _jumpToProposalActive(_proposal.id); + uint256 _originalDeadline = governor.proposalDeadline(_proposal.id); + + // Quorum is reached at the very start of the voting period, leaving far more than the + // extension window before the deadline — so the deadline does not move. + _delegatesCastVotes(_proposal.id, FOR); + assertEq(governor.proposalDeadline(_proposal.id), _originalDeadline); + } +} + +contract PostUpgradeQuorumBehaviorMainnetScript is PostUpgradeQuorumBehaviorTest { + function _setUpNetwork() internal override { + _createMainnetFork(); + } + + function _fetchOrDeploySystem() internal override returns (GitcoinGovernorWithGuardian) { + return _deployGovernorWithMainnetScript(); + } +} diff --git a/test/helpers/GitcoinGovernorUpgradeTestBase.sol b/test/helpers/GitcoinGovernorUpgradeTestBase.sol new file mode 100644 index 0000000..189480f --- /dev/null +++ b/test/helpers/GitcoinGovernorUpgradeTestBase.sol @@ -0,0 +1,459 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.35; + +import {Test} from "forge-std/Test.sol"; +import {IGovernor} from "@openzeppelin/contracts/governance/IGovernor.sol"; +import {ICompoundTimelock} from "@openzeppelin/contracts/vendor/compound/ICompoundTimelock.sol"; +import {GitcoinGovernorWithGuardian} from "src/GitcoinGovernorWithGuardian.sol"; +import {IGovernorBravo} from "src/interfaces/IGovernorBravo.sol"; +import { + DeployGitcoinGovernorWithGuardianMainnet +} from "script/DeployGitcoinGovernorWithGuardianMainnet.s.sol"; +import {IGtc} from "test/helpers/IGtc.sol"; +import {ProposeGovernorUpgradeTestConfig} from "test/helpers/ProposeGovernorUpgradeTestConfig.sol"; + +// Shared base for the mainnet fork integration suites. Holds the provenance abstraction (how the +// system under test comes into being), the real-delegate electorate, and step helpers for +// walking proposals through their lifecycle on both the old and the new Governor. +abstract contract GitcoinGovernorUpgradeTestBase is Test { + // Vote support values shared by both governors' bravo-style counting. + uint8 constant AGAINST = 0; + uint8 constant FOR = 1; + uint8 constant ABSTAIN = 2; + // The support value GovernorCountingFractional reserves for fractional votes. + uint8 constant VOTE_TYPE_FRACTIONAL = 255; + + uint256 constant FORK_BLOCK = 25_453_000; + + IGovernorBravo constant OLD_GOVERNOR = IGovernorBravo(0x9D4C63565D5618310271bF3F3c01b2954C1D1639); + IGtc constant GTC_TOKEN = IGtc(0xDe30da39c46104798bB5aA3fe8B9e0e1F348163F); + ICompoundTimelock constant TIMELOCK = + ICompoundTimelock(payable(0x57a8865cfB1eCEf7253c27da6B4BC3dAEE5Be518)); + + // kbw.eth — a real delegate whose voting weight (~485k GTC at FORK_BLOCK) clears the 150k + // proposal threshold on both governors (guarded in setUp). + address constant PROPOSER = 0xc2E2B715d9e302947Ec7e312fd2384b5a1296099; + + // Real Gitcoin delegates who, together with the PROPOSER, form the test electorate. Approximate + // voting weights at FORK_BLOCK are noted; live weights are fetched in setUp. + address constant KEV = 0x00De4B13153673BCAE2616b67bf822500d325Fc3; // kev.eth, ~1.56M GTC + address constant ANON_GNOSIS_SAFE = 0x93F80a67FdFDF9DaF1aee5276Db95c8761cc8561; // ~500k GTC + // eventhorizoncommunity.eth, ~152k GTC + address constant EVENT_HORIZON = 0xb35659cbac913D5E4119F2Af47fD490A45e2c826; + address constant LEFTERIS = 0x2B888954421b424C5D3D9Ce9bB67c9bD47537d12; // lefteris.eth, ~100k GTC + address constant CERV1 = 0x5a5D9aB7b1bD978F80909503EBb828879daCa9C3; // cerv1.eth, ~90k GTC + + // Governance parameters of the active Governor at FORK_BLOCK, mirrored by the new Governor's + // mainnet deploy config. + uint256 constant QUORUM = 2_500_000e18; + uint48 constant VOTE_EXTENSION = 14_400; + + // Floors on the Timelock's real treasury holdings, guarded in setUp: the send tests draw on + // the Timelock's genuine balances rather than manufactured ones, and need enough behind them + // to stay representative. At FORK_BLOCK the Timelock holds ~12.5M GTC and ~139 ETH. + uint256 constant MIN_TIMELOCK_GTC_BALANCE = 1_000_000e18; + uint256 constant MIN_TIMELOCK_ETH_BALANCE = 10 ether; + + string constant UPGRADE_PROPOSAL_DESCRIPTION = + "Upgrade the Gitcoin Governor to GitcoinGovernorWithGuardian"; + + // Bundles everything needed to drive one proposal through either governor's lifecycle. + struct ProposalDetails { + address[] targets; + uint256[] values; + bytes[] calldatas; + string description; + uint256 id; + } + + GitcoinGovernorWithGuardian governor; + uint256 upgradeProposalId; + + // The electorate: real delegates whose live voting weights are read from the fork in setUp. + // Combined they must clear the 2.5M quorum — asserted in setUp so that a fork-block bump that + // erodes their weight fails loudly rather than silently changing what the tests exercise. + // Weights are stored at GTC's native uint96 checkpoint width so that vote math over them can + // widen into narrower types (e.g. the uint128 fields of a fractional vote) without unsafe + // casts. + address[] delegates; + mapping(address delegate => uint96 weight) delegateWeights; + uint256 totalDelegateWeight; + + function setUp() public virtual { + _setUpNetwork(); + governor = _fetchOrDeploySystem(); + + delegates.push(KEV); + delegates.push(ANON_GNOSIS_SAFE); + delegates.push(PROPOSER); + delegates.push(EVENT_HORIZON); + delegates.push(LEFTERIS); + delegates.push(CERV1); + for (uint256 _index = 0; _index < delegates.length; _index += 1) { + uint96 _weight = GTC_TOKEN.getCurrentVotes(delegates[_index]); + delegateWeights[delegates[_index]] = _weight; + totalDelegateWeight += _weight; + } + + // Guards on the assumptions the suites make about the forked state. These revert rather + // than assert: a failure here means the test setup no longer holds — not that a behavior + // under test regressed. Each message is aimed at a future developer bumping FORK_BLOCK. + for (uint256 _index = 0; _index < delegates.length; _index += 1) { + if (delegateWeights[delegates[_index]] == 0) { + revert( + string.concat( + "Delegate ", + vm.toString(delegates[_index]), + " has no voting weight at FORK_BLOCK; replace them in the electorate" + ) + ); + } + } + if (totalDelegateWeight < QUORUM) { + revert( + string.concat( + "The test electorate's combined weight of ", + vm.toString(totalDelegateWeight), + " no longer clears the quorum of ", + vm.toString(QUORUM), + "; refresh the delegate set for this fork block" + ) + ); + } + if (_votingWeightOf(PROPOSER) < OLD_GOVERNOR.proposalThreshold()) { + revert( + string.concat( + "The PROPOSER's voting weight of ", + vm.toString(_votingWeightOf(PROPOSER)), + " no longer clears the proposal threshold of ", + vm.toString(OLD_GOVERNOR.proposalThreshold()), + "; choose a new proposer for this fork block" + ) + ); + } + if (GTC_TOKEN.balanceOf(address(TIMELOCK)) < MIN_TIMELOCK_GTC_BALANCE) { + revert( + "The Timelock holds too little GTC for representative treasury tests; revisit FORK_BLOCK" + ); + } + if (address(TIMELOCK).balance < MIN_TIMELOCK_ETH_BALANCE) { + revert( + "The Timelock holds too little ETH for representative treasury tests; revisit FORK_BLOCK" + ); + } + } + + function _setUpNetwork() internal virtual; + + function _fetchOrDeploySystem() internal virtual returns (GitcoinGovernorWithGuardian); + + //-------------------------- Provenance implementation helpers --------------------------// + // Provenance concretes at the bottom of each test file implement the two methods above with + // one-line delegations to these helpers. + + function _createMainnetFork() internal { + vm.createSelectFork("mainnet", FORK_BLOCK); + } + + // Deploys the new Governor onto the fork by running the real mainnet deploy script, exactly as + // the production deployment will run it. + function _deployGovernorWithMainnetScript() internal returns (GitcoinGovernorWithGuardian) { + DeployGitcoinGovernorWithGuardianMainnet _deployScript = + new DeployGitcoinGovernorWithGuardianMainnet(); + _deployScript.disableLogging(); + _deployScript.run(); + return _deployScript.governor(); + } + + //---------------------------------- Electorate helpers ----------------------------------// + + // A delegate's voting weight as read from the fork in setUp. Stable for the run: nothing in + // the tests re-delegates, and GTC moved by proposals is not delegated on either end. + function _votingWeightOf(address _delegate) internal view returns (uint96) { + return delegateWeights[_delegate]; + } + + //---------------------------------- Proposal construction ----------------------------------// + + function _buildProposal( + address _target, + uint256 _value, + bytes memory _calldata, + string memory _description + ) internal pure returns (ProposalDetails memory _proposal) { + _proposal.targets = new address[](1); + _proposal.values = new uint256[](1); + _proposal.calldatas = new bytes[](1); + _proposal.targets[0] = _target; + _proposal.values[0] = _value; + _proposal.calldatas[0] = _calldata; + _proposal.description = _description; + _proposal.id = _hashProposal(_proposal); + } + + function _buildGtcSendProposal(address _receiver, uint256 _amount, string memory _description) + internal + pure + returns (ProposalDetails memory) + { + return _buildProposal( + address(GTC_TOKEN), 0, abi.encodeCall(IGtc.transfer, (_receiver, _amount)), _description + ); + } + + // Both governors compute proposal ids identically: the OpenZeppelin hash of the actions and + // the description hash. + function _hashProposal(ProposalDetails memory _proposal) internal pure returns (uint256) { + return uint256( + keccak256( + abi.encode( + _proposal.targets, + _proposal.values, + _proposal.calldatas, + keccak256(bytes(_proposal.description)) + ) + ) + ); + } + + // The two actions of the upgrade proposal, mirroring what the proposal script builds. The + // tests assert the mirror is faithful by recomputing the script-returned proposal id from + // these actions. + function _upgradeProposalDetails() internal view returns (ProposalDetails memory _proposal) { + _proposal.targets = new address[](2); + _proposal.values = new uint256[](2); + _proposal.calldatas = new bytes[](2); + _proposal.targets[0] = address(TIMELOCK); + _proposal.calldatas[0] = abi.encodeCall(ICompoundTimelock.setPendingAdmin, (address(governor))); + _proposal.targets[1] = address(governor); + _proposal.calldatas[1] = abi.encodeCall(governor.__acceptAdmin, ()); + _proposal.description = UPGRADE_PROPOSAL_DESCRIPTION; + _proposal.id = _hashProposal(_proposal); + } + + //----------------------------- Upgrade proposal (old Governor) -----------------------------// + + // Submits the upgrade proposal by running the proposal script, exactly as a delegate would. + function _submitUpgradeProposal() internal { + ProposeGovernorUpgradeTestConfig _proposeScript = new ProposeGovernorUpgradeTestConfig( + OLD_GOVERNOR, governor, PROPOSER, UPGRADE_PROPOSAL_DESCRIPTION + ); + _proposeScript.disableLogging(); + _proposeScript.run(); + upgradeProposalId = _proposeScript.proposalId(); + } + + function _jumpToUpgradeProposalActive() internal { + vm.roll(OLD_GOVERNOR.proposalSnapshot(upgradeProposalId) + 1); + assertEq(OLD_GOVERNOR.state(upgradeProposalId), IGovernor.ProposalState.Active); + } + + function _jumpPastUpgradeProposalDeadline() internal { + vm.roll(OLD_GOVERNOR.proposalDeadline(upgradeProposalId) + 1); + } + + function _passUpgradeProposal() internal { + _jumpToUpgradeProposalActive(); + _delegatesCastVotesOnOldGovernor(upgradeProposalId, FOR); + _jumpPastUpgradeProposalDeadline(); + } + + function _defeatUpgradeProposal() internal { + _jumpToUpgradeProposalActive(); + _delegatesCastVotesOnOldGovernor(upgradeProposalId, AGAINST); + _jumpPastUpgradeProposalDeadline(); + } + + function _queueUpgradeProposal() internal { + ProposalDetails memory _proposal = _upgradeProposalDetails(); + OLD_GOVERNOR.queue( + _proposal.targets, + _proposal.values, + _proposal.calldatas, + keccak256(bytes(_proposal.description)) + ); + } + + function _jumpPastUpgradeProposalEta() internal { + vm.roll(block.number + 1); + vm.warp(OLD_GOVERNOR.proposalEta(upgradeProposalId) + 1); + } + + function _executeUpgradeProposal() internal { + ProposalDetails memory _proposal = _upgradeProposalDetails(); + OLD_GOVERNOR.execute( + _proposal.targets, + _proposal.values, + _proposal.calldatas, + keccak256(bytes(_proposal.description)) + ); + } + + // The full upgrade journey: submit via the proposal script, pass, queue, wait out the + // Timelock delay, execute, and confirm the new Governor now controls the Timelock. + function _upgradeToNewGovernor() internal { + _submitUpgradeProposal(); + _passUpgradeProposal(); + _queueUpgradeProposal(); + _jumpPastUpgradeProposalEta(); + _executeUpgradeProposal(); + assertEq(TIMELOCK.admin(), address(governor)); + } + + //------------------------- Arbitrary proposals on the old Governor -------------------------// + + function _submitProposalToOldGovernor(ProposalDetails memory _proposal) internal { + vm.prank(PROPOSER); + uint256 _id = OLD_GOVERNOR.propose( + _proposal.targets, _proposal.values, _proposal.calldatas, _proposal.description + ); + assertEq(_id, _proposal.id); + } + + function _delegatesCastVotesOnOldGovernor(uint256 _proposalId, uint8 _support) internal { + for (uint256 _index = 0; _index < delegates.length; _index += 1) { + vm.prank(delegates[_index]); + OLD_GOVERNOR.castVote(_proposalId, _support); + } + } + + function _submitAndPassProposalOnOldGovernor(ProposalDetails memory _proposal) internal { + _submitProposalToOldGovernor(_proposal); + vm.roll(OLD_GOVERNOR.proposalSnapshot(_proposal.id) + 1); + _delegatesCastVotesOnOldGovernor(_proposal.id, FOR); + vm.roll(OLD_GOVERNOR.proposalDeadline(_proposal.id) + 1); + assertEq(OLD_GOVERNOR.state(_proposal.id), IGovernor.ProposalState.Succeeded); + } + + // Queues a succeeded proposal on the old Governor, waits out the Timelock delay, and executes. + function _queueAndExecuteProposalOnOldGovernor(ProposalDetails memory _proposal) internal { + OLD_GOVERNOR.queue( + _proposal.targets, + _proposal.values, + _proposal.calldatas, + keccak256(bytes(_proposal.description)) + ); + vm.roll(block.number + 1); + vm.warp(OLD_GOVERNOR.proposalEta(_proposal.id) + 1); + OLD_GOVERNOR.execute( + _proposal.targets, + _proposal.values, + _proposal.calldatas, + keccak256(bytes(_proposal.description)) + ); + assertEq(OLD_GOVERNOR.state(_proposal.id), IGovernor.ProposalState.Executed); + } + + //------------------------------ Proposals on the new Governor ------------------------------// + + function _submitProposal(ProposalDetails memory _proposal) internal { + vm.prank(PROPOSER); + uint256 _id = governor.propose( + _proposal.targets, _proposal.values, _proposal.calldatas, _proposal.description + ); + assertEq(_id, _proposal.id); + assertEq(governor.state(_proposal.id), IGovernor.ProposalState.Pending); + } + + function _jumpToProposalActive(uint256 _proposalId) internal { + vm.roll(governor.proposalSnapshot(_proposalId) + 1); + assertEq(governor.state(_proposalId), IGovernor.ProposalState.Active); + } + + function _castVote(address _voter, uint256 _proposalId, uint8 _support) internal { + vm.prank(_voter); + governor.castVote(_proposalId, _support); + } + + function _delegatesCastVotes(uint256 _proposalId, uint8 _support) internal { + for (uint256 _index = 0; _index < delegates.length; _index += 1) { + _castVote(delegates[_index], _proposalId, _support); + } + } + + // Casts a vote from every electorate member except one — used by tests that need a tally + // sitting between the excluded delegate's weight and the full electorate's. + function _delegatesCastVotesExcept(uint256 _proposalId, uint8 _support, address _excluded) + internal + { + for (uint256 _index = 0; _index < delegates.length; _index += 1) { + if (delegates[_index] == _excluded) { + continue; + } + _castVote(delegates[_index], _proposalId, _support); + } + } + + function _jumpPastProposalDeadline(uint256 _proposalId) internal { + vm.roll(governor.proposalDeadline(_proposalId) + 1); + } + + function _queueProposal(ProposalDetails memory _proposal) internal { + governor.queue( + _proposal.targets, + _proposal.values, + _proposal.calldatas, + keccak256(bytes(_proposal.description)) + ); + } + + function _jumpPastProposalEta(uint256 _proposalId) internal { + vm.roll(block.number + 1); + vm.warp(governor.proposalEta(_proposalId) + 1); + } + + function _executeProposal(ProposalDetails memory _proposal) internal { + governor.execute( + _proposal.targets, + _proposal.values, + _proposal.calldatas, + keccak256(bytes(_proposal.description)) + ); + } + + function _submitAndPassProposal(ProposalDetails memory _proposal) internal { + _submitProposal(_proposal); + _jumpToProposalActive(_proposal.id); + _delegatesCastVotes(_proposal.id, FOR); + _jumpPastProposalDeadline(_proposal.id); + assertEq(governor.state(_proposal.id), IGovernor.ProposalState.Succeeded); + } + + function _submitPassQueueAndExecuteProposal(ProposalDetails memory _proposal) internal { + _submitAndPassProposal(_proposal); + _queueProposal(_proposal); + _jumpPastProposalEta(_proposal.id); + _executeProposal(_proposal); + assertEq(governor.state(_proposal.id), IGovernor.ProposalState.Executed); + } + + //----------------------------------------- Misc -----------------------------------------// + + // The hash under which the Compound Timelock stores a queued transaction. The new Governor + // queues every action with an empty signature and the proposal's eta. + function _timelockTransactionHash( + address _target, + uint256 _value, + bytes memory _data, + uint256 _eta + ) internal pure returns (bytes32) { + return keccak256(abi.encode(_target, _value, "", _data, _eta)); + } + + function assertEq(IGovernor.ProposalState _actual, IGovernor.ProposalState _expected) + internal + pure + { + assertEq(uint8(_actual), uint8(_expected)); + } +} + +// Base for suites that exercise the new Governor after the upgrade: the full adoption journey +// (deploy, propose, vote, queue, execute) runs once in setUp, so every test starts from a state +// where the new Governor controls the Timelock. +abstract contract GitcoinGovernorPostUpgradeTestBase is GitcoinGovernorUpgradeTestBase { + function setUp() public virtual override { + super.setUp(); + _upgradeToNewGovernor(); + } +} diff --git a/test/helpers/IGtc.sol b/test/helpers/IGtc.sol new file mode 100644 index 0000000..e6becde --- /dev/null +++ b/test/helpers/IGtc.sol @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.35; + +// Test-only interface for the COMP-style GTC token, covering the functions the integration tests +// use to move tokens, read delegates' voting weight, and manufacture a key-controlled voter for +// the vote-by-signature journey. The production contracts only need the narrower +// `src/interfaces/IComp.sol`. +interface IGtc { + function balanceOf(address _account) external view returns (uint256); + function transfer(address _to, uint256 _amount) external returns (bool); + function delegate(address _delegatee) external; + function getCurrentVotes(address _account) external view returns (uint96); +} diff --git a/test/helpers/ProposeGovernorUpgradeTestConfig.sol b/test/helpers/ProposeGovernorUpgradeTestConfig.sol new file mode 100644 index 0000000..072fe21 --- /dev/null +++ b/test/helpers/ProposeGovernorUpgradeTestConfig.sol @@ -0,0 +1,39 @@ +// 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"; + +// Test-only configuration for the upgrade proposal script. The mainnet concrete hardcodes its +// values as committed constants, but a fork test deploys the new Governor at a nonce-dependent +// address that cannot be known ahead of time — so this config takes the values by constructor +// injection instead. All the substantive mechanics (action construction, validation, submission) +// still run in the inherited abstract base. +contract ProposeGovernorUpgradeTestConfig is ProposeGovernorUpgrade { + IGovernorBravo internal immutable OLD_GOVERNOR; + GitcoinGovernorWithGuardian internal immutable NEW_GOVERNOR; + address internal immutable PROPOSER; + string internal description; + + constructor( + IGovernorBravo _oldGovernor, + GitcoinGovernorWithGuardian _newGovernor, + address _proposer, + string memory _description + ) { + OLD_GOVERNOR = _oldGovernor; + NEW_GOVERNOR = _newGovernor; + PROPOSER = _proposer; + description = _description; + } + + function _getProposalParams() internal view override returns (ProposalParams memory) { + return ProposalParams({ + oldGovernor: OLD_GOVERNOR, + newGovernor: NEW_GOVERNOR, + proposer: PROPOSER, + description: description + }); + } +}