diff --git a/.env.template b/.env.template new file mode 100644 index 0000000..fb5d2d7 --- /dev/null +++ b/.env.template @@ -0,0 +1,3 @@ +DEPLOYER_PRIVATE_KEY= +PROPOSER_PRIVATE_KEY= +MAINNET_RPC_URL= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9975a66..689ffca 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,6 +9,7 @@ on: env: FOUNDRY_PROFILE: ci + MAINNET_RPC_URL: ${{ secrets.MAINNET_RPC_URL }} jobs: build: diff --git a/.gitmodules b/.gitmodules index e80ffd8..c5abb46 100644 --- a/.gitmodules +++ b/.gitmodules @@ -4,3 +4,6 @@ [submodule "lib/openzeppelin-contracts"] path = lib/openzeppelin-contracts url = https://github.com/openzeppelin/openzeppelin-contracts +[submodule "lib/solmate"] + path = lib/solmate + url = https://github.com/transmissions11/solmate diff --git a/foundry.toml b/foundry.toml index d6e62be..b4e4768 100644 --- a/foundry.toml +++ b/foundry.toml @@ -2,7 +2,10 @@ evm_version = "paris" optimizer = true optimizer_runs = 10_000_000 - remappings = ["@openzeppelin/contracts/=lib/openzeppelin-contracts/contracts"] + remappings = [ + "@openzeppelin/contracts/=lib/openzeppelin-contracts/contracts", + "@solmate/utils/=lib/solmate/src/utils", + ] solc_version = "0.8.20" verbosity = 3 @@ -16,6 +19,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/lib/solmate b/lib/solmate new file mode 160000 index 0000000..e0e9ff0 --- /dev/null +++ b/lib/solmate @@ -0,0 +1 @@ +Subproject commit e0e9ff05d8aa5c7c48465511f85a6efdf5d5c30d diff --git a/script/Deploy.s.sol b/script/Deploy.s.sol index 140fc68..280cf3e 100644 --- a/script/Deploy.s.sol +++ b/script/Deploy.s.sol @@ -1,16 +1,26 @@ -// SPDX-License-Identifier: UNLICENSED -// slither-disable-start reentrancy-benign - -pragma solidity 0.8.20; +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.20; import {Script} from "forge-std/Script.sol"; -import {Counter} from "src/Counter.sol"; +import {DeployInput} from "script/DeployInput.sol"; +import {RadworksGovernor} from "src/RadworksGovernor.sol"; + +contract Deploy is DeployInput, Script { + uint256 deployerPrivateKey; + + function setUp() public { + deployerPrivateKey = vm.envOr( + "DEPLOYER_PRIVATE_KEY", + uint256(0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80) + ); + } -contract Deploy is Script { - Counter counter; + function run() public returns (RadworksGovernor) { + vm.startBroadcast(deployerPrivateKey); + RadworksGovernor _governor = + new RadworksGovernor(INITIAL_VOTING_DELAY, INITIAL_VOTING_PERIOD, INITIAL_PROPOSAL_THRESHOLD); + vm.stopBroadcast(); - function run() public { - vm.broadcast(); - counter = new Counter(); + return _governor; } } diff --git a/script/DeployInput.sol b/script/DeployInput.sol new file mode 100644 index 0000000..7ee6bcf --- /dev/null +++ b/script/DeployInput.sol @@ -0,0 +1,8 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.20; + +contract DeployInput { + uint256 constant INITIAL_VOTING_DELAY = 7200; // 24 hours + uint256 constant INITIAL_VOTING_PERIOD = 17_280; // matches existing config + uint256 constant INITIAL_PROPOSAL_THRESHOLD = 1_000_000e18; // matches existing config +} diff --git a/script/Propose.s.sol b/script/Propose.s.sol new file mode 100644 index 0000000..37947fd --- /dev/null +++ b/script/Propose.s.sol @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.20; + +import {Script} from "forge-std/Script.sol"; +import {ICompoundTimelock} from + "@openzeppelin/contracts/governance/extensions/GovernorTimelockCompound.sol"; + +import {RadworksGovernor} from "src/RadworksGovernor.sol"; +import {IGovernorAlpha} from "src/interfaces/IGovernorAlpha.sol"; +import {Constants} from "test/Constants.sol"; + +/// @notice Script to submit the proposal to upgrade Radworks governor. +contract Propose is Script, Constants { + IGovernorAlpha constant RADWORK_GOVERNOR_ALPHA = IGovernorAlpha(GOVERNOR_ALPHA); + address PROPOSER_ADDRESS = 0x464D78a5C97A2E2E9839C353ee9B6d4204c90B0b; // cloudhead.eth + + function propose(RadworksGovernor _newGovernor) internal returns (uint256 _proposalId) { + address[] memory _targets = new address[](2); + uint256[] memory _values = new uint256[](2); + string[] memory _signatures = new string[](2); + bytes[] memory _calldatas = new bytes[](2); + + _targets[0] = RADWORK_GOVERNOR_ALPHA.timelock(); + _values[0] = 0; + _signatures[0] = "setPendingAdmin(address)"; + _calldatas[0] = abi.encode(address(_newGovernor)); + + _targets[1] = address(_newGovernor); + _values[1] = 0; + _signatures[1] = "__acceptAdmin()"; + _calldatas[1] = ""; + + return RADWORK_GOVERNOR_ALPHA.propose( + _targets, _values, _signatures, _calldatas, "Upgrade to Governor Bravo" + ); + } + + /// @dev After the new Governor is deployed on mainnet, `_newGovernor` can become a const + function run(RadworksGovernor _newGovernor) public returns (uint256 _proposalId) { + // The expectation is the key loaded here corresponds to the address of the `proposer` above. + // When running as a script, broadcast will fail if the key is not correct. + uint256 _proposerKey = vm.envOr( + "PROPOSER_PRIVATE_KEY", + uint256(0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d) + ); + vm.rememberKey(_proposerKey); + + vm.startBroadcast(PROPOSER_ADDRESS); + _proposalId = propose(_newGovernor); + vm.stopBroadcast(); + return _proposalId; + } +} diff --git a/src/Counter.sol b/src/Counter.sol deleted file mode 100644 index 0124e7c..0000000 --- a/src/Counter.sol +++ /dev/null @@ -1,14 +0,0 @@ -// SPDX-License-Identifier: UNLICENSED -pragma solidity 0.8.20; - -contract Counter { - uint256 public number; - - function setNumber(uint256 newNumber) public { - number = newNumber; - } - - function increment() public { - number++; - } -} diff --git a/src/RadworksGovernor.sol b/src/RadworksGovernor.sol index c7e399f..46a9bd1 100644 --- a/src/RadworksGovernor.sol +++ b/src/RadworksGovernor.sol @@ -47,11 +47,8 @@ contract RadworksGovernor is /// @notice Human readable name of this Governor. string private constant GOVERNOR_NAME = "Radworks Governor Bravo"; - /// @notice The number of RAD (in "wei") that must participate in a vote for it to meet quorum - /// threshold. - /// - /// TODO: placeholder - uint256 private constant QUORUM = 100_000e18; // 100,000 RAD + /// @notice The number of RAD (in "wei") that must participate in a vote to meet quorum threshold. + uint256 private constant QUORUM = 4_000_000e18; // 4,000,000 RAD /// @param _initialVotingDelay The initial voting delay this Governor will enforce. /// @param _initialVotingPeriod The initial voting period this Governor will enforce. @@ -113,7 +110,7 @@ contract RadworksGovernor is /// @inheritdoc Governor function _voteSucceeded(uint256 proposalId) internal view virtual override returns (bool) { - ProposalVote storage proposalVote = _proposalVotes[proposalId]; + ProposalVote memory proposalVote = _proposalVotes[proposalId]; return proposalVote.forVotes > proposalVote.againstVotes; } diff --git a/src/interfaces/IGovernorAlpha.sol b/src/interfaces/IGovernorAlpha.sol new file mode 100644 index 0000000..59f1620 --- /dev/null +++ b/src/interfaces/IGovernorAlpha.sol @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity >=0.8.0; + +interface IGovernorAlpha { + event ProposalCanceled(uint256 id); + event ProposalCreated( + uint256 id, + address proposer, + address[] targets, + uint256[] values, + string[] signatures, + bytes[] calldatas, + uint256 startBlock, + uint256 endBlock, + string description + ); + event ProposalExecuted(uint256 id); + event ProposalQueued(uint256 id, uint256 eta); + event VoteCast(address voter, uint256 proposalId, bool support, uint256 votes); + + struct Receipt { + bool hasVoted; + bool support; + uint96 votes; + } + + function BALLOT_TYPEHASH() external view returns (bytes32); + function DOMAIN_TYPEHASH() external view returns (bytes32); + function cancel(uint256 proposalId) external; + function castVote(uint256 proposalId, bool support) external; + function castVoteBySig(uint256 proposalId, bool support, uint8 v, bytes32 r, bytes32 s) external; + function execute(uint256 proposalId) external payable; + function getActions(uint256 proposalId) + external + view + returns ( + address[] memory targets, + uint256[] memory values, + string[] memory signatures, + bytes[] memory calldatas + ); + function getReceipt(uint256 proposalId, address voter) external view returns (Receipt memory); + function latestProposalIds(address) external view returns (uint256); + function name() external view returns (string memory); + function proposalCount() external view returns (uint256); + function proposalMaxOperations() external pure returns (uint256); + function proposalThreshold() external pure returns (uint256); + function proposals(uint256) + external + view + returns ( + address proposer, + uint256 eta, + uint256 startBlock, + uint256 endBlock, + uint256 forVotes, + uint256 againstVotes, + bool canceled, + bool executed + ); + function propose( + address[] memory targets, + uint256[] memory values, + string[] memory signatures, + bytes[] memory calldatas, + string memory description + ) external returns (uint256); + function queue(uint256 proposalId) external; + function quorumVotes() external pure returns (uint256); + function state(uint256 proposalId) external view returns (uint8); + function timelock() external view returns (address); + function votingDelay() external pure returns (uint256); + function votingPeriod() external pure returns (uint256); +} diff --git a/test/Constants.sol b/test/Constants.sol new file mode 100644 index 0000000..9aee6fe --- /dev/null +++ b/test/Constants.sol @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.20; + +contract Constants { + address constant GOVERNOR_ALPHA = 0x690e775361AD66D1c4A25d89da9fCd639F5198eD; + address payable constant RAD_TOKEN = payable(0x31c8EAcBFFdD875c74b94b077895Bd78CF1E64A3); + address constant TIMELOCK = 0x8dA8f82d2BbDd896822de723F55D6EdF416130ba; + + // TODO: resolve the list of large delegates with tallyaddress + address constant PROPOSER = 0x464D78a5C97A2E2E9839C353ee9B6d4204c90B0b; + + address constant USDC_ADDRESS = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48; + address constant GTC_ADDRESS = 0xDe30da39c46104798bB5aA3fe8B9e0e1F348163F; + address constant WETH_ADDRESS = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2; + address constant MTV_ADDRESS = 0x6226e00bCAc68b0Fe55583B90A1d727C14fAB77f; + uint256 constant MAX_REASONABLE_TIME_PERIOD = 302_400; // 6 weeks assume a 12 sec block time + + // we have not yet deployed the Radworks Bravo Governor + address constant DEPLOYED_BRAVO_GOVERNOR = 0x1111111111111111111111111111111111111111; + + uint256 constant QUORUM = 4_000_000e18; +} diff --git a/test/Counter.t.sol b/test/Counter.t.sol deleted file mode 100644 index d168bbd..0000000 --- a/test/Counter.t.sol +++ /dev/null @@ -1,26 +0,0 @@ -// SPDX-License-Identifier: UNLICENSED -pragma solidity 0.8.20; - -import {Test, console2} from "forge-std/Test.sol"; -import {Deploy} from "script/Deploy.s.sol"; -import {Counter} from "src/Counter.sol"; - -contract CounterTest is Test, Deploy { - function setUp() public { - Deploy.run(); - } -} - -contract Increment is CounterTest { - function test_NumberIsIncremented() public { - counter.increment(); - assertEq(counter.number(), 1); - } -} - -contract SetNumber is CounterTest { - function testFuzz_NumberIsSet(uint256 x) public { - counter.setNumber(x); - assertEq(counter.number(), x); - } -} diff --git a/test/RadworksGovernor.t.sol b/test/RadworksGovernor.t.sol new file mode 100644 index 0000000..120763a --- /dev/null +++ b/test/RadworksGovernor.t.sol @@ -0,0 +1,176 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.20; + +import {ERC20VotesComp} from "@openzeppelin/contracts/token/ERC20/extensions/ERC20VotesComp.sol"; + +import {IGovernorAlpha} from "src/interfaces/IGovernorAlpha.sol"; +import {RadworksGovernorTest} from "test/helpers/RadworksGovernorTest.sol"; +import {ProposalTest} from "test/helpers/ProposalTest.sol"; + +abstract contract Constructor is RadworksGovernorTest { + function testFuzz_CorrectlySetsAllConstructorArgs(uint256 _blockNumber) public { + assertEq(governorBravo.name(), "Radworks Governor Bravo"); + assertEq(address(governorBravo.token()), RAD_TOKEN); + + assertEq(governorBravo.votingDelay(), INITIAL_VOTING_DELAY); + assertLt(governorBravo.votingDelay(), MAX_REASONABLE_TIME_PERIOD); + + assertEq(governorBravo.votingPeriod(), INITIAL_VOTING_PERIOD); + assertLt(governorBravo.votingPeriod(), MAX_REASONABLE_TIME_PERIOD); + + assertEq(governorBravo.proposalThreshold(), INITIAL_PROPOSAL_THRESHOLD); + + assertEq(governorBravo.quorum(_blockNumber), QUORUM); + assertEq(governorBravo.timelock(), TIMELOCK); + assertEq(governorBravo.COUNTING_MODE(), "support=bravo&quorum=bravo"); + } +} + +abstract contract Propose is ProposalTest { + function test_GovernorUpgradeProposalIsSubmittedCorrectly() public { + // Proposal has been recorded + assertEq(governorAlpha.proposalCount(), initialProposalCount + 1); + + // Proposal is in the expected state + uint8 _state = governorAlpha.state(upgradeProposalId); + assertEq(_state, PENDING); + + // Proposal actions correspond to Governor upgrade + ( + address[] memory _targets, + uint256[] memory _values, + string[] memory _signatures, + bytes[] memory _calldatas + ) = governorAlpha.getActions(upgradeProposalId); + assertEq(_targets.length, 2); + assertEq(_targets[0], TIMELOCK); + assertEq(_targets[1], address(governorBravo)); + assertEq(_values.length, 2); + assertEq(_values[0], 0); + assertEq(_values[1], 0); + assertEq(_signatures.length, 2); + assertEq(_signatures[0], "setPendingAdmin(address)"); + assertEq(_signatures[1], "__acceptAdmin()"); + assertEq(_calldatas.length, 2); + assertEq(_calldatas[0], abi.encode(address(governorBravo))); + assertEq(_calldatas[1], ""); + } + + function test_UpgradeProposalActiveAfterDelay() public { + _jumpToActiveUpgradeProposal(); + + // Ensure proposal has become active the block after the voting delay + uint8 _state = governorAlpha.state(upgradeProposalId); + assertEq(_state, ACTIVE); + } + + function testFuzz_UpgradeProposerCanCastVote(bool _willSupport) public { + _jumpToActiveUpgradeProposal(); + uint256 _proposerVotes = + ERC20VotesComp(RAD_TOKEN).getPriorVotes(PROPOSER, _upgradeProposalStartBlock()); + + vm.prank(PROPOSER); + governorAlpha.castVote(upgradeProposalId, _willSupport); + + IGovernorAlpha.Receipt memory _receipt = governorAlpha.getReceipt(upgradeProposalId, PROPOSER); + assertEq(_receipt.hasVoted, true); + assertEq(_receipt.support, _willSupport); + assertEq(_receipt.votes, _proposerVotes); + } + + function test_UpgradeProposalSucceedsWhenAllDelegatesVoteFor() public { + _passUpgradeProposal(); + + // Ensure proposal state is now succeeded + uint8 _state = governorAlpha.state(upgradeProposalId); + assertEq(_state, SUCCEEDED); + } + + function test_UpgradeProposalDefeatedWhenAllDelegatesVoteAgainst() public { + _defeatUpgradeProposal(); + + // Ensure proposal state is now defeated + uint8 _state = governorAlpha.state(upgradeProposalId); + assertEq(_state, DEFEATED); + } + + function test_UpgradeProposalCanBeQueuedAfterSucceeding() public { + _passUpgradeProposal(); + governorAlpha.queue(upgradeProposalId); + + // Ensure proposal can be queued after success + uint8 _state = governorAlpha.state(upgradeProposalId); + assertEq(_state, QUEUED); + + ( + address[] memory _targets, + uint256[] memory _values, + string[] memory _signatures, + bytes[] memory _calldatas + ) = governorAlpha.getActions(upgradeProposalId); + + uint256 _eta = block.timestamp + timelock.delay(); + + for (uint256 _index = 0; _index < _targets.length; _index++) { + // Calculate hash of transaction in Timelock + bytes32 _txHash = keccak256( + abi.encode(_targets[_index], _values[_index], _signatures[_index], _calldatas[_index], _eta) + ); + + // Ensure transaction is queued in Timelock + bool _isQueued = timelock.queuedTransactions(_txHash); + assertEq(_isQueued, true); + } + } + + function test_UpgradeProposalCanBeExecutedAfterDelay() public { + _passAndQueueUpgradeProposal(); + _jumpPastProposalEta(); + + // Execute the proposal + governorAlpha.execute(upgradeProposalId); + + // Ensure the proposal is now executed + uint8 _state = governorAlpha.state(upgradeProposalId); + assertEq(_state, EXECUTED); + + // Ensure the governorBravo is now the admin of the timelock + assertEq(timelock.admin(), address(governorBravo)); + } +} + +// TODO: future PR +abstract contract CastVoteWithReasonAndParams is ProposalTest {} + +// TODO: future PR +abstract contract Execute is ProposalTest {} + +// Run the tests using the deployed Governor Bravo (future PR) + +// Run the tests using a version of the Governor deployed by the Deploy script + +contract ConstructorTestWithDeployScriptGovernor is Constructor { + function _useDeployedGovernorBravo() internal pure override returns (bool) { + return false; + } +} + +contract ProposeTestWithDeployScriptGovernor is Propose { + function _useDeployedGovernorBravo() internal pure override returns (bool) { + return false; + } +} + +// TODO: (future PR) + +// contract CastVoteWithReasonAndParamsTestWithDeployScriptGovernor is CastVoteWithReasonAndParams { +// function _useDeployedGovernorBravo() internal pure override returns (bool) { +// return false; +// } +// } + +// contract _ExecuteTestWithDeployScriptGovernor is _Execute { +// function _useDeployedGovernorBravo() internal pure override returns (bool) { +// return false; +// } +// } diff --git a/test/helpers/Proposal.sol b/test/helpers/Proposal.sol new file mode 100644 index 0000000..d6d64b8 --- /dev/null +++ b/test/helpers/Proposal.sol @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.20; + +contract ProposalBuilder { + address[] private _targets; + uint256[] private _values; + bytes[] private _calldatas; + + function add(address _target, uint256 _value, bytes memory _calldata) public { + _targets.push(_target); + _values.push(_value); + _calldatas.push(_calldata); + } + + function targets() public view returns (address[] memory) { + return _targets; + } + + function values() public view returns (uint256[] memory) { + return _values; + } + + function calldatas() public view returns (bytes[] memory) { + return _calldatas; + } +} diff --git a/test/helpers/ProposalTest.sol b/test/helpers/ProposalTest.sol new file mode 100644 index 0000000..bc5da64 --- /dev/null +++ b/test/helpers/ProposalTest.sol @@ -0,0 +1,337 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.20; + +import {ICompoundTimelock} from + "@openzeppelin/contracts/governance/extensions/GovernorTimelockCompound.sol"; +import {IERC20} from "@openzeppelin/contracts/interfaces/IERC20.sol"; +import {IGovernor} from "@openzeppelin/contracts/governance/IGovernor.sol"; + +import {TestableProposeScript} from "test/helpers/TestableProposeScript.sol"; +import {IGovernorAlpha} from "src/interfaces/IGovernorAlpha.sol"; +import {RadworksGovernorTest} from "test/helpers/RadworksGovernorTest.sol"; + +abstract contract ProposalTest is RadworksGovernorTest { + //----------------- State and Setup ----------- // + + IGovernorAlpha governorAlpha = IGovernorAlpha(GOVERNOR_ALPHA); + ICompoundTimelock timelock = ICompoundTimelock(payable(TIMELOCK)); + uint256 initialProposalCount; + uint256 upgradeProposalId; + + // As defined in the GovernorAlpha ProposalState Enum + uint8 constant PENDING = 0; + uint8 constant ACTIVE = 1; + uint8 constant DEFEATED = 3; + uint8 constant SUCCEEDED = 4; + uint8 constant QUEUED = 5; + uint8 constant EXECUTED = 7; + + // From GovernorCountingSimple + uint8 constant AGAINST = 0; + uint8 constant FOR = 1; + uint8 constant ABSTAIN = 2; + + function setUp() public virtual override { + RadworksGovernorTest.setUp(); + + if (_useDeployedGovernorBravo()) { + // Use the actual live proposal data expected to be on chain + // if Radworks Governor Bravo has already deployed + upgradeProposalId = 17; // assume this is the next proposal (as most recent is 16) + // Since the proposal was already submitted, the count before its submissions is one less + initialProposalCount = governorAlpha.proposalCount() - 1; + } else { + initialProposalCount = governorAlpha.proposalCount(); + + TestableProposeScript _proposeScript = new TestableProposeScript(); + // We override the deployer to use an alternate delegate, because in this context, + // the PROPOSER_ADDRESS used already has a live proposal + _proposeScript.overrideProposerForTests(0x69dceee155C31eA0c8354F90BDD65C12FaF5A00a); + + upgradeProposalId = _proposeScript.run(governorBravo); + } + } + + //--------------- HELPERS ---------------// + + // a cheat for fuzzing addresses that are payable only + // Why is this no longer in standard cheats? JJF + // see https://github.com/foundry-rs/foundry/issues/3631 + function assumePayable(address addr) internal virtual { + (bool success,) = payable(addr).call{value: 0}(""); + vm.assume(success); + } + + function _assumeReceiver(address _receiver) internal { + assumePayable(_receiver); + vm.assume( + // We don't want the receiver to be the Timelock, as that would make our + // assertions less meaningful -- most of our tests want to confirm that + // proposals can cause tokens to be sent *from* the timelock to somewhere + // else. + _receiver != TIMELOCK + // We also can't have the receiver be the zero address because RAD + // blocks transfers to the zero address -- see line 396: + // https://etherscan.io/address/0x31c8EAcBFFdD875c74b94b077895Bd78CF1E64A3#code + && _receiver > address(0) + ); + assumeNoPrecompiles(_receiver); + } + + function _randomERC20Token(uint256 _seed) internal pure returns (IERC20 _token) { + if (_seed % 5 == 0) _token = IERC20(RAD_TOKEN); + if (_seed % 5 == 1) _token = IERC20(USDC_ADDRESS); + if (_seed % 5 == 2) _token = IERC20(GTC_ADDRESS); + if (_seed % 5 == 3) _token = IERC20(WETH_ADDRESS); + if (_seed % 5 == 4) _token = IERC20(MTV_ADDRESS); + } + + function _upgradeProposalStartBlock() internal view returns (uint256) { + (,, uint256 _startBlock,,,,,) = governorAlpha.proposals(upgradeProposalId); + return _startBlock; + } + + function _upgradeProposalEndBlock() internal view returns (uint256) { + (,,, uint256 _endBlock,,,,) = governorAlpha.proposals(upgradeProposalId); + return _endBlock; + } + + function _upgradeProposalEta() internal view returns (uint256) { + (, uint256 _eta,,,,,,) = governorAlpha.proposals(upgradeProposalId); + return _eta; + } + + function _jumpToActiveUpgradeProposal() internal { + vm.roll(_upgradeProposalStartBlock() + 1); + } + + function _jumpToUpgradeVoteComplete() internal { + vm.roll(_upgradeProposalEndBlock() + 1); + } + + function _jumpPastProposalEta() internal { + vm.roll(block.number + 1); // move up one block so we're not in the same block as when queued + vm.warp(_upgradeProposalEta() + 1); // jump past the eta timestamp + } + + function _delegatesVoteOnUpgradeProposal(bool _support) internal { + for (uint256 _index = 0; _index < delegates.length; _index++) { + vm.prank(delegates[_index].addr); + governorAlpha.castVote(upgradeProposalId, _support); + } + } + + function _passUpgradeProposal() internal { + _jumpToActiveUpgradeProposal(); + _delegatesVoteOnUpgradeProposal(true); + _jumpToUpgradeVoteComplete(); + } + + function _defeatUpgradeProposal() internal { + _jumpToActiveUpgradeProposal(); + _delegatesVoteOnUpgradeProposal(false); + _jumpToUpgradeVoteComplete(); + } + + function _passAndQueueUpgradeProposal() internal { + _passUpgradeProposal(); + governorAlpha.queue(upgradeProposalId); + } + + function _upgradeToBravoGovernor() internal { + _passAndQueueUpgradeProposal(); + _jumpPastProposalEta(); + governorAlpha.execute(upgradeProposalId); + } + + function _queueAndVoteAndExecuteProposalWithAlphaGovernor( + address[] memory _targets, + uint256[] memory _values, + string[] memory _signatures, + bytes[] memory _calldatas, + bool isGovernorAlphaAdmin + ) internal { + // Submit the new proposal + vm.prank(PROPOSER); + uint256 _newProposalId = + governorAlpha.propose(_targets, _values, _signatures, _calldatas, "Proposal for old Governor"); + + // Pass and execute the new proposal + (,, uint256 _startBlock, uint256 _endBlock,,,,) = governorAlpha.proposals(_newProposalId); + vm.roll(_startBlock + 1); + for (uint256 _index = 0; _index < delegates.length; _index++) { + vm.prank(delegates[_index].addr); + governorAlpha.castVote(_newProposalId, true); + } + vm.roll(_endBlock + 1); + + if (!isGovernorAlphaAdmin) { + vm.expectRevert("Timelock::queueTransaction: Call must come from admin."); + governorAlpha.queue(_newProposalId); + return; + } + + governorAlpha.queue(_newProposalId); + vm.roll(block.number + 1); + (, uint256 _eta,,,,,,) = governorAlpha.proposals(_newProposalId); + vm.warp(_eta + 1); + + governorAlpha.execute(_newProposalId); + + // Ensure the new proposal is now executed + assertEq(governorAlpha.state(_newProposalId), EXECUTED); + } + + function _buildProposalData(string memory _signature, bytes memory _calldata) + internal + pure + returns (bytes memory) + { + return abi.encodePacked(bytes4(keccak256(bytes(_signature))), _calldata); + } + + function _jumpToActiveProposal(uint256 _proposalId) internal { + uint256 _snapshot = governorBravo.proposalSnapshot(_proposalId); + vm.roll(_snapshot + 1); + + // Ensure the proposal is now Active + IGovernor.ProposalState _state = governorBravo.state(_proposalId); + assertEq(_state, IGovernor.ProposalState.Active); + } + + function _jumpToVotingComplete(uint256 _proposalId) internal { + // Jump one block past the proposal voting deadline + uint256 _deadline = governorBravo.proposalDeadline(_proposalId); + vm.roll(_deadline + 1); + } + + function _jumpPastProposalEta(uint256 _proposalId) internal { + uint256 _eta = governorBravo.proposalEta(_proposalId); + vm.roll(block.number + 1); + vm.warp(_eta + 1); + } + + function _delegatesCastVoteOnBravoGovernor(uint256 _proposalId, uint8 _support) internal { + require(_support < 3, "Invalid value for support"); + + for (uint256 _index = 0; _index < delegates.length; _index++) { + vm.prank(delegates[_index].addr); + governorBravo.castVote(_proposalId, _support); + } + } + + function _buildTokenSendProposal(address _token, uint256 _tokenAmount, address _receiver) + internal + pure + returns ( + address[] memory _targets, + uint256[] memory _values, + bytes[] memory _calldata, + string memory _description + ) + { + // Craft a new proposal to send _token. + _targets = new address[](1); + _values = new uint256[](1); + _calldata = new bytes[](1); + + _targets[0] = _token; + _values[0] = 0; + _calldata[0] = + _buildProposalData("transfer(address,uint256)", abi.encode(_receiver, _tokenAmount)); + _description = "Transfer some tokens from the new Governor"; + } + + function _submitTokenSendProposalToGovernorBravo( + address _token, + uint256 _amount, + address _receiver + ) + internal + returns ( + uint256 _newProposalId, + address[] memory _targets, + uint256[] memory _values, + bytes[] memory _calldata, + string memory _description + ) + { + (_targets, _values, _calldata, _description) = + _buildTokenSendProposal(_token, _amount, _receiver); + + // Submit the new proposal + vm.prank(PROPOSER); + _newProposalId = governorBravo.propose(_targets, _values, _calldata, _description); + + // Ensure proposal is in the expected state + IGovernor.ProposalState _state = governorBravo.state(_newProposalId); + assertEq(_state, IGovernor.ProposalState.Pending); + } + + // Take a proposal through its full lifecycle, from proposing it, to voting on + // it, to queuing it, to executing it (if relevant) via GovernorBravo. + function _queueAndVoteAndExecuteProposalWithBravoGovernor( + address[] memory _targets, + uint256[] memory _values, + bytes[] memory _calldatas, + string memory _description, + uint8 _voteType + ) internal { + // Submit the new proposal + vm.prank(PROPOSER); + uint256 _newProposalId = governorBravo.propose(_targets, _values, _calldatas, _description); + + // Ensure proposal is Pending. + IGovernor.ProposalState _state = governorBravo.state(_newProposalId); + assertEq(_state, IGovernor.ProposalState.Pending); + + _jumpToActiveProposal(_newProposalId); + + // Have all delegates cast their weight with the specified support type. + _delegatesCastVoteOnBravoGovernor(_newProposalId, _voteType); + + _jumpToVotingComplete(_newProposalId); + + _state = governorBravo.state(_newProposalId); + if (_voteType == AGAINST || _voteType == ABSTAIN) { + // The proposal should have failed. + assertEq(_state, IGovernor.ProposalState.Defeated); + + // Attempt to queue the proposal. + vm.expectRevert("Governor: proposal not successful"); + governorBravo.queue(_targets, _values, _calldatas, keccak256(bytes(_description))); + + _jumpPastProposalEta(_newProposalId); + + // Attempt to execute the proposal. + vm.expectRevert("Governor: proposal not successful"); + governorBravo.execute(_targets, _values, _calldatas, keccak256(bytes(_description))); + + // Exit this function, there's nothing left to test. + return; + } + + // The voteType was FOR. Ensure the proposal has succeeded. + assertEq(_state, IGovernor.ProposalState.Succeeded); + + // Queue the proposal + governorBravo.queue(_targets, _values, _calldatas, keccak256(bytes(_description))); + + // Ensure the proposal is queued + _state = governorBravo.state(_newProposalId); + assertEq(_state, IGovernor.ProposalState.Queued); + + _jumpPastProposalEta(_newProposalId); + + // Execute the proposal + governorBravo.execute(_targets, _values, _calldatas, keccak256(bytes(_description))); + + // Ensure the proposal is executed + _state = governorBravo.state(_newProposalId); + assertEq(_state, IGovernor.ProposalState.Executed); + } + + function assertEq(IGovernor.ProposalState _actual, IGovernor.ProposalState _expected) internal { + assertEq(uint8(_actual), uint8(_expected)); + } +} diff --git a/test/helpers/RadworksGovernorTest.sol b/test/helpers/RadworksGovernorTest.sol new file mode 100644 index 0000000..04c5e08 --- /dev/null +++ b/test/helpers/RadworksGovernorTest.sol @@ -0,0 +1,70 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.20; + +import {Test} from "forge-std/Test.sol"; + +import {Deploy} from "script/Deploy.s.sol"; +import {DeployInput} from "script/DeployInput.sol"; +import { + ERC20VotesComp, + GovernorVotesComp +} from "@openzeppelin/contracts/governance/extensions/GovernorVotesComp.sol"; +import {RadworksGovernor} from "src/RadworksGovernor.sol"; +import {Constants} from "test/Constants.sol"; + +abstract contract RadworksGovernorTest is Test, DeployInput, Constants { + ERC20VotesComp poolToken = ERC20VotesComp(RAD_TOKEN); + + struct Delegate { + string handle; + address addr; + uint96 votes; + } + + Delegate[] delegates; + + RadworksGovernor governorBravo; + + function setUp() public virtual { + // The latest block when this test was written. If you update the fork block + // make sure to also update the top 6 delegates below. + uint256 _forkBlock = 18_514_244; + + vm.createSelectFork(vm.rpcUrl("mainnet"), _forkBlock); + + // Taken from https://www.tally.xyz/gov/radworks/delegates?sort=voting_power_desc. + // If you update these delegates (including updating order in the array), + // make sure to update any tests that reference specific delegates. The last delegate is the + // proposer and lower in the voting power than the above link. + // TODO: resolve the list of large delegates with tally + Delegate[] memory _delegates = new Delegate[](3); + _delegates[0] = Delegate("Delegate 0", 0x288703AA4e65dD244680FaefA742C488b7CD1992, 4.24e6); + _delegates[1] = Delegate("Delegate 1", 0x69dceee155C31eA0c8354F90BDD65C12FaF5A00a, 1.86e6); + // _delegates[2] = Delegate("Delegate 2", 0x464D78a5C97A2E2E9839C353ee9B6d4204c90B0b, 1.58e6); + // _delegates[3] = Delegate("Delegate 3", 0xc74f55155C41dfB90C122A1702b49C8295D9a724, 988.24e3); + // _delegates[4] = Delegate("Delegate 4", 0xBD8d617Ac53c5Efc5fBDBb51d445f7A2350D4940, 680.27e6); + _delegates[2] = Delegate("proposer", PROPOSER, 1.58e6); + + // Fetch up-to-date voting weight for the top delegates. + for (uint256 i; i < _delegates.length; i++) { + Delegate memory _delegate = _delegates[i]; + _delegate.votes = poolToken.getCurrentVotes(_delegate.addr); + delegates.push(_delegate); + } + + // After the Radworks Governor Bravo is deployed, the actual deployed contract can be tested. + // Before then, we'll use the Deploy script to deploy a new instance of the contract in the test + // fork. + if (_useDeployedGovernorBravo()) { + governorBravo = RadworksGovernor(payable(DEPLOYED_BRAVO_GOVERNOR)); + } else { + // We still want to exercise the script in these tests to give us + // confidence that we could deploy again if necessary. + Deploy _deployScript = new Deploy(); + _deployScript.setUp(); + governorBravo = _deployScript.run(); + } + } + + function _useDeployedGovernorBravo() internal virtual returns (bool); +} diff --git a/test/helpers/TestableProposeScript.sol b/test/helpers/TestableProposeScript.sol new file mode 100644 index 0000000..458d78d --- /dev/null +++ b/test/helpers/TestableProposeScript.sol @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.20; + +import {Propose} from "script/Propose.s.sol"; + +/// @dev An extension of the proposal script for use in tests +contract TestableProposeScript is Propose { + /// @dev Used only in the context of testing in order to allow an alternate address to be the + /// proposer. This is needed when testing with live proposal data, because the Governor only + /// allows each proposer to have one live proposal at a time. + function overrideProposerForTests(address _testProposer) external { + PROPOSER_ADDRESS = _testProposer; + } +}