diff --git a/foundry.toml b/foundry.toml index 827f31f..53025f6 100644 --- a/foundry.toml +++ b/foundry.toml @@ -17,6 +17,7 @@ gas_reports = [ "GhostDistributor", "GhostBondingCalculator", "GhostTreasury", + "GhostGovernorExposed", ] remappings = [ "@openzeppelin-contracts/=dependencies/@openzeppelin-contracts-5.0.2/", diff --git a/src/GhstERC20.sol b/src/GhstERC20.sol index 7edfaef..2b922ba 100644 --- a/src/GhstERC20.sol +++ b/src/GhstERC20.sol @@ -13,6 +13,8 @@ contract Ghost is IGHST, ERC20, ERC20Permit, ERC20Votes { address public override stnk; address private _initializer; + error GhostDelegationIsBuiltIn(); + constructor(address _stnk, string memory name, string memory symbol) ERC20(name, symbol) ERC20Permit(name) @@ -37,6 +39,21 @@ contract Ghost is IGHST, ERC20, ERC20Permit, ERC20Votes { _burn(_from, _amount); } + function delegate(address) public override pure { + revert GhostDelegationIsBuiltIn(); + } + + function delegateBySig( + address, + uint256, + uint256, + uint8, + bytes32, + bytes32 + ) public override pure { + revert GhostDelegationIsBuiltIn(); + } + function index() public view override returns (uint256) { return ISTNK(stnk).index(); } @@ -49,11 +66,15 @@ contract Ghost is IGHST, ERC20, ERC20Permit, ERC20Votes { return _amount * 1e18 / index(); } + function nonces(address owner) public view override(ERC20Permit, Nonces) returns (uint256) { + return super.nonces(owner); + } + + function delegates(address account) public pure override returns (address) { + return account; + } + function _update(address from, address to, uint256 amount) internal override(ERC20, ERC20Votes) { super._update(from, to, amount); } - - function nonces(address owner) public view virtual override(ERC20Permit, Nonces) returns (uint256) { - return super.nonces(owner); - } } diff --git a/src/governance/GhostGovernor.sol b/src/governance/GhostGovernor.sol index 2597c15..62c4f5c 100644 --- a/src/governance/GhostGovernor.sol +++ b/src/governance/GhostGovernor.sol @@ -2,118 +2,184 @@ pragma solidity ^0.8.20; import {IGovernor, Governor} from "@openzeppelin-contracts/governance/Governor.sol"; -import {GovernorCountingSimple} from "@openzeppelin-contracts/governance/extensions/GovernorCountingSimple.sol"; +import {GovernorPreventLateQuorum} from "@openzeppelin-contracts/governance/extensions/GovernorPreventLateQuorum.sol"; +import {GovernorSettings} from "@openzeppelin-contracts/governance/extensions/GovernorSettings.sol"; +import {GovernorStorage} from "@openzeppelin-contracts/governance/extensions/GovernorStorage.sol"; import {GovernorVotes} from "@openzeppelin-contracts/governance/extensions/GovernorVotes.sol"; import {GovernorVotesQuorumFraction} from "@openzeppelin-contracts/governance/extensions/GovernorVotesQuorumFraction.sol"; -import {GovernorTimelockControl} from "@openzeppelin-contracts/governance/extensions/GovernorTimelockControl.sol"; -import {TimelockController} from "@openzeppelin-contracts/governance/TimelockController.sol"; import {IVotes} from "@openzeppelin-contracts/governance/utils/IVotes.sol"; -import {IERC165} from "@openzeppelin-contracts/interfaces/IERC165.sol"; +import {ReentrancyGuard} from "@openzeppelin-contracts/utils/ReentrancyGuard.sol"; + +import "@openzeppelin-contracts/token/ERC20/utils/SafeERC20.sol"; +import "@openzeppelin-contracts/token/ERC20/IERC20.sol"; + +import {GovernorGhostCounting} from "./GovernorGhostCounting.sol"; +import {Babylonian} from "../libraries/FixedPoint.sol"; contract GhostGovernor is - Governor, - GovernorCountingSimple, + GovernorGhostCounting, + GovernorPreventLateQuorum, + GovernorSettings, + GovernorStorage, GovernorVotes, GovernorVotesQuorumFraction, - GovernorTimelockControl + ReentrancyGuard { - uint256 private immutable _innerVotingDelay; - uint256 private immutable _innerVotingPeriod; + using SafeERC20 for IERC20; + + uint256 public activeProposedLock; + uint256 private _lastProposalId; + mapping(uint256 => uint256) public lockedAmounts; + + error ProposerNotEnoughVotes(uint256 proposalVotes, uint256 accumulatedVotes); + error InsufficientFundsToExtend(uint256 votes, uint256 neededVotes); constructor( IVotes _ghst, - TimelockController _timelock, - uint256 _fraction, - uint256 _innerVotingDelayValue, - uint256 _innerVotingPeriodValue + uint48 _initialVoteExtension, + uint48 _initialVotingDelay, + uint32 _initialVotingPeriod, + uint256 _initialProposalThreshold, + uint256 _quorumFraction ) - Governor("GhostGovernor") + Governor("GhostGovernorV1") GovernorVotes(_ghst) - GovernorVotesQuorumFraction(_fraction) - GovernorTimelockControl(_timelock) - { - _innerVotingDelay = _innerVotingDelayValue; - _innerVotingPeriod = _innerVotingPeriodValue; - } + GovernorPreventLateQuorum(_initialVoteExtension) + GovernorSettings(_initialVotingDelay, _initialVotingPeriod, _initialProposalThreshold) + GovernorVotesQuorumFraction(_quorumFraction) + {} - function votingDelay() public view override returns (uint256) { - return _innerVotingDelay; - } - - function votingPeriod() public view override returns (uint256) { - return _innerVotingPeriod; - } - - function proposalThreshold() public pure override returns (uint256) { - return 0; - } - - function state(uint256 proposalId) - public - view - override(Governor, GovernorTimelockControl) - returns (ProposalState) - { - return super.state(proposalId); - } - - function proposalNeedsQueuing(uint256 proposalId) + function proposalThreshold() public view virtual - override(Governor, GovernorTimelockControl) - returns (bool) + override(Governor, GovernorSettings) + returns (uint256) { - return super.proposalNeedsQueuing(proposalId); + return super.proposalThreshold(); } - function _queueOperations( + function proposalDeadline(uint256 proposalId) + public + view + virtual + override(Governor, GovernorPreventLateQuorum) + returns (uint256) + { + return super.proposalDeadline(proposalId); + } + + function releaseLocked(uint256 proposalId) public nonReentrant returns (uint256) { + uint256 releaseAmount = lockedAmounts[proposalId]; + address proposer = proposalProposer(proposalId); + + if (releaseAmount > 0 && clock() > proposalDeadline(proposalId)) { + lockedAmounts[proposalId] = 0; + IERC20(address(token())).transfer(proposer, releaseAmount); + return releaseAmount; + } + + return 0; + } + + function state(uint256 proposalId) public view virtual override returns (ProposalState) { + ProposalState result = super.state(proposalId); + + if (result == ProposalState.Active) { + (uint256 againstVotes, uint256 forVotes,) = proposalVotes(proposalId); + uint256 halfTotalVotes = token().getPastTotalSupply(proposalSnapshot(proposalId)) / 2; + if (forVotes > halfTotalVotes) return ProposalState.Succeeded; + if (againstVotes > halfTotalVotes) return ProposalState.Defeated; + } + + return result; + } + + function propose( + address[] memory targets, + uint256[] memory values, + bytes[] memory calldatas, + string memory description + ) public virtual override returns (uint256) { + address proposer = _msgSender(); + + // check description restriction + if (!_isValidDescriptionForProposer(proposer, description)) { + revert GovernorRestrictedProposer(proposer); + } + + // check proposal threshold + uint256 proposerVotes = getVotes(proposer, clock() - 1); + uint256 votesThreshold = proposalThreshold(); + if (proposerVotes < votesThreshold) { + revert GovernorInsufficientProposerVotes(proposer, proposerVotes, votesThreshold); + } + + if (proposerVotes <= activeProposedLock) { + revert ProposerNotEnoughVotes(proposerVotes, activeProposedLock); + } + + uint256 proposalId = _lastProposalId; + if (proposalId != 0) { + bytes32 currentProposalState = _encodeStateBitmap(state(proposalId)); + bytes32 terminalStatesBitmap = + _encodeStateBitmap(ProposalState.Canceled) | + _encodeStateBitmap(ProposalState.Expired) | + _encodeStateBitmap(ProposalState.Executed); + + if (currentProposalState & terminalStatesBitmap == 0) { + ( + address[] memory currentTargets, + uint256[] memory currentValues, + bytes[] memory currentCalldatas, + bytes32 descriptionHash + ) = proposalDetails(proposalId); + _cancel(currentTargets, currentValues, currentCalldatas, descriptionHash); + } + } + + proposalId = _propose(targets, values, calldatas, description, proposer); + + lockedAmounts[proposalId] += proposerVotes; + activeProposedLock = proposerVotes; + _lastProposalId = proposalId; + IERC20(address(token())).safeTransferFrom(proposer, address(this), proposerVotes); + + return proposalId; + } + + function _propose( + address[] memory targets, + uint256[] memory values, + bytes[] memory calldatas, + string memory description, + address proposer + ) internal virtual override(Governor, GovernorStorage) returns (uint256) { + return super._propose(targets, values, calldatas, description, proposer); + } + + function _castVote( uint256 proposalId, - address[] memory targets, - uint256[] memory values, - bytes[] memory calldatas, - bytes32 descriptionHash - ) internal override(Governor, GovernorTimelockControl) returns (uint48) { - return super._queueOperations( - proposalId, - targets, - values, - calldatas, - descriptionHash - ); + address account, + uint8 support, + string memory reason, + bytes memory params + ) internal virtual override(Governor, GovernorPreventLateQuorum) returns (uint256) { + return super._castVote(proposalId, account, support, reason, params); } - function _executeOperations( - uint256 proposalId, - address[] memory targets, - uint256[] memory values, - bytes[] memory calldatas, - bytes32 descriptionHash - ) internal override(Governor, GovernorTimelockControl) { - super._executeOperations( - proposalId, - targets, - values, - calldatas, - descriptionHash - ); - } - - function _cancel( - address[] memory targets, - uint256[] memory values, - bytes[] memory calldatas, - bytes32 descriptionHash - ) internal override(Governor, GovernorTimelockControl) returns (uint256) { - return super._cancel(targets, values, calldatas, descriptionHash); - } - - function _executor() + function _voteSucceeded(uint256 proposalId) internal view - override(Governor, GovernorTimelockControl) - returns (address) + virtual + override(Governor, GovernorGhostCounting) + returns (bool) { - return super._executor(); + uint256 totalVotes = token().getPastTotalSupply(proposalSnapshot(proposalId)); + (uint256 againstVotes, uint256 forVotes,) = proposalVotes(proposalId); + uint256 rhs = 3 * (5 * againstVotes + forVotes) * Babylonian.sqrt(totalVotes); + uint256 lhs = (forVotes + againstVotes); + lhs = lhs * Babylonian.sqrt(80 * lhs + totalVotes); + return lhs > rhs; } } diff --git a/src/governance/GovernorGhostCounting.sol b/src/governance/GovernorGhostCounting.sol new file mode 100644 index 0000000..1fe9508 --- /dev/null +++ b/src/governance/GovernorGhostCounting.sol @@ -0,0 +1,76 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.20; + +import {Governor} from "@openzeppelin-contracts/governance/Governor.sol"; + +abstract contract GovernorGhostCounting is Governor { + enum VoteType { + Against, + For, + Abstain + } + + struct ProposalVote { + uint256 againstVotes; + uint256 forVotes; + uint256 abstainVotes; + mapping(address voter => uint8) votes; + } + + mapping(uint256 proposalId => ProposalVote) private _proposalVotes; + + // solhint-disable-next-line func-name-mixedcase + function COUNTING_MODE() public pure virtual override returns (string memory) { + return "support=bravo&quorum=for"; + } + + function voteOf(uint256 proposalId, address account) public view virtual returns (uint8) { + return _proposalVotes[proposalId].votes[account]; + } + + function hasVoted(uint256 proposalId, address account) public view virtual override returns (bool) { + return _proposalVotes[proposalId].votes[account] > 0; + } + + function proposalVotes( + uint256 proposalId + ) public view virtual returns (uint256 againstVotes, uint256 forVotes, uint256 abstainVotes) { + ProposalVote storage proposalVote = _proposalVotes[proposalId]; + return (proposalVote.againstVotes, proposalVote.forVotes, proposalVote.abstainVotes); + } + + function _quorumReached(uint256 proposalId) internal view virtual override returns (bool) { + ProposalVote storage proposalVote = _proposalVotes[proposalId]; + uint256 totalVotes = proposalVote.forVotes + proposalVote.againstVotes; + return quorum(proposalSnapshot(proposalId)) <= totalVotes; + } + + function _voteSucceeded(uint256 proposalId) internal view virtual override returns (bool) { + ProposalVote storage proposalVote = _proposalVotes[proposalId]; + return proposalVote.forVotes > proposalVote.againstVotes; + } + + function _countVote( + uint256 proposalId, + address account, + uint8 support, + uint256 weight, + bytes memory // params + ) internal virtual override { + ProposalVote storage proposalVote = _proposalVotes[proposalId]; + + if (proposalVote.votes[account] > 0) { + revert GovernorAlreadyCastVote(account); + } + proposalVote.votes[account] = support + 1; + + if (support == uint8(VoteType.Against)) { + proposalVote.againstVotes += weight; + } else if (support == uint8(VoteType.For)) { + proposalVote.forVotes += weight; + } else { + revert GovernorInvalidVoteType(); + } + } +} diff --git a/test/governance/GhostGovernor.t.sol b/test/governance/GhostGovernor.t.sol new file mode 100644 index 0000000..f2ec44a --- /dev/null +++ b/test/governance/GhostGovernor.t.sol @@ -0,0 +1,559 @@ +pragma solidity 0.8.20; + +import {Test} from "forge-std/Test.sol"; +import {Strings} from "@openzeppelin-contracts/utils/Strings.sol"; + +import "../../src/governance/GhostGovernor.sol"; +import "../../src/GhstERC20.sol"; +import "../../src/StinkyERC20.sol"; + +contract GhostGovernorExposed is GhostGovernor { + constructor( + IVotes _ghst, + uint48 _initialVoteExtension, + uint48 _initialVotingDelay, + uint32 _initialVotingPeriod, + uint256 _initialProposalThreshold, + uint256 _quorumFraction + ) GhostGovernor( + _ghst, + _initialVoteExtension, + _initialVotingDelay, + _initialVotingPeriod, + _initialProposalThreshold, + _quorumFraction + ) {} + + function isValidDescriptionForProposer( + address proposer, + string memory description + ) public view returns (bool) { + return super._isValidDescriptionForProposer(proposer, description); + } + + function quorumReached(uint256 proposalId) public view returns (bool) { + return super._quorumReached(proposalId); + } + + function voteSucceeded(uint256 proposalId) public view returns (bool) { + return super._voteSucceeded(proposalId); + } +} + +contract GhostGovernorTest is Test { + using Strings for address; + + uint48 public constant VOTE_EXTENSION = 69; + uint48 public constant VOTING_DELAY = 420; + uint32 public constant VOTING_PERIOD = 1337; + uint256 public constant PROPOSAL_THRESHOLD = 69 * 1e18; + uint256 public constant QUORUM_FRACTION = 20; // percent + + address constant init = 0x0000000000000000000000000000000000000001; + address constant alice = 0x0000000000000000000000000000000000000002; + address constant bob = 0x0000000000000000000000000000000000000003; + address constant carol = 0x0000000000000000000000000000000000000004; + address constant dave = 0x0000000000000000000000000000000000000005; + address constant eve = 0x0000000000000000000000000000000000000006; + + GhostGovernorExposed public governor; + Stinky public stnk; + Ghost public ghst; + + function setUp() public { + vm.startPrank(init); + stnk = new Stinky(10819917194513808e56, "Stinky Test Name", "STNKTST"); + ghst = new Ghost(address(stnk), "Ghost Test Name", "GHSTTST"); + ghst.initialize(init); + governor = new GhostGovernorExposed( + ghst, + VOTE_EXTENSION, + VOTING_DELAY, + VOTING_PERIOD, + PROPOSAL_THRESHOLD, + QUORUM_FRACTION + ); + vm.stopPrank(); + + vm.prank(alice); + ghst.approve(address(governor), type(uint256).max); + + vm.prank(bob); + ghst.approve(address(governor), type(uint256).max); + + vm.prank(carol); + ghst.approve(address(governor), type(uint256).max); + } + + function test_correctGovernanceName() public view { + assertEq(governor.name(), "GhostGovernorV1"); + } + + function test_correctGovernanceVersion() public view { + assertEq(governor.version(), "1"); + } + + function test_correctGovernanceConfig() public view { + assertEq(governor.COUNTING_MODE(), "support=bravo&quorum=for"); + } + + function test_correctGovernanceProposalThreshold() public view { + assertEq(governor.proposalThreshold(), PROPOSAL_THRESHOLD); + } + + function test_correctGovernanceVotingDelay() public view { + assertEq(governor.votingDelay(), VOTING_DELAY); + } + + function test_correctGovernanceVotingPeriod() public view { + assertEq(governor.votingPeriod(), VOTING_PERIOD); + } + + function test_correctGovernanceVoteExtension() public view { + assertEq(governor.lateQuorumVoteExtension(), VOTE_EXTENSION); + } + + function test_correctGovernanceQuorumFraction() public view { + assertEq(governor.quorumNumerator(), QUORUM_FRACTION); + } + + function test_validDescriptionForProposer( + string memory description, + address proposer, + bool includeProposer + ) public view { + string memory finalDescription = description; + + if (includeProposer) { + finalDescription = string.concat( + description, + "#proposer=", + Strings.toHexString(proposer) + ); + } + + bool isValid = governor.isValidDescriptionForProposer(proposer, finalDescription); + assertTrue(isValid); + } + + function test_invalidDescriptionForProposer( + string memory description, + address commitProposer, + address actualProposer + ) public view { + vm.assume(commitProposer != actualProposer); + + string memory wrongDescription = string.concat( + description, + "#proposer=", + Strings.toHexString(commitProposer) + ); + + bool isValid = governor.isValidDescriptionForProposer(actualProposer, wrongDescription); + assertFalse(isValid); + } + + function test_succeededOnAbsoluteMajority() public { + vm.startPrank(init); + ghst.mint(alice, PROPOSAL_THRESHOLD); + ghst.mint(bob, 555 * 1e17); + ghst.mint(carol, 345 * 1e17 + 1); + ghst.mint(dave, 21 * 1e18); + vm.stopPrank(); + + vm.roll(block.number + 1); + + (uint256 proposalId,,,,) = _proposeDummy(alice, 69); + _waitForActive(proposalId); + + assertEq(uint8(governor.state(proposalId)), uint8(IGovernor.ProposalState.Active)); + _castVoteWrapper(proposalId, bob, 1, true, true); + assertEq(uint8(governor.state(proposalId)), uint8(IGovernor.ProposalState.Active)); + _castVoteWrapper(proposalId, carol, 1, true, true); + assertEq(uint8(governor.state(proposalId)), uint8(IGovernor.ProposalState.Succeeded)); + + vm.expectRevert(); + vm.prank(dave); + governor.castVote(proposalId, 0); + } + + function test_defeatedOnAbsoluteMajority() public { + vm.startPrank(init); + ghst.mint(alice, PROPOSAL_THRESHOLD); + ghst.mint(bob, 555 * 1e17); + ghst.mint(carol, 345 * 1e17 + 1); + ghst.mint(dave, 21 * 1e18); + vm.stopPrank(); + + vm.roll(block.number + 1); + + (uint256 proposalId,,,,) = _proposeDummy(alice, 69); + _waitForActive(proposalId); + + assertEq(uint8(governor.state(proposalId)), uint8(IGovernor.ProposalState.Active)); + _castVoteWrapper(proposalId, bob, 0, true, false); + assertEq(uint8(governor.state(proposalId)), uint8(IGovernor.ProposalState.Active)); + _castVoteWrapper(proposalId, carol, 0, true, false); + assertEq(uint8(governor.state(proposalId)), uint8(IGovernor.ProposalState.Defeated)); + + vm.expectRevert(); + vm.prank(dave); + governor.castVote(proposalId, 1); + } + + function test_onlyOneActiveProposal() public { + uint256 amount = PROPOSAL_THRESHOLD; + vm.startPrank(init); + ghst.mint(alice, amount); + ghst.mint(bob, 2 * amount); + ghst.mint(carol, 3 * amount); + vm.stopPrank(); + + vm.roll(block.number + 1); + + (uint256 proposalId,,,,) = _proposeDummy(alice, 69); + _waitForActive(proposalId); + + vm.prank(alice); + assertEq(governor.releaseLocked(proposalId), 0); + assertEq(governor.lockedAmounts(proposalId), amount); + assertEq(ghst.balanceOf(alice), 0); + + _castVoteWrapper(proposalId, alice, 1, false, false); + (uint256 againstVotes, uint256 forVotes, uint256 abstainVotes) = governor.proposalVotes(proposalId); + assertEq(againstVotes, 0); + assertEq(forVotes, 0); + assertEq(abstainVotes, 0); + + _castVoteWrapper(proposalId, bob, 1, true, true); + (againstVotes, forVotes, abstainVotes) = governor.proposalVotes(proposalId); + assertEq(againstVotes, 0); + assertEq(forVotes, 2 * amount); + assertEq(abstainVotes, 0); + + assertEq(ghst.balanceOf(alice), 0); + assertEq(ghst.balanceOf(bob), 2 * amount); + assertEq(ghst.balanceOf(carol), 3 * amount); + + _waitForSucceed(proposalId); + + vm.prank(alice); + assertEq(governor.releaseLocked(proposalId), amount); + assertEq(governor.lockedAmounts(proposalId), 0); + assertEq(ghst.balanceOf(alice), amount); + + vm.prank(alice); + assertEq(governor.releaseLocked(proposalId), 0); + assertEq(governor.lockedAmounts(proposalId), 0); + assertEq(ghst.balanceOf(alice), amount); + + assertEq(ghst.balanceOf(alice), amount); + assertEq(ghst.balanceOf(bob), 2 * amount); + assertEq(ghst.balanceOf(carol), 3 * amount); + + (proposalId,,,,) = _proposeDummy(bob, 420); + _waitForActive(proposalId); + + _castVoteWrapper(proposalId, alice, 1, false, true); + _castVoteWrapper(proposalId, carol, 1, true, true); + + vm.roll(block.number + 3); + + (uint256 newProposalId,,,,) = _proposeDummy(carol, 1337); + assertEq(uint8(governor.state(proposalId)), uint8(IGovernor.ProposalState.Canceled)); + assertEq(uint8(governor.state(newProposalId)), uint8(IGovernor.ProposalState.Pending)); + + assertEq(ghst.balanceOf(alice), amount); + assertEq(ghst.balanceOf(bob), 0); + assertEq(ghst.balanceOf(carol), 0); + + vm.prank(bob); + assertEq(governor.releaseLocked(proposalId), 0); + assertEq(governor.lockedAmounts(proposalId), 2 * amount); + assertEq(ghst.balanceOf(bob), 0); + + vm.roll(governor.proposalDeadline(proposalId) + 1); + + vm.prank(bob); + assertEq(governor.releaseLocked(proposalId), 2 * amount); + assertEq(governor.lockedAmounts(proposalId), 0); + assertEq(ghst.balanceOf(bob), 2 * amount); + + vm.prank(carol); + assertEq(governor.releaseLocked(newProposalId), 0); + assertEq(governor.lockedAmounts(newProposalId), 3 * amount); + assertEq(ghst.balanceOf(carol), 0); + } + + function test_proposalCountWorks() public { + assertEq(governor.proposalCount(), 0); + + uint256 i = 1; + for (; i < 69; ) { + { + uint256 amount = governor.activeProposedLock() + PROPOSAL_THRESHOLD; + + if (amount + ghst.totalSupply() > type(uint208).max) { + break; + } + + vm.prank(init); + ghst.mint(alice, amount); + vm.roll(block.number + 1); + } + + ( + uint256 proposalId, + address[] memory targets, + uint256[] memory values, + bytes[] memory calldatas, + string memory description + ) = _proposeDummy(alice, i); + + { + ( + address[] memory thisTargets, + uint256[] memory thisValues, + bytes[] memory thisCalldatas, + bytes32 thisDescriptionHash + ) = governor.proposalDetails(proposalId); + + assertEq(thisTargets.length, targets.length); + assertEq(thisValues.length, values.length); + assertEq(thisCalldatas.length, calldatas.length); + + assertEq(thisTargets[0], targets[0]); + assertEq(thisValues[0], values[0]); + assertEq(thisCalldatas[0], calldatas[0]); + assertEq(thisDescriptionHash, keccak256(bytes(description))); + } + + { + ( + uint256 otherProposalId, + address[] memory otherTargets, + uint256[] memory otherValues, + bytes[] memory otherCalldatas, + bytes32 otherDescriptionHash + ) = governor.proposalDetailsAt(i - 1); + + assertEq(otherTargets.length, targets.length); + assertEq(otherValues.length, values.length); + assertEq(otherCalldatas.length, calldatas.length); + + assertEq(otherProposalId, governor.hashProposal(targets, values, calldatas, keccak256(bytes(description)))); + assertEq(otherTargets[0], targets[0]); + assertEq(otherValues[0], values[0]); + assertEq(otherCalldatas[0], calldatas[0]); + assertEq(otherDescriptionHash, keccak256(bytes(description))); + } + + assertEq(governor.proposalCount(), i); + unchecked { ++i; } + } + } + + function test_preventLateQuorumWorks() public { + vm.startPrank(init); + ghst.mint(alice, PROPOSAL_THRESHOLD); + ghst.mint(bob, 50 * 1e18); + ghst.mint(carol, 100 * 1e18); + ghst.mint(dave, 100 * 1e18); + ghst.mint(eve, 500 * 1e18); + vm.stopPrank(); + + vm.roll(block.number + 1); + (uint256 proposalId,,,,) = _proposeDummy(alice, 69); + + assertEq(uint8(governor.state(proposalId)), uint8(IGovernor.ProposalState.Pending)); + vm.roll(block.number + VOTING_DELAY + 1); + assertEq(uint8(governor.state(proposalId)), uint8(IGovernor.ProposalState.Active)); + + assertEq(governor.voteOf(proposalId, bob), 0); + assertEq(governor.voteOf(proposalId, carol), 0); + assertEq(governor.voteOf(proposalId, dave), 0); + assertEq(governor.voteOf(proposalId, eve), 0); + + vm.roll(governor.proposalDeadline(proposalId)); + _castVoteWrapper(proposalId, bob, 1, false, false); + vm.roll(governor.proposalDeadline(proposalId)); + _castVoteWrapper(proposalId, carol, 0, false, false); + vm.roll(governor.proposalDeadline(proposalId)); + _castVoteWrapper(proposalId, dave, 1, true, false); + vm.roll(governor.proposalDeadline(proposalId)); + _castVoteWrapper(proposalId, eve, 1, true, true); + + vm.prank(alice); + governor.execute(proposalId); + assertEq(uint8(governor.state(proposalId)), uint8(IGovernor.ProposalState.Executed)); + + assertEq(governor.voteOf(proposalId, bob), 2); + assertEq(governor.voteOf(proposalId, carol), 1); + assertEq(governor.voteOf(proposalId, dave), 2); + assertEq(governor.voteOf(proposalId, eve), 2); + } + + function test_quorumNumeratorWorks() public { + uint256 numerator = governor.quorumNumerator(); + uint256 denominator = governor.quorumDenominator(); + uint256 currentBlock = block.number; + + vm.startPrank(init); + ghst.mint(alice, 1 * 1e18); + ghst.mint(bob, 69 * 1e18); + ghst.mint(carol, 420 * 1e18); + ghst.mint(dave, 1337 * 1e18); + ghst.mint(eve, 69420 * 1e18); + vm.stopPrank(); + + vm.roll(currentBlock + 2); + assertEq(governor.quorum(currentBlock), 71247 * 1e18 * numerator / denominator); + } + + function test_votingPowerTransfer() public { + vm.startPrank(init); + ghst.mint(alice, 35 * 1e18); + ghst.mint(bob, 34 * 1e18); + vm.stopPrank(); + + vm.roll(block.number + 1); + + vm.expectRevert(); + vm.prank(alice); + ghst.delegate(alice); + + vm.expectRevert(); + vm.prank(bob); + ghst.delegate(bob); + + _assertVotesEqualToBalance(); + + vm.prank(alice); + ghst.transfer(bob, 420); + _assertVotesEqualToBalance(); + + uint256 aliceBalance = ghst.balanceOf(alice); + vm.prank(alice); + ghst.transfer(bob, aliceBalance); + _assertVotesEqualToBalance(); + + vm.prank(bob); + ghst.transfer(carol, 1337); + _assertVotesEqualToBalance(); + + uint256 bobBalance = ghst.balanceOf(bob); + vm.prank(bob); + ghst.transfer(carol, bobBalance); + _assertVotesEqualToBalance(); + + vm.expectRevert(); + vm.prank(carol); + ghst.delegate(carol); + + assertEq(ghst.getVotes(alice), 0); + assertEq(ghst.getVotes(bob), 0); + assertEq(ghst.getVotes(carol), 69 * 1e18); + + assertEq(ghst.balanceOf(alice), 0); + assertEq(ghst.balanceOf(bob), 0); + assertEq(ghst.balanceOf(carol), 69 * 1e18); + + vm.prank(init); + ghst.burn(carol, 69 * 1e18); + + assertEq(ghst.getVotes(carol), 0); + assertEq(ghst.balanceOf(carol), 0); + assertEq(ghst.totalSupply(), 0); + } + + function test_changesOfTotalSupplyHasNoAffectOnPastTotalSupply() public { + vm.prank(init); + ghst.mint(alice, 100); + + vm.roll(block.number + 1); + assertEq(ghst.getPastTotalSupply(block.number - 1), 100); + assertEq(ghst.totalSupply(), 100); + + vm.prank(init); + ghst.burn(alice, 50); + + vm.roll(block.number + 1); + assertEq(ghst.getPastTotalSupply(block.number - 2), 100); + assertEq(ghst.getPastTotalSupply(block.number - 1), 50); + assertEq(ghst.totalSupply(), 50); + + vm.prank(init); + ghst.mint(bob, 150); + + vm.roll(block.number + 1); + assertEq(ghst.getPastTotalSupply(block.number - 3), 100); + assertEq(ghst.getPastTotalSupply(block.number - 2), 50); + assertEq(ghst.getPastTotalSupply(block.number - 1), 200); + assertEq(ghst.totalSupply(), 200); + } + + function _proposeDummy(address who, uint256 index) + private + returns ( + uint256 proposalId, + address[] memory targets, + uint256[] memory values, + bytes[] memory calldatas, + string memory description + ) + { + targets = new address[](1); + targets[0] = address(uint160(index + 1)); + + values = new uint256[](1); + values[0] = 0; + + calldatas = new bytes[](1); + calldatas[0] = abi.encodeWithSignature("setVotingDelay(uint48)", 0); + + description = string.concat("Proposal #", Strings.toString(index)); + + vm.prank(who); + proposalId = governor.propose( + targets, + values, + calldatas, + description + ); + + vm.roll(block.number + 1); + } + + function _castVoteWrapper( + uint256 proposalId, + address who, + uint8 vote, + bool isQuorumReached, + bool isVoteSucceeded + ) private { + assertEq(governor.hasVoted(proposalId, who), false); + vm.prank(who); + governor.castVote(proposalId, vote); + assertEq(governor.hasVoted(proposalId, who), true); + assertEq(governor.quorumReached(proposalId), isQuorumReached); + assertEq(governor.voteSucceeded(proposalId), isVoteSucceeded); + } + + function _assertVotesEqualToBalance() private view { + assertEq(ghst.getVotes(alice), ghst.balanceOf(alice)); + assertEq(ghst.getVotes(bob), ghst.balanceOf(bob)); + assertEq(ghst.getVotes(carol), ghst.balanceOf(carol)); + } + + function _waitForActive(uint256 proposalId) private { + assertEq(uint8(governor.state(proposalId)), uint8(IGovernor.ProposalState.Pending)); + vm.roll(block.number + VOTING_DELAY + 1); + assertEq(uint8(governor.state(proposalId)), uint8(IGovernor.ProposalState.Active)); + } + + function _waitForSucceed(uint256 proposalId) private { + assertEq(uint8(governor.state(proposalId)), uint8(IGovernor.ProposalState.Active)); + vm.roll(governor.proposalDeadline(proposalId) + 1); + assertEq(uint8(governor.state(proposalId)), uint8(IGovernor.ProposalState.Succeeded)); + } +} diff --git a/test/tokens/Votes.t.sol b/test/tokens/Votes.t.sol index 34444f3..6080bcc 100644 --- a/test/tokens/Votes.t.sol +++ b/test/tokens/Votes.t.sol @@ -39,12 +39,11 @@ abstract contract ERC20VotesTest is Test { function test_votes_delegate() external { // case 1: first delegation without balance - assertEq(tokenVotes.delegates(address(this)), address(0)); - vm.expectEmit(true, true, true, false, address(tokenVotes)); - emit DelegateChanged(address(this), address(0), aliceVotes); + assertEq(tokenVotes.delegates(address(this)), address(this)); + vm.expectRevert(); tokenVotes.delegate(aliceVotes); - assertEq(tokenVotes.delegates(address(this)), aliceVotes); + assertEq(tokenVotes.delegates(address(this)), address(this)); // no votes in aliceVotes assertEq(tokenVotes.getVotes(aliceVotes), 0); // no Checkpoint generated @@ -52,50 +51,43 @@ abstract contract ERC20VotesTest is Test { // case 2: first delegate with balance _mintVotesTokens(aliceVotes, amountVotes); - assertEq(tokenVotes.delegates(aliceVotes), address(0)); - - vm.expectEmit(true, true, true, false, address(tokenVotes)); - emit DelegateChanged(aliceVotes, address(0), bobVotes); - vm.expectEmit(true, false, false, true, address(tokenVotes)); - emit DelegateVotesChanged(bobVotes, 0, amountVotes); + assertEq(tokenVotes.delegates(aliceVotes), address(aliceVotes)); + vm.expectRevert(); vm.prank(aliceVotes); tokenVotes.delegate(bobVotes); - assertEq(tokenVotes.delegates(aliceVotes), bobVotes); - // amountVotes votes in bobVotes - assertEq(tokenVotes.getVotes(bobVotes), amountVotes); + + assertEq(tokenVotes.delegates(aliceVotes), aliceVotes); + assertEq(tokenVotes.getVotes(aliceVotes), amountVotes); + assertEq(tokenVotes.getVotes(bobVotes), 0); + // 1 Checkpoint generated - assertEq(tokenVotes.numCheckpoints(bobVotes), 1); - Checkpoints.Checkpoint208 memory ckpt = tokenVotes.checkpoints(bobVotes, 0); + assertEq(tokenVotes.numCheckpoints(aliceVotes), 1); + Checkpoints.Checkpoint208 memory ckpt = tokenVotes.checkpoints(aliceVotes, 0); assertEq(ckpt._key, 1); assertEq(ckpt._value, amountVotes); // case 3: delegate with balance not first vm.roll(2); - vm.expectEmit(true, true, true, false, address(tokenVotes)); - emit DelegateChanged(aliceVotes, bobVotes, charlieVotes); - vm.expectEmit(true, false, false, true, address(tokenVotes)); - emit DelegateVotesChanged(bobVotes, amountVotes, 0); - vm.expectEmit(true, false, false, true, address(tokenVotes)); - emit DelegateVotesChanged(charlieVotes, 0, amountVotes); + vm.expectRevert(); vm.prank(aliceVotes); tokenVotes.delegate(charlieVotes); - assertEq(tokenVotes.delegates(aliceVotes), charlieVotes); - // amountVotes votes in charlieVotes - assertEq(tokenVotes.getVotes(charlieVotes), amountVotes); - // 1 Checkpoint generated - assertEq(tokenVotes.numCheckpoints(charlieVotes), 1); - ckpt = tokenVotes.checkpoints(charlieVotes, 0); - assertEq(ckpt._key, 2); - assertEq(ckpt._value, amountVotes); - // 0 votes in bobVotes + + assertEq(tokenVotes.delegates(aliceVotes), aliceVotes); + assertEq(tokenVotes.delegates(bobVotes), bobVotes); + assertEq(tokenVotes.delegates(charlieVotes), charlieVotes); + + assertEq(tokenVotes.getVotes(aliceVotes), amountVotes); assertEq(tokenVotes.getVotes(bobVotes), 0); + assertEq(tokenVotes.getVotes(charlieVotes), 0); + // 1 Checkpoint generated - assertEq(tokenVotes.numCheckpoints(bobVotes), 2); - ckpt = tokenVotes.checkpoints(bobVotes, 1); - assertEq(ckpt._key, 2); - assertEq(ckpt._value, 0); + assertEq(tokenVotes.numCheckpoints(aliceVotes), 1); + ckpt = tokenVotes.checkpoints(aliceVotes, 0); + assertEq(ckpt._key, 1); + assertEq(ckpt._value, amountVotes); + } function test_votes_mintAndBurnAndMaxSupply() external { @@ -139,56 +131,73 @@ abstract contract ERC20VotesTest is Test { function test_votes_afterTokenTransfer() external { _mintVotesTokens(address(this), 100); + vm.expectRevert(); tokenVotes.delegate(aliceVotes); - assertEq(tokenVotes.delegates(address(this)), aliceVotes); - assertEq(tokenVotes.numCheckpoints(aliceVotes), 1); + assertEq(tokenVotes.delegates(address(this)), address(this)); + assertEq(tokenVotes.numCheckpoints(aliceVotes), 0); + assertEq(tokenVotes.numCheckpoints(address(this)), 1); // test for {transfer} // case 1: 'to' has no delegatee vm.roll(2); vm.expectEmit(true, false, false, true, address(tokenVotes)); - emit DelegateVotesChanged(aliceVotes, 100, 100 - 1); + emit DelegateVotesChanged(address(this), 100, 100 - 1); vm.prank(address(this)); tokenVotes.transfer(bobVotes, 1); - assertEq(tokenVotes.getVotes(aliceVotes), 100 - 1); + assertEq(tokenVotes.getVotes(address(this)), 100 - 1); // case 2: 'to' has a delegatee _mintVotesTokens(charlieVotes, 100); + vm.expectRevert(); vm.prank(charlieVotes); tokenVotes.delegate(eveVotes); vm.roll(3); vm.expectEmit(true, false, false, true, address(tokenVotes)); - emit DelegateVotesChanged(aliceVotes, 99, 99 - 1); + emit DelegateVotesChanged(charlieVotes, 100, 100 - 1); vm.expectEmit(true, false, false, true, address(tokenVotes)); - emit DelegateVotesChanged(eveVotes, 100, 100 + 1); + emit DelegateVotesChanged(eveVotes, 0, 1); - tokenVotes.transfer(charlieVotes, 1); - assertEq(tokenVotes.getVotes(aliceVotes), 99 - 1); - assertEq(tokenVotes.getVotes(eveVotes), 100 + 1); + vm.prank(charlieVotes); + tokenVotes.transfer(eveVotes, 1); + assertEq(tokenVotes.getVotes(charlieVotes), 100 - 1); + assertEq(tokenVotes.getVotes(eveVotes), 1); // test for {transferFrom} // case 3: 'to' has no delegatee vm.roll(4); - assertEq(tokenVotes.delegates(bobVotes), address(0)); + assertEq(tokenVotes.delegates(aliceVotes), aliceVotes); + assertEq(tokenVotes.delegates(bobVotes), bobVotes); + assertEq(tokenVotes.delegates(charlieVotes), charlieVotes); + assertEq(tokenVotes.delegates(eveVotes), eveVotes); tokenVotes.approve(aliceVotes, 100); + assertEq(tokenVotes.delegates(aliceVotes), aliceVotes); + assertEq(tokenVotes.delegates(bobVotes), bobVotes); + assertEq(tokenVotes.delegates(charlieVotes), charlieVotes); + assertEq(tokenVotes.delegates(eveVotes), eveVotes); + vm.startPrank(aliceVotes); vm.expectEmit(true, false, false, true, address(tokenVotes)); - emit DelegateVotesChanged(aliceVotes, 98, 98 - 1); + emit DelegateVotesChanged(address(this), 99, 99 - 1); + vm.expectEmit(true, false, false, true, address(tokenVotes)); + emit DelegateVotesChanged(bobVotes, 1, 2); tokenVotes.transferFrom(address(this), bobVotes, 1); // case 4: 'to' has a delegatee vm.roll(5); - assertEq(tokenVotes.delegates(charlieVotes), eveVotes); + assertEq(tokenVotes.delegates(aliceVotes), aliceVotes); + assertEq(tokenVotes.delegates(bobVotes), bobVotes); + assertEq(tokenVotes.delegates(charlieVotes), charlieVotes); + assertEq(tokenVotes.delegates(eveVotes), eveVotes); vm.expectEmit(true, false, false, true, address(tokenVotes)); - emit DelegateVotesChanged(aliceVotes, 97, 97 - 1); + emit DelegateVotesChanged(address(this), 98, 98 - 1); vm.expectEmit(true, false, false, true, address(tokenVotes)); - emit DelegateVotesChanged(eveVotes, 101, 101 + 1); + emit DelegateVotesChanged(charlieVotes, 99, 99 + 1); tokenVotes.transferFrom(address(this), charlieVotes, 1); } @@ -212,6 +221,7 @@ abstract contract ERC20VotesTest is Test { // 11 23 4 // 13 31 5 + vm.expectRevert(); tokenVotes.delegate(aliceVotes); vm.roll(2); _mintVotesTokens(address(this), 10); @@ -228,33 +238,33 @@ abstract contract ERC20VotesTest is Test { vm.roll(20); // check {getPastVotes} && {getPastTotalSupply} - assertEq(tokenVotes.numCheckpoints(aliceVotes), 6); + assertEq(tokenVotes.numCheckpoints(address(this)), 6); - assertEq(tokenVotes.getPastVotes(aliceVotes, 1), 0); + assertEq(tokenVotes.getPastVotes(address(this), 1), 0); assertEq(tokenVotes.getPastTotalSupply(1), 0); - assertEq(tokenVotes.getPastVotes(aliceVotes, 2), 10); + assertEq(tokenVotes.getPastVotes(address(this), 2), 10); assertEq(tokenVotes.getPastTotalSupply(2), 10); - assertEq(tokenVotes.getPastVotes(aliceVotes, 4), 15); + assertEq(tokenVotes.getPastVotes(address(this), 4), 15); assertEq(tokenVotes.getPastTotalSupply(4), 15); - assertEq(tokenVotes.getPastVotes(aliceVotes, 6), 19); + assertEq(tokenVotes.getPastVotes(address(this), 6), 19); assertEq(tokenVotes.getPastTotalSupply(6), 19); - assertEq(tokenVotes.getPastVotes(aliceVotes, 9), 19); + assertEq(tokenVotes.getPastVotes(address(this), 9), 19); assertEq(tokenVotes.getPastTotalSupply(9), 19); - assertEq(tokenVotes.getPastVotes(aliceVotes, 10), 20); + assertEq(tokenVotes.getPastVotes(address(this), 10), 20); assertEq(tokenVotes.getPastTotalSupply(10), 20); - assertEq(tokenVotes.getPastVotes(aliceVotes, 12), 23); + assertEq(tokenVotes.getPastVotes(address(this), 12), 23); assertEq(tokenVotes.getPastTotalSupply(12), 23); - assertEq(tokenVotes.getPastVotes(aliceVotes, 13), 31); + assertEq(tokenVotes.getPastVotes(address(this), 13), 31); assertEq(tokenVotes.getPastTotalSupply(13), 31); - assertEq(tokenVotes.getPastVotes(aliceVotes, 19), 31); + assertEq(tokenVotes.getPastVotes(address(this), 19), 31); assertEq(tokenVotes.getPastTotalSupply(19), 31); // revert if block not mined