From ce28eaa9f0a2a9c3d47ae875b027d5cd267d5979 Mon Sep 17 00:00:00 2001 From: Victor Elias Date: Fri, 25 Aug 2023 19:11:51 -0300 Subject: [PATCH] treasury: Delta Governor v1 (#615) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * package.json: Update @openzeppelin libraries Will be required for new checkpoints code and later governor implementation. * bonding: Create SortedArrays library Used for checkpointing logic in bonding state checkpoints * bonding: Create BondingCheckpoints contract Handles historic checkpointing ("snapshotting") and lookup of the bonding state. * bonding: Checkpoint bonding state on changes * test/bonding: Test BondingManager and Checkpoints - unit tests - integration tests - gas-report - fork test for upgrade * bonding: Migrate to custom error types * bonding: Allow querying unbonded+uncheckpointed accounts This will provide a cleaner experience using governance tools, since users that don't have any stake won't get errors when querying their voting power. For users that do have stake, we will make sure to checkpoint them on first deploy. * treasury: Create treasury governance contracts * test/treasury: Add unit test fro BondingCheckpointsVotes * test/treasury: Test GovernorCountingOverridable * test/treasury: Test LivepeerGovernor * test/treasury: A couple additional Governor tests 100% coverage 😎 * test/treasury: Rename Counting unit test mock "Harness" seems to make more sense, I could only think of that now. * Apply suggestions from code review Co-authored-by: Chase Adams * treasury: Fix storage layout situation * treasury: Move governor initial params to configs * bonding: Make sure we checkpoint up to once per op * bonding: Make bonding checkpoints implement IVotes * bonding: Read votes from the end of the round Meaning the start of next round instead of the current round. This is more compatible with the way OZ expects the timepoints to work in the clock and snapshots on the Governor framework. * bonding: Make checkpoint reading revert-free This changes the BondingVotes implementation to stop having so many reverts on supposedly invalid states. Instead, redefine the voting power (and checkpointed stake) as being zero before the first round to be checkpointed. This had other implications in the code like removing changes in BondingManager to make the state complete (always having an earnings pool on lastClaimRound). Instead, the BondingVotes implementaiton is resilient to that as well and just assumes reward() had never been called. This also fixed the redundant reverts between BondingVotes and SortedArrays, as now there are almost no reverts. * bonding: Address minor code review comments * treasury: Migrate to the new BondingVotes contract * treasury: Address PR comments * bonding: Move constructor to after modifiers Just for consistency with other contracts. Some docs as a bonus * test/mocks: Remove mock functions that moved to other mock * bonding: Implement ERC20 metadata on votes This is to increase compatibility with some tools out there like Tally, which require only these functions from the ERC20 spec, not the full implementation. So we can have these from the get-go to make things easier if we want to make something with them. * bonding: Address PR comments * bonding: Address BondingVotes review comments * treasury: Merge BondingCheckpoints and nits --------- Co-authored-by: Chase Adams --- .../test/mocks/GovenorInterfacesFixture.sol | 51 ++ .../GovernorCountingOverridableHarness.sol | 74 +++ .../mocks/LivepeerGovernorUpgradeMock.sol | 14 + contracts/test/mocks/VotesMock.sol | 112 ++++ .../treasury/GovernorCountingOverridable.sol | 225 ++++++++ contracts/treasury/LivepeerGovernor.sol | 168 ++++++ contracts/treasury/Treasury.sol | 24 + deploy/deploy_livepeer_governor.ts | 87 +++ deploy/migrations.config.ts | 10 + test/helpers/governorEnums.js | 16 + test/helpers/math.js | 6 +- test/helpers/setupIntegrationTest.ts | 10 +- test/integration/LivepeerGovernor.ts | 513 ++++++++++++++++++ test/unit/GovernorCountingOverridable.js | 447 +++++++++++++++ utils/deployer.ts | 9 + 15 files changed, 1760 insertions(+), 6 deletions(-) create mode 100644 contracts/test/mocks/GovenorInterfacesFixture.sol create mode 100644 contracts/test/mocks/GovernorCountingOverridableHarness.sol create mode 100644 contracts/test/mocks/LivepeerGovernorUpgradeMock.sol create mode 100644 contracts/test/mocks/VotesMock.sol create mode 100644 contracts/treasury/GovernorCountingOverridable.sol create mode 100644 contracts/treasury/LivepeerGovernor.sol create mode 100644 contracts/treasury/Treasury.sol create mode 100644 deploy/deploy_livepeer_governor.ts create mode 100644 test/helpers/governorEnums.js create mode 100644 test/integration/LivepeerGovernor.ts create mode 100644 test/unit/GovernorCountingOverridable.js diff --git a/contracts/test/mocks/GovenorInterfacesFixture.sol b/contracts/test/mocks/GovenorInterfacesFixture.sol new file mode 100644 index 00000000..6e42e9a8 --- /dev/null +++ b/contracts/test/mocks/GovenorInterfacesFixture.sol @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.9; + +import "@openzeppelin/contracts-upgradeable/governance/IGovernorUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/governance/extensions/IGovernorTimelockUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/token/ERC1155/IERC1155ReceiverUpgradeable.sol"; + +/** + * @dev This is a helper contract to return the expected interface values that the LivepeerGovenor interface should + * support. This only exists in Solidity since generating these interfaces in JS is kinda of a pain. + */ +contract GovernorInterfacesFixture { + function TimelockUpgradeableInterface() external pure returns (bytes4) { + return type(IGovernorTimelockUpgradeable).interfaceId; + } + + /** + * @dev ID calculation logic copied from {GovernorUpgradeable-supportsInterface}. + */ + function GovernorInterfaces() external pure returns (bytes4[] memory) { + IGovernorUpgradeable governor; + // + bytes4 governorCancelId = governor.cancel.selector ^ governor.proposalProposer.selector; + + bytes4 governorParamsId = governor.castVoteWithReasonAndParams.selector ^ + governor.castVoteWithReasonAndParamsBySig.selector ^ + governor.getVotesWithParams.selector; + + // The original interface id in v4.3. + bytes4 governor43Id = type(IGovernorUpgradeable).interfaceId ^ + type(IERC6372Upgradeable).interfaceId ^ + governorCancelId ^ + governorParamsId; + + // An updated interface id in v4.6, with params added. + bytes4 governor46Id = type(IGovernorUpgradeable).interfaceId ^ + type(IERC6372Upgradeable).interfaceId ^ + governorCancelId; + + // For the updated interface id in v4.9, we use governorCancelId directly. + // + + // replace the interface checks with return the expected interface ids + bytes4[] memory ids = new bytes4[](4); + ids[0] = governor43Id; + ids[1] = governor46Id; + ids[2] = governorCancelId; + ids[3] = type(IERC1155ReceiverUpgradeable).interfaceId; + return ids; + } +} diff --git a/contracts/test/mocks/GovernorCountingOverridableHarness.sol b/contracts/test/mocks/GovernorCountingOverridableHarness.sol new file mode 100644 index 00000000..15628268 --- /dev/null +++ b/contracts/test/mocks/GovernorCountingOverridableHarness.sol @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.9; + +import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; +import "@openzeppelin/contracts-upgradeable/governance/GovernorUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/governance/extensions/GovernorVotesUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/governance/extensions/GovernorVotesQuorumFractionUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/governance/extensions/GovernorSettingsUpgradeable.sol"; + +import "../../treasury/GovernorCountingOverridable.sol"; + +/** + * @dev This is a concrete contract to test the GovernorCountingOverridable extension. It implements the minimum + * necessary to get a working Governor to test the extension. + */ +contract GovernorCountingOverridableHarness is + Initializable, + GovernorUpgradeable, + GovernorSettingsUpgradeable, + GovernorVotesUpgradeable, + GovernorCountingOverridable +{ + // use non-standard values for these to test if it's really used + uint256 constant QUOTA = 420000; // 42% + uint256 constant QUORUM = 370000; // 37% + + IVotes internal iVotes; // 🍎 + + function initialize(IVotes _votes) public initializer { + iVotes = _votes; + + __Governor_init("GovernorCountingOverridableConcrete"); + __GovernorSettings_init( + 0, /* no voting delay */ + 100, /* 100 blocks voting period */ + 0 /* no minimum proposal threshold */ + ); + + __GovernorVotes_init(iVotes); + __GovernorCountingOverridable_init(QUOTA); + } + + function votes() public view override returns (IVotes) { + return iVotes; + } + + function quorum(uint256 timepoint) public view virtual override returns (uint256) { + uint256 totalSupply = iVotes.getPastTotalSupply(timepoint); + return MathUtils.percOf(totalSupply, QUORUM); + } + + /** + * @dev Expose internal _quorumReached function for testing. + */ + function quorumReached(uint256 proposalId) public view returns (bool) { + return super._quorumReached(proposalId); + } + + /** + * @dev Expose internal _voteSucceeded function for testing. + */ + function voteSucceeded(uint256 proposalId) public view returns (bool) { + return super._voteSucceeded(proposalId); + } + + function proposalThreshold() + public + view + override(GovernorUpgradeable, GovernorSettingsUpgradeable) + returns (uint256) + { + return super.proposalThreshold(); + } +} diff --git a/contracts/test/mocks/LivepeerGovernorUpgradeMock.sol b/contracts/test/mocks/LivepeerGovernorUpgradeMock.sol new file mode 100644 index 00000000..61d844db --- /dev/null +++ b/contracts/test/mocks/LivepeerGovernorUpgradeMock.sol @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.9; + +import "../../treasury/LivepeerGovernor.sol"; + +contract LivepeerGovernorUpgradeMock is LivepeerGovernor { + uint256 public customField; + + constructor(address _controller) LivepeerGovernor(_controller) {} + + function setCustomField(uint256 _customField) external { + customField = _customField; + } +} diff --git a/contracts/test/mocks/VotesMock.sol b/contracts/test/mocks/VotesMock.sol new file mode 100644 index 00000000..c6530cb3 --- /dev/null +++ b/contracts/test/mocks/VotesMock.sol @@ -0,0 +1,112 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.9; + +import "@openzeppelin/contracts-upgradeable/token/ERC20/ERC20Upgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/token/ERC20/extensions/ERC20BurnableUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/token/ERC20/extensions/ERC20SnapshotUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/token/ERC20/extensions/ERC20VotesUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; + +import { IVotes } from "../../treasury/GovernorCountingOverridable.sol"; +import "../../bonding/libraries/SortedArrays.sol"; + +/** + * @dev Minimum implementation of an IVotes interface to test the GovernorCountingOverridable extension. It inherits + * from the default ERC20VotesUpgradeable implementation but overrides the voting power functions to provide power to + * delegators as well (to be made overridable by the GovernorCountingOverridable extension). + */ +contract VotesMock is + Initializable, + ERC20Upgradeable, + ERC20BurnableUpgradeable, + OwnableUpgradeable, + ERC20VotesUpgradeable, + IVotes +{ + function initialize() public initializer { + __ERC20_init("VotesMock", "VTCK"); + __ERC20Burnable_init(); + __Ownable_init(); + __ERC20Votes_init(); + } + + function delegatedAt(address _account, uint256 _timepoint) external view returns (address) { + _timepoint; // unused + // Blatant simplification that only works in our tests where we never change participants balance during + // proposal voting period. We check and return delegators current state instead of tracking historical values. + return delegates(_account); + } + + /** + * @dev Simulates the behavior of our actual voting power, where the delegator also has voting power which can + * override their transcoder's vote. This is not the case in the OpenZeppelin implementation. + */ + function getPastVotes(address account, uint256 blockNumber) + public + view + override(IVotesUpgradeable, ERC20VotesUpgradeable) + returns (uint256) + { + // Blatant simplification that only works in our tests where we never change participants balance during + // proposal voting period. We check and return delegators current state instead of tracking historical values. + if (delegates(account) != account) { + return balanceOf(account); + } + return super.getPastVotes(account, blockNumber); + } + + /** + * @dev Same as above. Still don't understand why the OZ implementation for these 2 is incompatible, with getPast* + * reverting if you query it with the current round. + */ + function getVotes(address account) + public + view + override(IVotesUpgradeable, ERC20VotesUpgradeable) + returns (uint256) + { + if (delegates(account) != account) { + return balanceOf(account); + } + return super.getVotes(account); + } + + function mint(address to, uint256 amount) public onlyOwner { + _mint(to, amount); + } + + // The following functions are overrides required by Solidity. + + function _afterTokenTransfer( + address from, + address to, + uint256 amount + ) internal override(ERC20Upgradeable, ERC20VotesUpgradeable) { + super._afterTokenTransfer(from, to, amount); + } + + function _mint(address to, uint256 amount) internal override(ERC20Upgradeable, ERC20VotesUpgradeable) { + super._mint(to, amount); + } + + function _burn(address account, uint256 amount) internal override(ERC20Upgradeable, ERC20VotesUpgradeable) { + super._burn(account, amount); + } + + function name() public view override(IVotes, ERC20Upgradeable) returns (string memory) { + return super.name(); + } + + function symbol() public view override(IVotes, ERC20Upgradeable) returns (string memory) { + return super.symbol(); + } + + function decimals() public view override(IVotes, ERC20Upgradeable) returns (uint8) { + return super.decimals(); + } + + function totalSupply() public view override(IVotes, ERC20Upgradeable) returns (uint256) { + return super.totalSupply(); + } +} diff --git a/contracts/treasury/GovernorCountingOverridable.sol b/contracts/treasury/GovernorCountingOverridable.sol new file mode 100644 index 00000000..ef7cb05d --- /dev/null +++ b/contracts/treasury/GovernorCountingOverridable.sol @@ -0,0 +1,225 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.9; + +import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; +import "@openzeppelin/contracts-upgradeable/governance/GovernorUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/interfaces/IERC5805Upgradeable.sol"; + +import "../bonding/libraries/EarningsPool.sol"; +import "../bonding/libraries/EarningsPoolLIP36.sol"; + +import "../Manager.sol"; +import "../IController.sol"; +import "../rounds/IRoundsManager.sol"; +import "./IVotes.sol"; + +/** + * @title GovernorCountingOverridable + * @notice Implements the Counting module from OpenZeppelin Governor with support for delegators overriding their + * delegated transcoder's vote. This module is used through inheritance by the Governor contract. + */ +abstract contract GovernorCountingOverridable is Initializable, GovernorUpgradeable { + error InvalidVoteType(uint8 voteType); + error VoteAlreadyCast(); + + /** + * @dev Supported vote types. Matches Governor Bravo ordering. + */ + enum VoteType { + Against, + For, + Abstain + } + + /** + * @dev Tracks state of specicic voters in a single proposal. + */ + struct ProposalVoterState { + bool hasVoted; + VoteType support; + // This vote deductions state is only necessary to support the case where a delegator might vote before their + // transcoder. When that happens, we need to deduct the delegator(s) votes before tallying the transcoder vote. + uint256 deductions; + } + + /** + * @dev Tracks the tallying state for a proposal vote counting logic. + */ + struct ProposalTally { + uint256 againstVotes; + uint256 forVotes; + uint256 abstainVotes; + mapping(address => ProposalVoterState) voters; + } + + // Maps proposal IDs to their corresponding vote tallies. + mapping(uint256 => ProposalTally) private _proposalTallies; + + /** + * @notice The required percentage of "for" votes in relation to the total opinionated votes (for and abstain) for + * a proposal to succeed. Represented as a MathUtils percentage value (e.g. 6 decimal places). + */ + uint256 public quota; + + function __GovernorCountingOverridable_init(uint256 _quota) internal onlyInitializing { + __GovernorCountingOverridable_init_unchained(_quota); + } + + function __GovernorCountingOverridable_init_unchained(uint256 _quota) internal onlyInitializing { + quota = _quota; + } + + /** + * @dev See {IGovernor-COUNTING_MODE}. + */ + // solhint-disable-next-line func-name-mixedcase + function COUNTING_MODE() public pure virtual override returns (string memory) { + return "support=bravo&quorum=for,abstain,against"; + } + + /** + * @dev See {IGovernor-hasVoted}. + */ + function hasVoted(uint256 _proposalId, address _account) public view virtual override returns (bool) { + return _proposalTallies[_proposalId].voters[_account].hasVoted; + } + + /** + * @dev Accessor to the internal vote counts. + */ + function proposalVotes(uint256 _proposalId) + public + view + virtual + returns ( + uint256 againstVotes, + uint256 forVotes, + uint256 abstainVotes + ) + { + ProposalTally storage tally = _proposalTallies[_proposalId]; + return (tally.againstVotes, tally.forVotes, tally.abstainVotes); + } + + /** + * @dev See {Governor-_quorumReached}. + */ + function _quorumReached(uint256 _proposalId) internal view virtual override returns (bool) { + (uint256 againstVotes, uint256 forVotes, uint256 abstainVotes) = proposalVotes(_proposalId); + + uint256 totalVotes = againstVotes + forVotes + abstainVotes; + + return totalVotes >= quorum(proposalSnapshot(_proposalId)); + } + + /** + * @dev See {Governor-_voteSucceeded}. In this module, the forVotes must be at least QUOTA of the total votes. + */ + function _voteSucceeded(uint256 _proposalId) internal view virtual override returns (bool) { + (uint256 againstVotes, uint256 forVotes, ) = proposalVotes(_proposalId); + + // we ignore abstain votes for vote succeeded calculation + uint256 opinionatedVotes = againstVotes + forVotes; + + return forVotes >= MathUtils.percOf(opinionatedVotes, quota); + } + + /** + * @dev See {Governor-_countVote}. In this module, the support follows the `VoteType` enum (from Governor Bravo). + */ + function _countVote( + uint256 _proposalId, + address _account, + uint8 _supportInt, + uint256 _weight, + bytes memory // params + ) internal virtual override { + if (_supportInt > uint8(VoteType.Abstain)) { + revert InvalidVoteType(_supportInt); + } + VoteType support = VoteType(_supportInt); + + ProposalTally storage tally = _proposalTallies[_proposalId]; + ProposalVoterState storage voter = tally.voters[_account]; + + if (voter.hasVoted) { + revert VoteAlreadyCast(); + } + voter.hasVoted = true; + voter.support = support; + + _weight = _handleVoteOverrides(_proposalId, tally, voter, _account, _weight); + + if (support == VoteType.Against) { + tally.againstVotes += _weight; + } else if (support == VoteType.For) { + tally.forVotes += _weight; + } else { + tally.abstainVotes += _weight; + } + } + + /** + * @notice Handles vote overrides that delegators can make to their + * corresponding delegated transcoder votes. Usually only the transcoders + * vote on proposals, but any delegator can change their part of the vote. + * This tracks past votes and deduction on separate mappings in order to + * calculate the effective voting power of each vote. + * @param _proposalId ID of the proposal being voted on + * @param _tally struct where the vote totals are tallied on + * @param _voter struct where the specific voter state is tracked + * @param _account current user making a vote + * @param _weight voting weight of the user making the vote + */ + function _handleVoteOverrides( + uint256 _proposalId, + ProposalTally storage _tally, + ProposalVoterState storage _voter, + address _account, + uint256 _weight + ) internal returns (uint256) { + uint256 timepoint = proposalSnapshot(_proposalId); + address delegate = votes().delegatedAt(_account, timepoint); + + bool isTranscoder = _account == delegate; + if (isTranscoder) { + // deduce weight from any previous delegators for this transcoder to + // make a vote + return _weight - _voter.deductions; + } + + // this is a delegator, so add a deduction to the delegated transcoder + ProposalVoterState storage delegateVoter = _tally.voters[delegate]; + delegateVoter.deductions += _weight; + + if (delegateVoter.hasVoted) { + // this is a delegator overriding its delegated transcoder vote, + // we need to update the current totals to move the weight of + // the delegator vote to the right outcome. + VoteType delegateSupport = delegateVoter.support; + + if (delegateSupport == VoteType.Against) { + _tally.againstVotes -= _weight; + } else if (delegateSupport == VoteType.For) { + _tally.forVotes -= _weight; + } else { + assert(delegateSupport == VoteType.Abstain); + _tally.abstainVotes -= _weight; + } + } + + return _weight; + } + + /** + * @dev Implement in inheriting contract to provide the voting power provider. + */ + function votes() public view virtual returns (IVotes); + + /** + * @dev This empty reserved space is put in place to allow future versions to add new + * variables without shifting down storage in the inheritance chain. + * See https://docs.openzeppelin.com/contracts/4.x/upgradeable#storage_gaps + */ + uint256[48] private __gap; +} diff --git a/contracts/treasury/LivepeerGovernor.sol b/contracts/treasury/LivepeerGovernor.sol new file mode 100644 index 00000000..3f72db2b --- /dev/null +++ b/contracts/treasury/LivepeerGovernor.sol @@ -0,0 +1,168 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.9; + +import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; +import "@openzeppelin/contracts-upgradeable/governance/GovernorUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/governance/extensions/GovernorVotesUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/governance/extensions/GovernorVotesQuorumFractionUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/governance/extensions/GovernorSettingsUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/governance/extensions/GovernorTimelockControlUpgradeable.sol"; + +import "../bonding/libraries/EarningsPool.sol"; +import "../bonding/libraries/EarningsPoolLIP36.sol"; + +import "../ManagerProxyTarget.sol"; +import "../IController.sol"; +import "../rounds/IRoundsManager.sol"; +import "./GovernorCountingOverridable.sol"; +import "./Treasury.sol"; + +/** + * @title LivepeerGovernor + * @notice Core contract for Livepeer governance, starting as the treasury governor. + * @dev If we ever add fields to this class or more extensions, make sure to add a storage gap to our custom + * GovernorCountingOverridable extension. + */ +contract LivepeerGovernor is + ManagerProxyTarget, + Initializable, + GovernorUpgradeable, + GovernorSettingsUpgradeable, + GovernorTimelockControlUpgradeable, + GovernorVotesUpgradeable, + GovernorVotesQuorumFractionUpgradeable, + GovernorCountingOverridable +{ + /** + * @notice TreasuryGovernor constructor. Only invokes constructor of base Manager contract with provided Controller. + * @dev This constructor will not initialize any state variables besides `controller`. The `initialize` function + * must be called through the proxy after construction to initialize the contract's state in the proxy contract. + * @param _controller Address of Controller that this contract will be registered with + */ + /// @custom:oz-upgrades-unsafe-allow constructor + constructor(address _controller) Manager(_controller) { + _disableInitializers(); + } + + /** + * Initializes the LivepeerGovernor instance. This requires the following contracts to have already been deployed + * and registered on the controller: + * - "Treasury" + * - "BondingVotes" + * - "PollCreator" + */ + function initialize( + uint256 initialVotingDelay, + uint256 initialVotingPeriod, + uint256 initialProposalThreshold, + uint256 initialQuorum, + uint256 quota + ) public initializer { + __Governor_init("LivepeerGovernor"); + __GovernorSettings_init(initialVotingDelay, initialVotingPeriod, initialProposalThreshold); + __GovernorTimelockControl_init(treasury()); + + // The GovernorVotes module will hold a fixed reference to the votes contract. If we ever change its address we + // need to call the {bumpGovernorVotesTokenAddress} function to update it in here as well. + __GovernorVotes_init(votes()); + + __GovernorVotesQuorumFraction_init(initialQuorum); + + __GovernorCountingOverridable_init(quota); + } + + /** + * @dev Overrides the quorum denominator from the GovernorVotesQuorumFractionUpgradeable extension. We use + * MathUtils.PERC_DIVISOR so that our quorum numerator is a valid MathUtils fraction. + */ + function quorumDenominator() public view virtual override returns (uint256) { + return MathUtils.PERC_DIVISOR; + } + + /** + * @dev See {GovernorCountingOverridable-votes}. + */ + function votes() public view override returns (IVotes) { + return bondingVotes(); + } + + /** + * @dev This should be called if we ever change the address of the BondingVotes contract. Not a normal flow, but its + * address could still eventually change in the controller so we provide this function as a future-proof commodity. + * This is callable by anyone because it always fetches the current address from the controller, so not exploitable. + */ + function bumpGovernorVotesTokenAddress() external { + token = votes(); + } + + /** + * @dev Returns the BondingVotes contract address from the controller. + */ + function bondingVotes() internal view returns (IVotes) { + return IVotes(controller.getContract(keccak256("BondingVotes"))); + } + + /** + * @dev Returns the Treasury contract address from the controller. + */ + function treasury() internal view returns (Treasury) { + return Treasury(payable(controller.getContract(keccak256("Treasury")))); + } + + // The following functions are overrides required by Solidity. + + function proposalThreshold() + public + view + override(GovernorUpgradeable, GovernorSettingsUpgradeable) + returns (uint256) + { + return super.proposalThreshold(); + } + + function state(uint256 proposalId) + public + view + override(GovernorUpgradeable, GovernorTimelockControlUpgradeable) + returns (ProposalState) + { + return super.state(proposalId); + } + + function _execute( + uint256 proposalId, + address[] memory targets, + uint256[] memory values, + bytes[] memory calldatas, + bytes32 descriptionHash + ) internal override(GovernorUpgradeable, GovernorTimelockControlUpgradeable) { + super._execute(proposalId, targets, values, calldatas, descriptionHash); + } + + function _cancel( + address[] memory targets, + uint256[] memory values, + bytes[] memory calldatas, + bytes32 descriptionHash + ) internal override(GovernorUpgradeable, GovernorTimelockControlUpgradeable) returns (uint256) { + return super._cancel(targets, values, calldatas, descriptionHash); + } + + function _executor() + internal + view + override(GovernorUpgradeable, GovernorTimelockControlUpgradeable) + returns (address) + { + return super._executor(); + } + + function supportsInterface(bytes4 interfaceId) + public + view + override(GovernorUpgradeable, GovernorTimelockControlUpgradeable) + returns (bool) + { + return super.supportsInterface(interfaceId); + } +} diff --git a/contracts/treasury/Treasury.sol b/contracts/treasury/Treasury.sol new file mode 100644 index 00000000..7a5822c0 --- /dev/null +++ b/contracts/treasury/Treasury.sol @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.9; + +import "@openzeppelin/contracts-upgradeable/governance/TimelockControllerUpgradeable.sol"; + +/** + * @title Treasury + * @notice Holder of the treasury and executor of proposals for the LivepeerGovernor. + * @dev This was only really needed because TimelockControllerUpgradeable does not expose a public initializer, so we + * need to inherit and expose the initialization function here. + * + * Even though this contract is upgradeable to fit with the rest of the contracts that expect upgradeable instances, it + * is not used with a proxy, so we don't need to disable initializers in the constructor. + */ +contract Treasury is Initializable, TimelockControllerUpgradeable { + function initialize( + uint256 minDelay, + address[] memory proposers, + address[] memory executors, + address admin + ) external initializer { + __TimelockController_init(minDelay, proposers, executors, admin); + } +} diff --git a/deploy/deploy_livepeer_governor.ts b/deploy/deploy_livepeer_governor.ts new file mode 100644 index 00000000..9e849ed0 --- /dev/null +++ b/deploy/deploy_livepeer_governor.ts @@ -0,0 +1,87 @@ +import {constants} from "ethers" +import {ethers} from "hardhat" +import {HardhatRuntimeEnvironment} from "hardhat/types" +import {DeployFunction} from "hardhat-deploy/types" + +import ContractDeployer from "../utils/deployer" +import {LivepeerGovernor, Treasury} from "../typechain" +import getNetworkConfig from "./migrations.config" + +const PROD_NETWORKS = ["mainnet", "arbitrumMainnet"] + +const isProdNetwork = (name: string): boolean => { + return PROD_NETWORKS.indexOf(name) > -1 +} + +const func: DeployFunction = async function(hre: HardhatRuntimeEnvironment) { + const {deployments, getNamedAccounts} = hre // Get the deployments and getNamedAccounts which are provided by hardhat-deploy + const {deploy} = deployments // the deployments object itself contains the deploy function + + const {deployer} = await getNamedAccounts() // Fetch named accounts from hardhat.config.ts + + const config = getNetworkConfig(hre.network.name) + + const contractDeployer = new ContractDeployer(deploy, deployer, deployments) + const controller = await contractDeployer.fetchDeployedController() + + const treasury = await contractDeployer.deployAndRegister({ + contract: "Treasury", + name: "Treasury", + args: [] + }) + const Treasury: Treasury = await ethers.getContractAt( + "Treasury", + treasury.address + ) + + await Treasury.initialize( + config.treasury.minDelay, + [], // governor will be added as a proposer later + [constants.AddressZero], // let anyone execute proposals + deployer // temporary admin role for deployer + ).then(tx => tx.wait()) + + const livepeerGovernor = await contractDeployer.deployAndRegister({ + contract: "LivepeerGovernor", + name: "LivepeerGovernor", + args: [controller.address], + proxy: true + }) + const LivepeerGovernor: LivepeerGovernor = await ethers.getContractAt( + "LivepeerGovernor", + livepeerGovernor.address + ) + + await LivepeerGovernor.initialize( + config.livepeerGovernor.initialVotingDelay, + config.livepeerGovernor.initialVotingPeriod, + config.livepeerGovernor.initialProposalThreshold, + config.livepeerGovernor.initialQuorum, + config.livepeerGovernor.quota + ).then(tx => tx.wait()) + + // Now grant proposer and executor roles to governor and renounce deployer admin role + const roles = { + proposer: await Treasury.PROPOSER_ROLE(), + canceller: await Treasury.CANCELLER_ROLE(), + executor: await Treasury.EXECUTOR_ROLE(), + admin: await Treasury.TIMELOCK_ADMIN_ROLE() + } + for (const role of [roles.proposer, roles.canceller, roles.executor]) { + await Treasury.grantRole(role, LivepeerGovernor.address).then(tx => + tx.wait() + ) + } + + if (isProdNetwork(hre.network.name)) { + // TODO: Make sure we really want this. Multi-sig would have root to everything + await Treasury.grantRole(roles.admin, config.governor.owner).then(tx => + tx.wait() + ) + } + await Treasury.renounceRole(roles.admin, deployer).then(tx => tx.wait()) +} + +func.dependencies = ["Contracts", "Poll"] +func.tags = ["LivepeerGovernor"] +export default func diff --git a/deploy/migrations.config.ts b/deploy/migrations.config.ts index b2aba13c..e9ecf23b 100644 --- a/deploy/migrations.config.ts +++ b/deploy/migrations.config.ts @@ -52,6 +52,16 @@ const defaultConfig = { inflation: 137, inflationChange: 3, targetBondingRate: 500000 + }, + treasury: { + minDelay: 0 // 0s initial proposal execution delay + }, + livepeerGovernor: { + initialVotingDelay: 1, // 1 round + initialVotingPeriod: 10, // 10 rounds + initialProposalThreshold: ethers.utils.parseEther("100"), // 100 LPT + initialQuorum: 333300, // 33% + quota: 500000 // 50% } } diff --git a/test/helpers/governorEnums.js b/test/helpers/governorEnums.js new file mode 100644 index 00000000..c59f3fbc --- /dev/null +++ b/test/helpers/governorEnums.js @@ -0,0 +1,16 @@ +export const VoteType = { + Against: 0, + For: 1, + Abstain: 2 +} + +export const ProposalState = { + Pending: 0, + Active: 1, + Canceled: 2, + Defeated: 3, + Succeeded: 4, + Queued: 5, + Expired: 6, + Executed: 7 +} diff --git a/test/helpers/math.js b/test/helpers/math.js index 8e67f337..39886a3f 100644 --- a/test/helpers/math.js +++ b/test/helpers/math.js @@ -8,7 +8,7 @@ const percPoints = (a, b) => { // Returns a * (b / c) scaled by PERC_DIVISOR // See percOf() in contracts/libraries/MathUtils.sol -const percOf = (a, b, c) => { +const percOf = (a, b, c = BigNumber.from(constants.PERC_DIVISOR)) => { return _percOf(a, b, c, BigNumber.from(constants.PERC_DIVISOR)) } @@ -16,7 +16,7 @@ const precise = { percPoints: (a, b) => { return _percPoints(a, b, constants.PERC_DIVISOR_PRECISE) }, - percOf: (a, b, c) => { + percOf: (a, b, c = constants.PERC_DIVISOR_PRECISE) => { return _percOf(a, b, c, constants.PERC_DIVISOR_PRECISE) } } @@ -25,7 +25,7 @@ const v2 = { percPoints: (a, b) => { return _percPoints(a, b, constants.PERC_DIVISOR_V2) }, - percOf: (a, b, c) => { + percOf: (a, b, c = constants.PERC_DIVISOR_V2) => { return _percOf(a, b, c, constants.PERC_DIVISOR_V2) } } diff --git a/test/helpers/setupIntegrationTest.ts b/test/helpers/setupIntegrationTest.ts index 4c3a2935..1514b0e0 100644 --- a/test/helpers/setupIntegrationTest.ts +++ b/test/helpers/setupIntegrationTest.ts @@ -3,8 +3,12 @@ import {GenericMock__factory} from "../../typechain" import {contractId} from "../../utils/helpers" const setupIntegrationTest = deployments.createFixture( - async ({deployments, getNamedAccounts, ethers}) => { - const fixture = await deployments.fixture(["Contracts"]) + async ( + {deployments, getNamedAccounts, ethers}, + opts?: { tags: string[] } + ) => { + const tags = opts?.tags ?? ["Contracts"] + const fixture = await deployments.fixture(tags) const {deployer} = await getNamedAccounts() const signer = await ethers.getSigner(deployer) @@ -33,4 +37,4 @@ const setupIntegrationTest = deployments.createFixture( } ) -module.exports = setupIntegrationTest +export default setupIntegrationTest diff --git a/test/integration/LivepeerGovernor.ts b/test/integration/LivepeerGovernor.ts new file mode 100644 index 00000000..7a6c7d80 --- /dev/null +++ b/test/integration/LivepeerGovernor.ts @@ -0,0 +1,513 @@ +import {ethers} from "hardhat" +import {SignerWithAddress as Signer} from "@nomiclabs/hardhat-ethers/dist/src/signers" +import {BigNumberish} from "ethers" +import chai, {assert, expect} from "chai" +import {solidity} from "ethereum-waffle" + +import {contractId} from "../../utils/helpers" +import setupIntegrationTest from "../helpers/setupIntegrationTest" +import { + AdjustableRoundsManager, + BondingManager, + BondingVotes, + Controller, + GovernorInterfacesFixture, + LivepeerGovernor, + LivepeerGovernorUpgradeMock, + LivepeerToken, + PollCreator, + Treasury +} from "../../typechain" +import {ProposalState, VoteType} from "../helpers/governorEnums" +import RPC from "../../utils/rpc" +import {constants} from "../../utils/constants" + +chai.use(solidity) + +describe("LivepeerGovernor", () => { + let rpc: RPC + let controller: Controller + + let roundsManager: AdjustableRoundsManager + let bondingManager: BondingManager + let bondingVotes: BondingVotes + let token: LivepeerToken + let pollCreator: PollCreator + + let treasury: Treasury + let governor: LivepeerGovernor + let governorTarget: LivepeerGovernor + + let signers: Signer[] + let proposer: Signer // the only participant here + let roundLength: number + + before(async () => { + rpc = new RPC((global as any).web3) + signers = await ethers.getSigners() + proposer = signers[0] + + const fixture = await setupIntegrationTest({ + tags: ["LivepeerGovernor"] + }) + controller = await ethers.getContractAt( + "Controller", + fixture.Controller.address + ) + roundsManager = await ethers.getContractAt( + "AdjustableRoundsManager", + fixture.AdjustableRoundsManager.address + ) + roundLength = (await roundsManager.roundLength()).toNumber() + bondingManager = await ethers.getContractAt( + "BondingManager", + fixture.BondingManager.address + ) + bondingVotes = await ethers.getContractAt( + "BondingVotes", + fixture.BondingVotes.address + ) + token = await ethers.getContractAt( + "LivepeerToken", + fixture.LivepeerToken.address + ) + pollCreator = await ethers.getContractAt( + "PollCreator", + fixture.PollCreator.address + ) + + treasury = await ethers.getContractAt( + "Treasury", + fixture.Treasury.address + ) + governor = await ethers.getContractAt( + "LivepeerGovernor", + fixture.LivepeerGovernor.address + ) + governorTarget = await ethers.getContractAt( + "LivepeerGovernor", + fixture.LivepeerGovernorTarget.address + ) + + await controller.unpause() + + await bond(proposer, ethers.utils.parseEther("100"), proposer) + + // the bond checkpoints on the next round, and Governor.propose() + // checks the previous round, so we need to wait 2 rounds here + await waitRounds(2) + }) + + let snapshotId: string + + beforeEach(async () => { + snapshotId = await rpc.snapshot() + }) + + afterEach(async () => { + await rpc.revert(snapshotId) + }) + + async function bond( + delegator: Signer, + amount: BigNumberish, + transcoder: Signer + ) { + await token.transfer(delegator.address, amount) + await token.connect(delegator).approve(bondingManager.address, amount) + await bondingManager.connect(delegator).bond(amount, transcoder.address) + } + + async function waitRounds(rounds: number) { + for (let i = 0; i < rounds; i++) { + await roundsManager.mineBlocks(roundLength) + await roundsManager.initializeRound() + } + } + + async function createProposal( + signer: Signer, + target: string, + functionData: string, + description: string + ) { + const execArgs: GovernorExecuteArgs = [ + [target], + [0], + [functionData], + description + ] + const tx = await governor + .connect(signer) + .propose.apply(governor, execArgs) + // after the propose calls, description is replaced with hash + execArgs[3] = ethers.utils.solidityKeccak256(["string"], [description]) + + const filter = governor.filters.ProposalCreated() + const events = await governor.queryFilter( + filter, + tx.blockNumber, + tx.blockNumber + ) + const proposalId = events[0].args[0] + + return [proposalId, execArgs] as const + } + + type GovernorExecuteArgs = [string[], BigNumberish[], string[], string] + + async function governorExecute( + signer: Signer, + target: string, + functionData: string, + description: string, + beforeExecute?: (args: GovernorExecuteArgs) => Promise + ) { + const [proposalId, execArgs] = await createProposal( + signer, + target, + functionData, + description + ) + + // let the voting begin + await waitRounds(2) + + await governor.connect(signer).castVote(proposalId, VoteType.For) + + await waitRounds(10) + + await governor.connect(signer).queue.apply(governor, execArgs) + + if (beforeExecute) { + await beforeExecute(execArgs) + } + await governor.connect(signer).execute.apply(governor, execArgs) + + assert.equal(await governor.state(proposalId), ProposalState.Executed) + } + + it("ensure deployment success", async () => { + assert.equal(await governor.name(), "LivepeerGovernor") + }) + + describe("upgradeability", () => { + describe("target", () => { + it("should implement ManagerProxyTarget", async () => { + // these would get handled by the proxy, so check the target state directly + assert.equal( + await governorTarget.controller(), + controller.address + ) + assert.equal( + await governorTarget.targetContractId(), + constants.NULL_BYTES + ) + }) + + it("should not be initializable", async () => { + // revert msg is misleading, but it's not initializable because initializers are disabled + await expect( + governorTarget.initialize(0, 0, 0, 0, 0) + ).to.be.revertedWith( + "Initializable: contract is already initialized" + ) + + // to be more certain, we check if the `initialized` event was emitted with MaxInt8 + const filter = governorTarget.filters.Initialized() + const events = await governorTarget.queryFilter( + filter, + 0, + "latest" + ) + assert.equal(events.length, 1) + assert.equal(events[0].args[0], 255) // MaxUint8 (disabled) instead of 1 (initialized) + }) + }) + + describe("proxy", () => { + let newGovernorTarget: LivepeerGovernorUpgradeMock + + before(async () => { + const factory = await ethers.getContractFactory( + "LivepeerGovernorUpgradeMock" + ) + newGovernorTarget = (await factory.deploy( + controller.address + )) as LivepeerGovernorUpgradeMock + }) + + it("should have the right configuration", async () => { + assert.equal(await governor.controller(), controller.address) + assert.equal( + await governor.targetContractId(), + contractId("LivepeerGovernorTarget") + ) + }) + + it("should not be re-initializable", async () => { + await expect( + governor.initialize(0, 0, 0, 0, 0) + ).to.be.revertedWith( + "Initializable: contract is already initialized" + ) + + // check if there has been a regular initialization in the past + const filter = governor.filters.Initialized() + const events = await governor.queryFilter(filter, 0, "latest") + assert.equal(events.length, 1) + assert.equal(events[0].args[0], 1) // 1 (initialized) instead of MaxUint8 (disabled) + }) + + it("should allow upgrades", async () => { + const [, gitCommitHash] = await controller.getContractInfo( + contractId("LivepeerGovernorTarget") + ) + await controller.setContractInfo( + contractId("LivepeerGovernorTarget"), + newGovernorTarget.address, + gitCommitHash + ) + + // should keep initialized state + await expect( + governor.initialize(0, 0, 0, 0, 0) + ).to.be.revertedWith( + "Initializable: contract is already initialized" + ) + + // should have the new logic + const newGovernor = (await ethers.getContractAt( + "LivepeerGovernorUpgradeMock", // same proxy, just grab the new abi + governor.address + )) as LivepeerGovernorUpgradeMock + + assert.equal( + await newGovernor.customField().then(bn => bn.toNumber()), + 0 + ) + + await newGovernor.setCustomField(123) + assert.equal( + await newGovernor.customField().then(bn => bn.toNumber()), + 123 + ) + }) + }) + }) + + describe("supportsInterface", () => { + const INVALID_ID = "0xffffffff" + + let interfacesProvider: GovernorInterfacesFixture + + before(async () => { + const factory = await ethers.getContractFactory( + "GovernorInterfacesFixture" + ) + interfacesProvider = + (await factory.deploy()) as GovernorInterfacesFixture + }) + + it("should support all Governor interfaces", async () => { + const interfaces = await interfacesProvider.GovernorInterfaces() + for (const iface of interfaces) { + assert.isTrue(await governor.supportsInterface(iface)) + } + }) + + it("should support TimelockController interface", async () => { + const iface = + await interfacesProvider.TimelockUpgradeableInterface() + assert.isTrue(await governor.supportsInterface(iface)) + }) + + it("should not support an invalid interface", async () => { + assert.isFalse(await governor.supportsInterface(INVALID_ID)) + }) + }) + + describe("treasury timelock", async () => { + it("should have 0 initial minDelay", async () => { + const minDelay = await treasury.getMinDelay() + assert.equal(minDelay.toNumber(), 0) + }) + + it("should be able to cancel proposal before voting starts", async () => { + // Cancellation is not really a timelock feature, but we only override _cancel() because we inherit from + // GovernorTimelockControlUpgradeable, so test that cancelling still works here. + + const [proposalId, cancelArgs] = await createProposal( + signers[0], + treasury.address, + token.interface.encodeFunctionData("transfer", [ + signers[1].address, + ethers.utils.parseEther("500") + ]), + "sample transfer" + ) + await governor + .connect(signers[0]) + .cancel.apply(governor, cancelArgs) + + assert.equal( + await governor.state(proposalId), + ProposalState.Canceled + ) + }) + + describe("should allow updating minDelay", () => { + const testDelay = 3 * 24 * 60 * 60 // 3 days + + beforeEach(async () => { + await governorExecute( + signers[0], + treasury.address, + treasury.interface.encodeFunctionData("updateDelay", [ + testDelay + ]), + "set treasury minDelay to 3 days" + ) + }) + + it("should return new value", async () => { + const minDelay = await treasury.getMinDelay() + assert.equal(minDelay.toNumber(), testDelay) + }) + + it("should effectively delay execution", async () => { + // default execute code will not wait for the 3 days after queueing + const tx = governorExecute( + signers[0], + token.address, + token.interface.encodeFunctionData("transfer", [ + signers[1].address, + ethers.utils.parseEther("500") + ]), + "sample transfer" + ) + + await expect(tx).to.be.revertedWith( + "TimelockController: operation is not ready" + ) + }) + + it("should work after waiting for the delay", async () => { + await governorExecute( + signers[0], + treasury.address, + treasury.interface.encodeFunctionData("updateDelay", [0]), + "set treasury minDelay back to 0", + // wait for 3 day-long rounds before executing + () => rpc.wait(3, 24 * 60 * 60) + ) + + const minDelay = await treasury.getMinDelay() + assert.equal(minDelay.toNumber(), 0) + }) + }) + }) + + describe("settings", () => { + const testProperty = ( + name: string, + initialValue: BigNumberish, + setFunc?: string, + newValue?: BigNumberish + ) => { + describe(name, () => { + let getter: typeof governor["votingDelay"] + + before(async () => { + getter = governor[ + name as keyof LivepeerGovernor + ] as typeof getter + }) + + it(`should start as ${initialValue}`, async () => { + const value = await getter() + assert.equal(value.toString(), initialValue.toString()) + }) + + if (setFunc && newValue) { + it("should be updatable", async () => { + await governorExecute( + proposer, + governor.address, + governor.interface.encodeFunctionData( + setFunc as any, + [newValue] + ), + `set ${name} to ${newValue}` + ) + + const value = await getter() + assert.equal(value.toString(), newValue.toString()) + }) + } + }) + } + + testProperty("votingDelay", 1, "setVotingDelay", 5) + testProperty("votingPeriod", 10, "setVotingPeriod", 14) + testProperty( + "proposalThreshold", + ethers.utils.parseEther("100"), + "setProposalThreshold", + ethers.utils.parseEther("50") + ) + testProperty( + "quorumNumerator()", + 333300, // 33.33% + "updateQuorumNumerator", + 500000 // 50% + ) + testProperty("quorumDenominator", constants.PERC_DIVISOR) + }) + + describe("voting module", () => { + it("should use BondingCheckpointVotes as the token", async () => { + const tokenAddr = await governor.token() + assert.equal(tokenAddr, bondingVotes.address) + }) + + describe("bumpGovernorVotesTokenAddress()", () => { + let newBondingVotes: BondingVotes + + before(async () => { + const factory = await ethers.getContractFactory("BondingVotes") + newBondingVotes = (await factory.deploy( + controller.address + )) as BondingVotes + + // Replace the proxy directly + const id = contractId("BondingVotes") + const [, gitCommitHash] = await controller.getContractInfo(id) + await controller.setContractInfo( + id, + newBondingVotes.address, + gitCommitHash + ) + }) + + it("should not update the reference automatically", async () => { + assert.equal(await governor.token(), bondingVotes.address) + }) + + it("should update reference after calling bumpGovernorVotesTokenAddress", async () => { + await governor.bumpGovernorVotesTokenAddress() + assert.equal(await governor.token(), newBondingVotes.address) + }) + }) + + describe("quota()", () => { + it("should return the same value as PollCreator", async () => { + const expected = await pollCreator + .QUOTA() + .then(bn => bn.toString()) + const actual = await governor.quota().then(bn => bn.toString()) + assert.equal(actual, expected) + }) + }) + }) +}) diff --git a/test/unit/GovernorCountingOverridable.js b/test/unit/GovernorCountingOverridable.js new file mode 100644 index 00000000..74e67b95 --- /dev/null +++ b/test/unit/GovernorCountingOverridable.js @@ -0,0 +1,447 @@ +import {assert} from "chai" +import {ethers, web3} from "hardhat" +const BigNumber = ethers.BigNumber +import chai from "chai" +import {solidity} from "ethereum-waffle" + +import Fixture from "./helpers/Fixture" +import math from "../helpers/math" +import {ProposalState, VoteType} from "../helpers/governorEnums" + +chai.use(solidity) +const {expect} = chai + +describe("GovernorCountingOverridable", () => { + let signers + let fixture + + let votes + let governor + + let proposer + let proposalId + let voters + + const initVoter = async ({ + signer, + amount = ethers.utils.parseEther("1"), + delegateAddress = signer.address + }) => { + await votes.mint(signer.address, amount) + await votes.connect(signer).delegate(delegateAddress) + return signer + } + + const createProposal = async (proposer, description) => { + const tx = await governor + .connect(proposer) + .propose([proposer.address], [100], ["0x"], description) + + const filter = governor.filters.ProposalCreated() + const events = await governor.queryFilter( + filter, + tx.blockNumber, + tx.blockNumber + ) + const proposalId = events[0].args[0] + return proposalId + } + + before(async () => { + signers = await ethers.getSigners() + proposer = signers[0] + voters = signers.slice(1, 11) + + fixture = new Fixture(web3) + await fixture.deploy() + + // setup votes token + + const votesFac = await ethers.getContractFactory("VotesMock") + votes = await votesFac.deploy() + await votes.initialize() + + await initVoter({signer: proposer}) + for (const i = 1; i <= voters.length; i++) { + await initVoter({ + signer: voters[i - 1], + amount: ethers.utils.parseEther("1").mul(i) + }) + } + + // setup governor + + const governorFac = await ethers.getContractFactory( + "GovernorCountingOverridableHarness" + ) + governor = await governorFac.deploy() + await governor.initialize(votes.address) + + await signers[99].sendTransaction({ + to: governor.address, + value: ethers.utils.parseEther("100") + }) + + proposalId = await createProposal(proposer, "Steal all the money") + + // skip a block so voting can start + await fixture.rpc.wait() + }) + + beforeEach(async () => { + await fixture.setUp() + }) + + afterEach(async () => { + await fixture.tearDown() + }) + + describe("test fixture", () => { + const QUOTA = BigNumber.from(420000) // 42% + const QUORUM = BigNumber.from(370000) // 37% + const TOTAL_SUPPLY = ethers.utils.parseEther("56") // 1 (proposer) + (1 + 2 ... 9 + 10) (voters) + + let proposalSnapshot + + beforeEach(async () => { + proposalSnapshot = await governor.proposalSnapshot(proposalId) + }) + + it("quota should be 42%", async () => { + const quota = await governor.quota() + assert.equal(quota.toString(), QUOTA.toString()) + }) + + it("total supply should be 56 VTCK", async () => { + const totalSupply = await votes.getPastTotalSupply(proposalSnapshot) + assert.equal(totalSupply.toString(), TOTAL_SUPPLY.toString()) + }) + + it("quorum should be 37% of total supply", async () => { + const expectedQuorum = math.percOf(TOTAL_SUPPLY, QUORUM) + + const quorum = await governor.quorum(proposalSnapshot) + assert.equal(quorum.toString(), expectedQuorum.toString()) + }) + + it("it should use the block number as clock", async () => { + assert.equal( + await governor.clock(), + await ethers.provider.getBlockNumber() + ) + }) + }) + + describe("COUNTING_MODE", () => { + it("should include bravo support and all vote types on quorum count", async () => { + assert.equal( + await governor.COUNTING_MODE(), + "support=bravo&quorum=for,abstain,against" + ) + }) + }) + + describe("hasVoted", () => { + it("should return false for users that haven't voted", async () => { + for (let i = 0; i < 10; i++) { + assert.isFalse( + await governor.hasVoted(proposalId, signers[i].address) + ) + } + }) + + it("should return true after voting", async () => { + await governor.connect(voters[0]).castVote(proposalId, VoteType.For) + + assert.isTrue( + await governor.hasVoted(proposalId, voters[0].address) + ) + }) + }) + + describe("proposalVotes", () => { + it("should return the sum of all votes made of each type", async () => { + // against, for abstain, as per bravo ordering + const tally = [0, 0, 0] + + const checkTally = async () => { + const ether = ethers.utils.parseEther("1") + const expected = tally.map(c => ether.mul(c).toString()) + + const votes = await governor + .proposalVotes(proposalId) + .then(v => v.map(v => v.toString())) + + assert.deepEqual(votes, expected) + } + + for (let i = 1; i <= 10; i++) { + await checkTally() + + // Each voter has a voting power of {i} VTCK + const voteType = + i % 2 ? + VoteType.Against : // 25 Against (1 + 3 + 5 + 7 + 9) + i % 3 ? + VoteType.For : // 24 For (2 + 4 + 8 + 10) + VoteType.Abstain // 6 abstain (6) + + await governor + .connect(voters[i - 1]) + .castVote(proposalId, voteType) + tally[voteType] += i + } + + // sanity check + assert.deepEqual(tally, [25, 24, 6]) + await checkTally() + + await fixture.rpc.wait(100) + + assert.equal( + await governor.state(proposalId), + ProposalState.Succeeded // funds were stolen! + ) + }) + }) + + describe("_quorumReached", () => { + it("should return false if less than the quorum has voted", async () => { + // results in a 35.7% participation, just below the quorum of 37% + const voterIdxs = [1, 2, 3, 4, 10] + + assert.isFalse(await governor.quorumReached(proposalId)) + for (const i of voterIdxs) { + await governor + .connect(voters[i - 1]) + .castVote(proposalId, i % 3) // should count all vote types + + assert.isFalse(await governor.quorumReached(proposalId)) + } + }) + + it("should return true after quorum has voted", async () => { + // results in a 37.5% participation, above quorum of 37% + const voterIdxs = [1, 2, 3, 4, 5, 6] + + for (const i of voterIdxs) { + await governor + .connect(voters[i - 1]) + .castVote(proposalId, i % 3) // should count all vote types + } + assert.isTrue(await governor.quorumReached(proposalId)) + }) + }) + + describe("_voteSucceeded", () => { + it("should return false when less than the quota voted For", async () => { + // results in a 41.8% For votes, just below the quota of 42% + const forVotersIdxs = [1, 2, 3, 4, 5, 8] + + // starts as true as 0>=0, governor never uses it without checking quorum + assert.isTrue(await governor.voteSucceeded(proposalId)) + + const [forVotes, totalVotes] = [0, 0] + for (let i = 1; i <= 10; i++) { + const voteType = forVotersIdxs.includes(i) ? + VoteType.For : + VoteType.Against + + await governor + .connect(voters[i - 1]) + .castVote(proposalId, voteType) + + totalVotes += i + if (voteType === VoteType.For) { + forVotes += i + } + + assert.equal( + await governor.voteSucceeded(proposalId), + forVotes > Math.floor(0.42 * totalVotes) + ) + } + + // double check the expected end result + assert.isFalse(await governor.voteSucceeded(proposalId)) + + await fixture.rpc.wait(100) + + assert.equal( + await governor.state(proposalId), // calls _voteSucceeded internally + ProposalState.Defeated // not enough for votes + ) + }) + + it("should return true if For votes are higher than quota", async () => { + // results in 43.6% For votes, above the quota of 42% + const forVotersIdxs = [1, 2, 3, 4, 5, 9] + + for (let i = 1; i <= 10; i++) { + const voteType = forVotersIdxs.includes(i) ? + VoteType.For : + VoteType.Against + + await governor + .connect(voters[i - 1]) + .castVote(proposalId, voteType) + } + + assert.isTrue(await governor.voteSucceeded(proposalId)) + + await fixture.rpc.wait(100) + + assert.equal( + await governor.state(proposalId), // calls _voteSucceeded internally + ProposalState.Succeeded // money stolen :( + ) + }) + + it("should ignore abstain votes", async () => { + const multiVote = async (idxs, support) => { + for (const i of idxs) { + await governor + .connect(voters[i - 1]) + .castVote(proposalId, support) + } + } + + await multiVote([1, 2, 3], VoteType.For) + await multiVote([4, 5], VoteType.Against) + // 40% For votes at this point, if Against was counted as For it'd change + assert.isFalse(await governor.voteSucceeded(proposalId)) + + await multiVote([6], VoteType.Abstain) + // does't make it true (not counted as For) + assert.isFalse(await governor.voteSucceeded(proposalId)) + + // now tip the scales + await multiVote([7], VoteType.For) + assert.isTrue(await governor.voteSucceeded(proposalId)) + + await multiVote([8, 9, 10], VoteType.Abstain) + // doesn't make it false either (not counted as Against) + assert.isTrue(await governor.voteSucceeded(proposalId)) + }) + }) + + describe("_countVote", () => { + let delegators + let transcoders + + // override proposalId in these tests + let proposalId + + beforeEach(async () => { + // rename them here for simpler reasoning + transcoders = voters + delegators = signers.slice(11, 21) + + for (const i = 1; i <= delegators.length; i++) { + await initVoter({ + signer: delegators[i - 1], + amount: ethers.utils.parseEther("1").mul(i), + // with this the `transcoders` should have 2x their voting power + delegateAddress: transcoders[i - 1].address + }) + } + + // create another proposal so it grabs the new snapshot + proposalId = await createProposal( + proposer, + "Totally not steal all the money" + ) + await fixture.rpc.wait() + }) + + const expectVotes = async expected => { + expected = expected.map(e => + ethers.utils.parseEther("1").mul(e).toString() + ) + + const votes = await governor + .proposalVotes(proposalId) + .then(v => v.map(v => v.toString())) + assert.deepEqual(votes, expected) + } + + it("should fail on invalid vote type", async () => { + await expect( + governor.connect(transcoders[0]).castVote(proposalId, 7) + ).to.be.revertedWith("InvalidVoteType(7)") + }) + + it("should fail on duplicate votes", async () => { + await governor + .connect(transcoders[0]) + .castVote(proposalId, VoteType.For) + + await expect( + governor + .connect(transcoders[0]) + .castVote(proposalId, VoteType.For) + ).to.be.revertedWith("VoteAlreadyCast()") + }) + + describe("overrides", () => { + for (const transVote of Object.keys(VoteType)) { + describe(`transcoder votes ${transVote} first`, () => { + beforeEach(async () => { + await governor + .connect(transcoders[0]) + .castVote(proposalId, VoteType[transVote]) + }) + + it("should count transcoder votes with delegations", async () => { + const expected = [0, 0, 0] + expected[VoteType[transVote]] += 2 + + await expectVotes(expected) + }) + + for (const delVote of Object.keys(VoteType)) { + describe(`delegator votes ${delVote} after`, () => { + beforeEach(async () => { + await governor + .connect(delegators[0]) + .castVote(proposalId, VoteType[delVote]) + }) + + it("should count delegator votes and deduct transcoder", async () => { + const expected = [0, 0, 0] + expected[VoteType[transVote]] += 1 + expected[VoteType[delVote]] += 1 + + await expectVotes(expected) + }) + }) + } + }) + } + + describe("delegator votes first", () => { + beforeEach(async () => { + await governor + .connect(delegators[0]) + .castVote(proposalId, VoteType.Against) + }) + + it("should count delegator votes", async () => { + await expectVotes([1, 0, 0]) + }) + + describe("transcoder votes after", () => { + beforeEach(async () => { + await governor + .connect(transcoders[0]) + .castVote(proposalId, VoteType.Abstain) + }) + + it("should count transcoder votes without delegation", async () => { + await expectVotes([1, 0, 1]) + }) + }) + }) + }) + }) +}) diff --git a/utils/deployer.ts b/utils/deployer.ts index fe65d7b6..f3866f57 100644 --- a/utils/deployer.ts +++ b/utils/deployer.ts @@ -69,6 +69,15 @@ export default class ContractDeployer { return this.controller } + async fetchDeployedController(): Promise { + const deployment = await this.deployments.get("Controller") + this.controller = (await ethers.getContractAt( + "Controller", + deployment.address + )) as Controller + return this.controller + } + async deployAndRegister(config: { contract: string name: string