浏览代码

Delegate override vote (#5192)

Co-authored-by: Arr00 <13561405+arr00@users.noreply.github.com>
Hadrien Croubois 11 月之前
父节点
当前提交
378914ceab

+ 5 - 0
.changeset/great-lions-hear.md

@@ -0,0 +1,5 @@
+---
+'openzeppelin-solidity': patch
+---
+
+`VotesExtended`: Create an extension of `Votes` which checkpoints balances and delegates.

+ 5 - 0
.changeset/pink-wasps-hammer.md

@@ -0,0 +1,5 @@
+---
+'openzeppelin-solidity': patch
+---
+
+`GovernorCountingOverridable`: Add a governor counting module that enables token holders to override the vote of their delegate.

+ 10 - 1
contracts/governance/Governor.sol

@@ -260,6 +260,13 @@ abstract contract Governor is Context, ERC165, EIP712, Nonces, IGovernor, IERC72
         bytes memory params
         bytes memory params
     ) internal virtual returns (uint256);
     ) internal virtual returns (uint256);
 
 
+    /**
+     * @dev Hook that should be called every time the tally for a proposal is updated.
+     *
+     * Note: This function must run successfully. Reverts will result in the bricking of governance
+     */
+    function _tallyUpdated(uint256 proposalId) internal virtual {}
+
     /**
     /**
      * @dev Default additional encoded parameters used by castVote methods that don't include them
      * @dev Default additional encoded parameters used by castVote methods that don't include them
      *
      *
@@ -649,6 +656,8 @@ abstract contract Governor is Context, ERC165, EIP712, Nonces, IGovernor, IERC72
             emit VoteCastWithParams(account, proposalId, support, votedWeight, reason, params);
             emit VoteCastWithParams(account, proposalId, support, votedWeight, reason, params);
         }
         }
 
 
+        _tallyUpdated(proposalId);
+
         return votedWeight;
         return votedWeight;
     }
     }
 
 
@@ -732,7 +741,7 @@ abstract contract Governor is Context, ERC165, EIP712, Nonces, IGovernor, IERC72
      *
      *
      * If requirements are not met, reverts with a {GovernorUnexpectedProposalState} error.
      * If requirements are not met, reverts with a {GovernorUnexpectedProposalState} error.
      */
      */
-    function _validateStateBitmap(uint256 proposalId, bytes32 allowedStates) private view returns (ProposalState) {
+    function _validateStateBitmap(uint256 proposalId, bytes32 allowedStates) internal view returns (ProposalState) {
         ProposalState currentState = state(proposalId);
         ProposalState currentState = state(proposalId);
         if (_encodeStateBitmap(currentState) & allowedStates == bytes32(0)) {
         if (_encodeStateBitmap(currentState) & allowedStates == bytes32(0)) {
             revert GovernorUnexpectedProposalState(proposalId, currentState, allowedStates);
             revert GovernorUnexpectedProposalState(proposalId, currentState, allowedStates);

+ 6 - 0
contracts/governance/README.adoc

@@ -30,6 +30,8 @@ Counting modules determine valid voting options.
 
 
 * {GovernorCountingFractional}: A more modular voting system that allows a user to vote with only part of its voting power, and to split that weight arbitrarily between the 3 different options (Against, For and Abstain).
 * {GovernorCountingFractional}: A more modular voting system that allows a user to vote with only part of its voting power, and to split that weight arbitrarily between the 3 different options (Against, For and Abstain).
 
 
+* {GovernorCountingOverridable}: An extended version of `GovernorCountingSimple` which allows delegatees to override their delegates while the vote is live.
+
 Timelock extensions add a delay for governance decisions to be executed. The workflow is extended to require a `queue` step before execution. With these modules, proposals are executed by the external timelock contract, thus it is the timelock that has to hold the assets that are being governed.
 Timelock extensions add a delay for governance decisions to be executed. The workflow is extended to require a `queue` step before execution. With these modules, proposals are executed by the external timelock contract, thus it is the timelock that has to hold the assets that are being governed.
 
 
 * {GovernorTimelockAccess}: Connects with an instance of an {AccessManager}. This allows restrictions (and delays) enforced by the manager to be considered by the Governor and integrated into the AccessManager's "schedule + execute" workflow.
 * {GovernorTimelockAccess}: Connects with an instance of an {AccessManager}. This allows restrictions (and delays) enforced by the manager to be considered by the Governor and integrated into the AccessManager's "schedule + execute" workflow.
@@ -66,6 +68,8 @@ NOTE: Functions of the `Governor` contract do not include access control. If you
 
 
 {{GovernorCountingFractional}}
 {{GovernorCountingFractional}}
 
 
+{{GovernorCountingOverride}}
+
 {{GovernorVotes}}
 {{GovernorVotes}}
 
 
 {{GovernorVotesQuorumFraction}}
 {{GovernorVotesQuorumFraction}}
@@ -88,6 +92,8 @@ NOTE: Functions of the `Governor` contract do not include access control. If you
 
 
 {{Votes}}
 {{Votes}}
 
 
+{{VotesExtended}}
+
 == Timelock
 == Timelock
 
 
 In a governance system, the {TimelockController} contract is in charge of introducing a delay between a proposal and its execution. It can be used with or without a {Governor}.
 In a governance system, the {TimelockController} contract is in charge of introducing a delay between a proposal and its execution. It can be used with or without a {Governor}.

+ 212 - 0
contracts/governance/extensions/GovernorCountingOverridable.sol

@@ -0,0 +1,212 @@
+// SPDX-License-Identifier: MIT
+
+pragma solidity ^0.8.20;
+
+import {SignatureChecker} from "../../utils/cryptography/SignatureChecker.sol";
+import {SafeCast} from "../../utils/math/SafeCast.sol";
+import {VotesExtended} from "../utils/VotesExtended.sol";
+import {GovernorVotes} from "./GovernorVotes.sol";
+
+/**
+ * @dev Extension of {Governor} which enables delegatees to override the vote of their delegates. This module requires a
+ * token token that inherits `VotesExtended`.
+ */
+abstract contract GovernorCountingOverridable is GovernorVotes {
+    bytes32 public constant OVERRIDE_BALLOT_TYPEHASH =
+        keccak256("OverrideBallot(uint256 proposalId,uint8 support,address voter,uint256 nonce,string reason)");
+
+    /**
+     * @dev Supported vote types. Matches Governor Bravo ordering.
+     */
+    enum VoteType {
+        Against,
+        For,
+        Abstain
+    }
+
+    struct VoteReceipt {
+        uint8 casted; // 0 if vote was not casted. Otherwise: support + 1
+        bool hasOverriden;
+        uint208 overridenWeight;
+    }
+
+    struct ProposalVote {
+        uint256[3] votes;
+        mapping(address voter => VoteReceipt) voteReceipt;
+    }
+
+    event VoteReduced(address indexed voter, uint256 proposalId, uint8 support, uint256 weight);
+    event OverrideVoteCast(address indexed voter, uint256 proposalId, uint8 support, uint256 weight, string reason);
+
+    error GovernorAlreadyOverridenVote(address account);
+
+    mapping(uint256 proposalId => ProposalVote) private _proposalVotes;
+
+    /**
+     * @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,override&quorum=for,abstain&overridable=true";
+    }
+
+    /**
+     * @dev See {IGovernor-hasVoted}.
+     */
+    function hasVoted(uint256 proposalId, address account) public view virtual override returns (bool) {
+        return _proposalVotes[proposalId].voteReceipt[account].casted != 0;
+    }
+
+    /**
+     * @dev Check if an `account` has overridden their delegate for a proposal.
+     */
+    function hasVotedOverride(uint256 proposalId, address account) public view virtual returns (bool) {
+        return _proposalVotes[proposalId].voteReceipt[account].hasOverriden;
+    }
+
+    /**
+     * @dev Accessor to the internal vote counts.
+     */
+    function proposalVotes(
+        uint256 proposalId
+    ) public view virtual returns (uint256 againstVotes, uint256 forVotes, uint256 abstainVotes) {
+        uint256[3] storage votes = _proposalVotes[proposalId].votes;
+        return (votes[uint8(VoteType.Against)], votes[uint8(VoteType.For)], votes[uint8(VoteType.Abstain)]);
+    }
+
+    /**
+     * @dev See {Governor-_quorumReached}.
+     */
+    function _quorumReached(uint256 proposalId) internal view virtual override returns (bool) {
+        uint256[3] storage votes = _proposalVotes[proposalId].votes;
+        return quorum(proposalSnapshot(proposalId)) <= votes[uint8(VoteType.For)] + votes[uint8(VoteType.Abstain)];
+    }
+
+    /**
+     * @dev See {Governor-_voteSucceeded}. In this module, the forVotes must be strictly over the againstVotes.
+     */
+    function _voteSucceeded(uint256 proposalId) internal view virtual override returns (bool) {
+        uint256[3] storage votes = _proposalVotes[proposalId].votes;
+        return votes[uint8(VoteType.For)] > votes[uint8(VoteType.Against)];
+    }
+
+    /**
+     * @dev See {Governor-_countVote}. In this module, the support follows the `VoteType` enum (from Governor Bravo).
+     *
+     * NOTE: called by {Governor-_castVote} which emits the {IGovernor-VoteCast} (or {IGovernor-VoteCastWithParams})
+     * event.
+     */
+    function _countVote(
+        uint256 proposalId,
+        address account,
+        uint8 support,
+        uint256 totalWeight,
+        bytes memory /*params*/
+    ) internal virtual override returns (uint256) {
+        ProposalVote storage proposalVote = _proposalVotes[proposalId];
+
+        if (support > uint8(VoteType.Abstain)) {
+            revert GovernorInvalidVoteType();
+        }
+
+        if (proposalVote.voteReceipt[account].casted != 0) {
+            revert GovernorAlreadyCastVote(account);
+        }
+
+        totalWeight -= proposalVote.voteReceipt[account].overridenWeight;
+        proposalVote.votes[support] += totalWeight;
+        proposalVote.voteReceipt[account].casted = support + 1;
+
+        return totalWeight;
+    }
+
+    /// @dev Variant of {Governor-_countVote} that deals with vote overrides.
+    function _countOverride(uint256 proposalId, address account, uint8 support) internal virtual returns (uint256) {
+        ProposalVote storage proposalVote = _proposalVotes[proposalId];
+
+        if (support > uint8(VoteType.Abstain)) {
+            revert GovernorInvalidVoteType();
+        }
+
+        if (proposalVote.voteReceipt[account].hasOverriden) {
+            revert GovernorAlreadyOverridenVote(account);
+        }
+
+        uint256 proposalSnapshot = proposalSnapshot(proposalId);
+        uint256 overridenWeight = VotesExtended(address(token())).getPastBalanceOf(account, proposalSnapshot);
+        address delegate = VotesExtended(address(token())).getPastDelegate(account, proposalSnapshot);
+        uint8 delegateCasted = proposalVote.voteReceipt[delegate].casted;
+
+        proposalVote.voteReceipt[account].hasOverriden = true;
+        proposalVote.votes[support] += overridenWeight;
+        if (delegateCasted == 0) {
+            proposalVote.voteReceipt[delegate].overridenWeight += SafeCast.toUint208(overridenWeight);
+        } else {
+            uint8 delegateSupport = delegateCasted - 1;
+            proposalVote.votes[delegateSupport] -= overridenWeight;
+            emit VoteReduced(delegate, proposalId, delegateSupport, overridenWeight);
+        }
+
+        return overridenWeight;
+    }
+
+    /// @dev variant of {Governor-_castVote} that deals with vote overrides.
+    function _castOverride(
+        uint256 proposalId,
+        address account,
+        uint8 support,
+        string calldata reason
+    ) internal virtual returns (uint256) {
+        _validateStateBitmap(proposalId, _encodeStateBitmap(ProposalState.Active));
+
+        uint256 overridenWeight = _countOverride(proposalId, account, support);
+
+        emit OverrideVoteCast(account, proposalId, support, overridenWeight, reason);
+
+        _tallyUpdated(proposalId);
+
+        return overridenWeight;
+    }
+
+    /// @dev Public function for casting an override vote
+    function castOverrideVote(
+        uint256 proposalId,
+        uint8 support,
+        string calldata reason
+    ) public virtual returns (uint256) {
+        address voter = _msgSender();
+        return _castOverride(proposalId, voter, support, reason);
+    }
+
+    /// @dev Public function for casting an override vote using a voter's signature
+    function castOverrideVoteBySig(
+        uint256 proposalId,
+        uint8 support,
+        address voter,
+        string calldata reason,
+        bytes calldata signature
+    ) public virtual returns (uint256) {
+        bool valid = SignatureChecker.isValidSignatureNow(
+            voter,
+            _hashTypedDataV4(
+                keccak256(
+                    abi.encode(
+                        OVERRIDE_BALLOT_TYPEHASH,
+                        proposalId,
+                        support,
+                        voter,
+                        _useNonce(voter),
+                        keccak256(bytes(reason))
+                    )
+                )
+            ),
+            signature
+        );
+
+        if (!valid) {
+            revert GovernorInvalidSignature(voter);
+        }
+
+        return _castOverride(proposalId, voter, support, reason);
+    }
+}

+ 3 - 13
contracts/governance/extensions/GovernorPreventLateQuorum.sol

@@ -44,20 +44,12 @@ abstract contract GovernorPreventLateQuorum is Governor {
     }
     }
 
 
     /**
     /**
-     * @dev Casts a vote and detects if it caused quorum to be reached, potentially extending the voting period. See
-     * {Governor-_castVote}.
+     * @dev Vote tally updated and detects if it caused quorum to be reached, potentially extending the voting period.
      *
      *
      * May emit a {ProposalExtended} event.
      * May emit a {ProposalExtended} event.
      */
      */
-    function _castVote(
-        uint256 proposalId,
-        address account,
-        uint8 support,
-        string memory reason,
-        bytes memory params
-    ) internal virtual override returns (uint256) {
-        uint256 result = super._castVote(proposalId, account, support, reason, params);
-
+    function _tallyUpdated(uint256 proposalId) internal virtual override {
+        super._tallyUpdated(proposalId);
         if (_extendedDeadlines[proposalId] == 0 && _quorumReached(proposalId)) {
         if (_extendedDeadlines[proposalId] == 0 && _quorumReached(proposalId)) {
             uint48 extendedDeadline = clock() + lateQuorumVoteExtension();
             uint48 extendedDeadline = clock() + lateQuorumVoteExtension();
 
 
@@ -67,8 +59,6 @@ abstract contract GovernorPreventLateQuorum is Governor {
 
 
             _extendedDeadlines[proposalId] = extendedDeadline;
             _extendedDeadlines[proposalId] = extendedDeadline;
         }
         }
-
-        return result;
     }
     }
 
 
     /**
     /**

+ 70 - 0
contracts/governance/utils/VotesExtended.sol

@@ -0,0 +1,70 @@
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.20;
+
+import {Checkpoints} from "../../utils/structs/Checkpoints.sol";
+import {Votes} from "./Votes.sol";
+import {SafeCast} from "../../utils/math/SafeCast.sol";
+
+/**
+ * @dev Extension of {Votes} that adds exposes checkpoints for delegations and balances.
+ */
+abstract contract VotesExtended is Votes {
+    using SafeCast for uint256;
+    using Checkpoints for Checkpoints.Trace160;
+    using Checkpoints for Checkpoints.Trace208;
+
+    mapping(address delegatee => Checkpoints.Trace160) private _delegateCheckpoints;
+    mapping(address account => Checkpoints.Trace208) private _balanceOfCheckpoints;
+
+    /**
+     * @dev Returns the delegate of an `account` at a specific moment in the past. If the `clock()` is
+     * configured to use block numbers, this will return the value at the end of the corresponding block.
+     *
+     * Requirements:
+     *
+     * - `timepoint` must be in the past. If operating using block numbers, the block must be already mined.
+     */
+    function getPastDelegate(address account, uint256 timepoint) public view virtual returns (address) {
+        uint48 currentTimepoint = clock();
+        if (timepoint >= currentTimepoint) {
+            revert ERC5805FutureLookup(timepoint, currentTimepoint);
+        }
+        return address(_delegateCheckpoints[account].upperLookupRecent(timepoint.toUint48()));
+    }
+
+    /**
+     * @dev Returns the `balanceOf` of an `account` at a specific moment in the past. If the `clock()` is
+     * configured to use block numbers, this will return the value at the end of the corresponding block.
+     *
+     * Requirements:
+     *
+     * - `timepoint` must be in the past. If operating using block numbers, the block must be already mined.
+     */
+    function getPastBalanceOf(address account, uint256 timepoint) public view virtual returns (uint256) {
+        uint48 currentTimepoint = clock();
+        if (timepoint >= currentTimepoint) {
+            revert ERC5805FutureLookup(timepoint, currentTimepoint);
+        }
+        return _balanceOfCheckpoints[account].upperLookupRecent(timepoint.toUint48());
+    }
+
+    /// @inheritdoc Votes
+    function _delegate(address account, address delegatee) internal virtual override {
+        super._delegate(account, delegatee);
+
+        _delegateCheckpoints[account].push(clock(), uint160(delegatee));
+    }
+
+    /// @inheritdoc Votes
+    function _transferVotingUnits(address from, address to, uint256 amount) internal virtual override {
+        super._transferVotingUnits(from, to, amount);
+        if (from != to) {
+            if (from != address(0)) {
+                _balanceOfCheckpoints[from].push(clock(), _getVotingUnits(from).toUint208());
+            }
+            if (to != address(0)) {
+                _balanceOfCheckpoints[to].push(clock(), _getVotingUnits(to).toUint208());
+            }
+        }
+    }
+}

+ 42 - 0
contracts/mocks/VotesAdditionalCheckpointsMock.sol

@@ -0,0 +1,42 @@
+// SPDX-License-Identifier: MIT
+
+pragma solidity ^0.8.20;
+
+import {VotesExtended} from "../governance/utils/VotesExtended.sol";
+
+abstract contract VotesExtendedMock is VotesExtended {
+    mapping(address voter => uint256) private _votingUnits;
+
+    function getTotalSupply() public view returns (uint256) {
+        return _getTotalSupply();
+    }
+
+    function delegate(address account, address newDelegation) public {
+        return _delegate(account, newDelegation);
+    }
+
+    function _getVotingUnits(address account) internal view override returns (uint256) {
+        return _votingUnits[account];
+    }
+
+    function _mint(address account, uint256 votes) internal {
+        _votingUnits[account] += votes;
+        _transferVotingUnits(address(0), account, votes);
+    }
+
+    function _burn(address account, uint256 votes) internal {
+        _votingUnits[account] += votes;
+        _transferVotingUnits(account, address(0), votes);
+    }
+}
+
+abstract contract VotesExtendedTimestampMock is VotesExtendedMock {
+    function clock() public view override returns (uint48) {
+        return uint48(block.timestamp);
+    }
+
+    // solhint-disable-next-line func-name-mixedcase
+    function CLOCK_MODE() public view virtual override returns (string memory) {
+        return "mode=timestamp";
+    }
+}

+ 18 - 0
contracts/mocks/governance/GovernorCountingOverridableMock.sol

@@ -0,0 +1,18 @@
+// SPDX-License-Identifier: MIT
+
+pragma solidity ^0.8.20;
+
+import {Governor} from "../../governance/Governor.sol";
+import {GovernorSettings} from "../../governance/extensions/GovernorSettings.sol";
+import {GovernorVotesQuorumFraction} from "../../governance/extensions/GovernorVotesQuorumFraction.sol";
+import {GovernorCountingOverridable, VotesExtended} from "../../governance/extensions/GovernorCountingOverridable.sol";
+
+abstract contract GovernorCountingOverridableMock is
+    GovernorSettings,
+    GovernorVotesQuorumFraction,
+    GovernorCountingOverridable
+{
+    function proposalThreshold() public view override(Governor, GovernorSettings) returns (uint256) {
+        return super.proposalThreshold();
+    }
+}

+ 2 - 8
contracts/mocks/governance/GovernorPreventLateQuorumMock.sol

@@ -34,13 +34,7 @@ abstract contract GovernorPreventLateQuorumMock is
         return super.proposalThreshold();
         return super.proposalThreshold();
     }
     }
 
 
-    function _castVote(
-        uint256 proposalId,
-        address account,
-        uint8 support,
-        string memory reason,
-        bytes memory params
-    ) internal override(Governor, GovernorPreventLateQuorum) returns (uint256) {
-        return super._castVote(proposalId, account, support, reason, params);
+    function _tallyUpdated(uint256 proposalId) internal override(Governor, GovernorPreventLateQuorum) {
+        super._tallyUpdated(proposalId);
     }
     }
 }
 }

+ 31 - 0
contracts/mocks/token/ERC20VotesAdditionalCheckpointsMock.sol

@@ -0,0 +1,31 @@
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.20;
+
+import {ERC20Votes} from "../../token/ERC20/extensions/ERC20Votes.sol";
+import {VotesExtended, Votes} from "../../governance/utils/VotesExtended.sol";
+import {SafeCast} from "../../utils/math/SafeCast.sol";
+
+abstract contract ERC20VotesExtendedMock is ERC20Votes, VotesExtended {
+    function _delegate(address account, address delegatee) internal virtual override(Votes, VotesExtended) {
+        return super._delegate(account, delegatee);
+    }
+
+    function _transferVotingUnits(
+        address from,
+        address to,
+        uint256 amount
+    ) internal virtual override(Votes, VotesExtended) {
+        return super._transferVotingUnits(from, to, amount);
+    }
+}
+
+abstract contract ERC20VotesExtendedTimestampMock is ERC20VotesExtendedMock {
+    function clock() public view virtual override returns (uint48) {
+        return SafeCast.toUint48(block.timestamp);
+    }
+
+    // solhint-disable-next-line func-name-mixedcase
+    function CLOCK_MODE() public view virtual override returns (string memory) {
+        return "mode=timestamp";
+    }
+}

+ 344 - 0
test/governance/extensions/GovernorCountingOverridable.test.js

@@ -0,0 +1,344 @@
+const { ethers } = require('hardhat');
+const { expect } = require('chai');
+const { loadFixture, mine } = require('@nomicfoundation/hardhat-network-helpers');
+
+const { GovernorHelper } = require('../../helpers/governance');
+const { getDomain, OverrideBallot } = require('../../helpers/eip712');
+const { VoteType } = require('../../helpers/enums');
+
+const TOKENS = [
+  { Token: '$ERC20VotesExtendedMock', mode: 'blocknumber' },
+  // { Token: '$ERC20VotesExtendedTimestampMock', mode: 'timestamp' },
+];
+
+const name = 'Override Governor';
+const version = '1';
+const tokenName = 'MockToken';
+const tokenSymbol = 'MTKN';
+const tokenSupply = ethers.parseEther('100');
+const votingDelay = 4n;
+const votingPeriod = 16n;
+const value = ethers.parseEther('1');
+
+const signBallot = account => (contract, message) =>
+  getDomain(contract).then(domain => account.signTypedData(domain, { OverrideBallot }, message));
+
+describe('GovernorCountingOverridable', function () {
+  for (const { Token, mode } of TOKENS) {
+    const fixture = async () => {
+      const [owner, proposer, voter1, voter2, voter3, voter4, other] = await ethers.getSigners();
+      const receiver = await ethers.deployContract('CallReceiverMock');
+
+      const token = await ethers.deployContract(Token, [tokenName, tokenSymbol, tokenName, version]);
+      const mock = await ethers.deployContract('$GovernorCountingOverridableMock', [
+        name, // name
+        votingDelay, // initialVotingDelay
+        votingPeriod, // initialVotingPeriod
+        0n, // initialProposalThreshold
+        token, // tokenAddress
+        10n, // quorumNumeratorValue
+      ]);
+
+      await owner.sendTransaction({ to: mock, value });
+      await token.$_mint(owner, tokenSupply);
+
+      const helper = new GovernorHelper(mock, mode);
+      await helper.connect(owner).delegate({ token, to: voter1, value: ethers.parseEther('10') });
+      await helper.connect(owner).delegate({ token, to: voter2, value: ethers.parseEther('7') });
+      await helper.connect(owner).delegate({ token, to: voter3, value: ethers.parseEther('5') });
+      await helper.connect(owner).delegate({ token, to: voter4, value: ethers.parseEther('2') });
+
+      return { owner, proposer, voter1, voter2, voter3, voter4, other, receiver, token, mock, helper };
+    };
+
+    describe(`using ${Token}`, function () {
+      beforeEach(async function () {
+        Object.assign(this, await loadFixture(fixture));
+
+        // default proposal
+        this.proposal = this.helper.setProposal(
+          [
+            {
+              target: this.receiver.target,
+              value,
+              data: this.receiver.interface.encodeFunctionData('mockFunction'),
+            },
+          ],
+          '<proposal description>',
+        );
+      });
+
+      it('deployment check', async function () {
+        expect(await this.mock.name()).to.equal(name);
+        expect(await this.mock.token()).to.equal(this.token);
+        expect(await this.mock.votingDelay()).to.equal(votingDelay);
+        expect(await this.mock.votingPeriod()).to.equal(votingPeriod);
+        expect(await this.mock.COUNTING_MODE()).to.equal('support=bravo,override&quorum=for,abstain&overridable=true');
+      });
+
+      it('nominal is unaffected', async function () {
+        await this.helper.connect(this.proposer).propose();
+        await this.helper.waitForSnapshot();
+        await this.helper.connect(this.voter1).vote({ support: VoteType.For, reason: 'This is nice' });
+        await this.helper.connect(this.voter2).vote({ support: VoteType.For });
+        await this.helper.connect(this.voter3).vote({ support: VoteType.Against });
+        await this.helper.connect(this.voter4).vote({ support: VoteType.Abstain });
+        await this.helper.waitForDeadline();
+        await this.helper.execute();
+
+        expect(await this.mock.hasVoted(this.proposal.id, this.owner)).to.be.false;
+        expect(await this.mock.hasVoted(this.proposal.id, this.voter1)).to.be.true;
+        expect(await this.mock.hasVoted(this.proposal.id, this.voter2)).to.be.true;
+        expect(await ethers.provider.getBalance(this.mock)).to.equal(0n);
+        expect(await ethers.provider.getBalance(this.receiver)).to.equal(value);
+      });
+
+      describe('cast override vote', async function () {
+        beforeEach(async function () {
+          // user 1 -(delegate 10 tokens)-> user 2
+          // user 2 -(delegate 7 tokens)-> user 2
+          // user 3 -(delegate 5 tokens)-> user 1
+          // user 4 -(delegate 2 tokens)-> user 2
+          await this.token.connect(this.voter1).delegate(this.voter2);
+          await this.token.connect(this.voter3).delegate(this.voter1);
+          await this.token.connect(this.voter4).delegate(this.voter2);
+          await mine();
+
+          await this.helper.connect(this.proposer).propose();
+          await this.helper.waitForSnapshot();
+        });
+
+        it('override after delegate vote', async function () {
+          expect(await this.mock.hasVoted(this.helper.id, this.voter1)).to.be.false;
+          expect(await this.mock.hasVoted(this.helper.id, this.voter2)).to.be.false;
+          expect(await this.mock.hasVoted(this.helper.id, this.voter3)).to.be.false;
+          expect(await this.mock.hasVoted(this.helper.id, this.voter4)).to.be.false;
+          expect(await this.mock.hasVotedOverride(this.helper.id, this.voter1)).to.be.false;
+          expect(await this.mock.hasVotedOverride(this.helper.id, this.voter2)).to.be.false;
+          expect(await this.mock.hasVotedOverride(this.helper.id, this.voter3)).to.be.false;
+          expect(await this.mock.hasVotedOverride(this.helper.id, this.voter4)).to.be.false;
+
+          // user 2 votes
+
+          await expect(this.helper.connect(this.voter2).vote({ support: VoteType.For }))
+            .to.emit(this.mock, 'VoteCast')
+            .withArgs(this.voter2, this.helper.id, VoteType.For, ethers.parseEther('19'), ''); // 10 + 7 + 2
+
+          expect(await this.mock.proposalVotes(this.helper.id)).to.deep.eq(
+            [0, 19, 0].map(x => ethers.parseEther(x.toString())),
+          );
+          expect(await this.mock.hasVoted(this.helper.id, this.voter2)).to.be.true;
+          expect(await this.mock.hasVotedOverride(this.helper.id, this.voter2)).to.be.false;
+
+          // user 1 overrides after user 2 votes
+
+          const reason = "disagree with user 2's decision";
+          await expect(this.mock.connect(this.voter1).castOverrideVote(this.helper.id, VoteType.Against, reason))
+            .to.emit(this.mock, 'OverrideVoteCast')
+            .withArgs(this.voter1, this.helper.id, VoteType.Against, ethers.parseEther('10'), reason)
+            .to.emit(this.mock, 'VoteReduced')
+            .withArgs(this.voter2, this.helper.id, VoteType.For, ethers.parseEther('10'));
+
+          expect(await this.mock.proposalVotes(this.helper.id)).to.deep.eq(
+            [10, 9, 0].map(x => ethers.parseEther(x.toString())),
+          );
+          expect(await this.mock.hasVoted(this.helper.id, this.voter1)).to.be.false;
+          expect(await this.mock.hasVotedOverride(this.helper.id, this.voter1)).to.be.true;
+        });
+
+        it('override before delegate vote', async function () {
+          expect(await this.mock.hasVoted(this.helper.id, this.voter1)).to.be.false;
+          expect(await this.mock.hasVoted(this.helper.id, this.voter2)).to.be.false;
+          expect(await this.mock.hasVoted(this.helper.id, this.voter3)).to.be.false;
+          expect(await this.mock.hasVoted(this.helper.id, this.voter4)).to.be.false;
+          expect(await this.mock.hasVotedOverride(this.helper.id, this.voter1)).to.be.false;
+          expect(await this.mock.hasVotedOverride(this.helper.id, this.voter2)).to.be.false;
+          expect(await this.mock.hasVotedOverride(this.helper.id, this.voter3)).to.be.false;
+          expect(await this.mock.hasVotedOverride(this.helper.id, this.voter4)).to.be.false;
+
+          // user 1 overrides before user 2 votes
+
+          const reason = 'voter 2 is not voting';
+          await expect(this.mock.connect(this.voter1).castOverrideVote(this.helper.id, VoteType.Against, reason))
+            .to.emit(this.mock, 'OverrideVoteCast')
+            .withArgs(this.voter1, this.helper.id, VoteType.Against, ethers.parseEther('10'), reason)
+            .to.not.emit(this.mock, 'VoteReduced');
+
+          expect(await this.mock.proposalVotes(this.helper.id)).to.deep.eq(
+            [10, 0, 0].map(x => ethers.parseEther(x.toString())),
+          );
+          expect(await this.mock.hasVoted(this.helper.id, this.voter1)).to.be.false;
+          expect(await this.mock.hasVotedOverride(this.helper.id, this.voter1)).to.be.true;
+
+          // user 2 votes
+
+          await expect(this.helper.connect(this.voter2).vote({ support: VoteType.For }))
+            .to.emit(this.mock, 'VoteCast')
+            .withArgs(this.voter2, this.helper.id, VoteType.For, ethers.parseEther('9'), ''); // 7 + 2
+
+          expect(await this.mock.proposalVotes(this.helper.id)).to.deep.eq(
+            [10, 9, 0].map(x => ethers.parseEther(x.toString())),
+          );
+          expect(await this.mock.hasVoted(this.helper.id, this.voter2)).to.be.true;
+          expect(await this.mock.hasVotedOverride(this.helper.id, this.voter2)).to.be.false;
+        });
+
+        it('override before and after delegate vote', async function () {
+          expect(await this.mock.hasVoted(this.helper.id, this.voter1)).to.be.false;
+          expect(await this.mock.hasVoted(this.helper.id, this.voter2)).to.be.false;
+          expect(await this.mock.hasVoted(this.helper.id, this.voter3)).to.be.false;
+          expect(await this.mock.hasVoted(this.helper.id, this.voter4)).to.be.false;
+          expect(await this.mock.hasVotedOverride(this.helper.id, this.voter1)).to.be.false;
+          expect(await this.mock.hasVotedOverride(this.helper.id, this.voter2)).to.be.false;
+          expect(await this.mock.hasVotedOverride(this.helper.id, this.voter3)).to.be.false;
+          expect(await this.mock.hasVotedOverride(this.helper.id, this.voter4)).to.be.false;
+
+          // user 1 overrides before user 2 votes
+
+          const reason = 'voter 2 is not voting';
+          await expect(this.mock.connect(this.voter1).castOverrideVote(this.helper.id, VoteType.Against, reason))
+            .to.emit(this.mock, 'OverrideVoteCast')
+            .withArgs(this.voter1, this.helper.id, VoteType.Against, ethers.parseEther('10'), reason)
+            .to.not.emit(this.mock, 'VoteReduced');
+
+          expect(await this.mock.proposalVotes(this.helper.id)).to.deep.eq(
+            [10, 0, 0].map(x => ethers.parseEther(x.toString())),
+          );
+          expect(await this.mock.hasVoted(this.helper.id, this.voter1)).to.be.false;
+          expect(await this.mock.hasVotedOverride(this.helper.id, this.voter1)).to.be.true;
+
+          // user 2 votes
+
+          await expect(this.helper.connect(this.voter2).vote({ support: VoteType.For }))
+            .to.emit(this.mock, 'VoteCast')
+            .withArgs(this.voter2, this.helper.id, VoteType.For, ethers.parseEther('9'), ''); // 7 + 2
+
+          expect(await this.mock.proposalVotes(this.helper.id)).to.deep.eq(
+            [10, 9, 0].map(x => ethers.parseEther(x.toString())),
+          );
+          expect(await this.mock.hasVoted(this.helper.id, this.voter2)).to.be.true;
+          expect(await this.mock.hasVotedOverride(this.helper.id, this.voter2)).to.be.false;
+
+          // User 4 overrides after user 2 votes
+
+          const reason2 = "disagree with user 2's decision";
+          await expect(this.mock.connect(this.voter4).castOverrideVote(this.helper.id, VoteType.Abstain, reason2))
+            .to.emit(this.mock, 'OverrideVoteCast')
+            .withArgs(this.voter4, this.helper.id, VoteType.Abstain, ethers.parseEther('2'), reason2)
+            .to.emit(this.mock, 'VoteReduced')
+            .withArgs(this.voter2, this.helper.id, VoteType.For, ethers.parseEther('2'));
+
+          expect(await this.mock.proposalVotes(this.helper.id)).to.deep.eq(
+            [10, 7, 2].map(x => ethers.parseEther(x.toString())),
+          );
+          expect(await this.mock.hasVoted(this.helper.id, this.voter4)).to.be.false;
+          expect(await this.mock.hasVotedOverride(this.helper.id, this.voter4)).to.be.true;
+        });
+
+        it('vote (with delegated balance) and override (with self balance) are independent', async function () {
+          expect(await this.mock.proposalVotes(this.helper.id)).to.deep.eq(
+            [0, 0, 0].map(x => ethers.parseEther(x.toString())),
+          );
+          expect(await this.mock.hasVoted(this.helper.id, this.voter1)).to.be.false;
+          expect(await this.mock.hasVotedOverride(this.helper.id, this.voter1)).to.be.false;
+
+          // user 1 votes with delegated weight from user 3
+          await expect(this.mock.connect(this.voter1).castVote(this.helper.id, VoteType.For))
+            .to.emit(this.mock, 'VoteCast')
+            .withArgs(this.voter1, this.helper.id, VoteType.For, ethers.parseEther('5'), '');
+
+          // user 1 cast an override vote with its own balance (delegated to user 2)
+          await expect(this.mock.connect(this.voter1).castOverrideVote(this.helper.id, VoteType.Against, ''))
+            .to.emit(this.mock, 'OverrideVoteCast')
+            .withArgs(this.voter1, this.helper.id, VoteType.Against, ethers.parseEther('10'), '');
+
+          expect(await this.mock.proposalVotes(this.helper.id)).to.deep.eq(
+            [10, 5, 0].map(x => ethers.parseEther(x.toString())),
+          );
+          expect(await this.mock.hasVoted(this.helper.id, this.voter1)).to.be.true;
+          expect(await this.mock.hasVotedOverride(this.helper.id, this.voter1)).to.be.true;
+        });
+
+        it('can not override vote twice', async function () {
+          await expect(this.mock.connect(this.voter1).castOverrideVote(this.helper.id, VoteType.Against, ''))
+            .to.emit(this.mock, 'OverrideVoteCast')
+            .withArgs(this.voter1, this.helper.id, VoteType.Against, ethers.parseEther('10'), '');
+          await expect(this.mock.connect(this.voter1).castOverrideVote(this.helper.id, VoteType.Abstain, ''))
+            .to.be.revertedWithCustomError(this.mock, 'GovernorAlreadyOverridenVote')
+            .withArgs(this.voter1.address);
+        });
+
+        it('can not vote twice', async function () {
+          await expect(this.mock.connect(this.voter1).castVote(this.helper.id, VoteType.Against));
+          await expect(this.mock.connect(this.voter1).castVote(this.helper.id, VoteType.Abstain))
+            .to.be.revertedWithCustomError(this.mock, 'GovernorAlreadyCastVote')
+            .withArgs(this.voter1.address);
+        });
+
+        describe('invalid vote type', function () {
+          it('override vote', async function () {
+            await expect(
+              this.mock.connect(this.voter1).castOverrideVote(this.helper.id, 3, ''),
+            ).to.be.revertedWithCustomError(this.mock, 'GovernorInvalidVoteType');
+          });
+
+          it('traditional vote', async function () {
+            await expect(this.mock.connect(this.voter1).castVote(this.helper.id, 3)).to.be.revertedWithCustomError(
+              this.mock,
+              'GovernorInvalidVoteType',
+            );
+          });
+        });
+
+        describe('by signature', function () {
+          it('EOA signature', async function () {
+            const nonce = await this.mock.nonces(this.voter1);
+
+            await expect(
+              this.helper.overrideVote({
+                support: VoteType.For,
+                voter: this.voter1.address,
+                nonce,
+                signature: signBallot(this.voter1),
+              }),
+            )
+              .to.emit(this.mock, 'OverrideVoteCast')
+              .withArgs(this.voter1, this.helper.id, VoteType.For, ethers.parseEther('10'), '');
+
+            expect(await this.mock.hasVotedOverride(this.proposal.id, this.voter1)).to.be.true;
+          });
+
+          it('revert if signature does not match signer', async function () {
+            const nonce = await this.mock.nonces(this.voter1);
+
+            const voteParams = {
+              support: VoteType.For,
+              voter: this.voter2.address,
+              nonce,
+              signature: signBallot(this.voter1),
+            };
+
+            await expect(this.helper.overrideVote(voteParams))
+              .to.be.revertedWithCustomError(this.mock, 'GovernorInvalidSignature')
+              .withArgs(voteParams.voter);
+          });
+
+          it('revert if vote nonce is incorrect', async function () {
+            const nonce = await this.mock.nonces(this.voter1);
+
+            const voteParams = {
+              support: VoteType.For,
+              voter: this.voter1.address,
+              nonce: nonce + 1n,
+              signature: signBallot(this.voter1),
+            };
+
+            await expect(this.helper.overrideVote(voteParams))
+              .to.be.revertedWithCustomError(this.mock, 'GovernorInvalidSignature')
+              .withArgs(voteParams.voter);
+          });
+        });
+      });
+    });
+  }
+});

+ 152 - 0
test/governance/utils/VotesAdditionalCheckpoints.test.js

@@ -0,0 +1,152 @@
+const { ethers } = require('hardhat');
+const { expect } = require('chai');
+const { loadFixture, mine } = require('@nomicfoundation/hardhat-network-helpers');
+
+const { sum } = require('../../helpers/math');
+const { zip } = require('../../helpers/iterate');
+const time = require('../../helpers/time');
+
+const { shouldBehaveLikeVotes } = require('./Votes.behavior');
+
+const MODES = {
+  blocknumber: '$VotesExtendedMock',
+  timestamp: '$VotesExtendedTimestampMock',
+};
+
+const AMOUNTS = [ethers.parseEther('10000000'), 10n, 20n];
+
+describe('VotesExtended', function () {
+  for (const [mode, artifact] of Object.entries(MODES)) {
+    const fixture = async () => {
+      const accounts = await ethers.getSigners();
+
+      const amounts = Object.fromEntries(
+        zip(
+          accounts.slice(0, AMOUNTS.length).map(({ address }) => address),
+          AMOUNTS,
+        ),
+      );
+
+      const name = 'Override Votes';
+      const version = '1';
+      const votes = await ethers.deployContract(artifact, [name, version]);
+
+      return { accounts, amounts, votes, name, version };
+    };
+
+    describe(`vote with ${mode}`, function () {
+      beforeEach(async function () {
+        Object.assign(this, await loadFixture(fixture));
+      });
+
+      shouldBehaveLikeVotes(AMOUNTS, { mode, fungible: true });
+
+      it('starts with zero votes', async function () {
+        expect(await this.votes.getTotalSupply()).to.equal(0n);
+      });
+
+      describe('performs voting operations', function () {
+        beforeEach(async function () {
+          this.txs = [];
+          for (const [account, amount] of Object.entries(this.amounts)) {
+            this.txs.push(await this.votes.$_mint(account, amount));
+          }
+        });
+
+        it('reverts if block number >= current block', async function () {
+          const lastTxTimepoint = await time.clockFromReceipt[mode](this.txs.at(-1));
+          const clock = await this.votes.clock();
+          await expect(this.votes.getPastTotalSupply(lastTxTimepoint))
+            .to.be.revertedWithCustomError(this.votes, 'ERC5805FutureLookup')
+            .withArgs(lastTxTimepoint, clock);
+        });
+
+        it('delegates', async function () {
+          expect(await this.votes.getVotes(this.accounts[0])).to.equal(0n);
+          expect(await this.votes.getVotes(this.accounts[1])).to.equal(0n);
+          expect(await this.votes.delegates(this.accounts[0])).to.equal(ethers.ZeroAddress);
+          expect(await this.votes.delegates(this.accounts[1])).to.equal(ethers.ZeroAddress);
+
+          await this.votes.delegate(this.accounts[0], ethers.Typed.address(this.accounts[0]));
+
+          expect(await this.votes.getVotes(this.accounts[0])).to.equal(this.amounts[this.accounts[0].address]);
+          expect(await this.votes.getVotes(this.accounts[1])).to.equal(0n);
+          expect(await this.votes.delegates(this.accounts[0])).to.equal(this.accounts[0]);
+          expect(await this.votes.delegates(this.accounts[1])).to.equal(ethers.ZeroAddress);
+
+          await this.votes.delegate(this.accounts[1], ethers.Typed.address(this.accounts[0]));
+
+          expect(await this.votes.getVotes(this.accounts[0])).to.equal(
+            this.amounts[this.accounts[0].address] + this.amounts[this.accounts[1].address],
+          );
+          expect(await this.votes.getVotes(this.accounts[1])).to.equal(0n);
+          expect(await this.votes.delegates(this.accounts[0])).to.equal(this.accounts[0]);
+          expect(await this.votes.delegates(this.accounts[1])).to.equal(this.accounts[0]);
+        });
+
+        it('cross delegates', async function () {
+          await this.votes.delegate(this.accounts[0], ethers.Typed.address(this.accounts[1]));
+          await this.votes.delegate(this.accounts[1], ethers.Typed.address(this.accounts[0]));
+
+          expect(await this.votes.getVotes(this.accounts[0])).to.equal(this.amounts[this.accounts[1].address]);
+          expect(await this.votes.getVotes(this.accounts[1])).to.equal(this.amounts[this.accounts[0].address]);
+        });
+
+        it('returns total amount of votes', async function () {
+          const totalSupply = sum(...Object.values(this.amounts));
+          expect(await this.votes.getTotalSupply()).to.equal(totalSupply);
+        });
+      });
+    });
+
+    describe(`checkpoint delegates with ${mode}`, function () {
+      beforeEach(async function () {
+        Object.assign(this, await loadFixture(fixture));
+      });
+
+      it('checkpoint delegates', async function () {
+        const tx = await this.votes.delegate(this.accounts[0], ethers.Typed.address(this.accounts[1]));
+        const timepoint = await time.clockFromReceipt[mode](tx);
+        await mine(2);
+
+        expect(await this.votes.getPastDelegate(this.accounts[0], timepoint - 1n)).to.equal(ethers.ZeroAddress);
+        expect(await this.votes.getPastDelegate(this.accounts[0], timepoint)).to.equal(this.accounts[1].address);
+        expect(await this.votes.getPastDelegate(this.accounts[0], timepoint + 1n)).to.equal(this.accounts[1].address);
+      });
+
+      it('reverts if current timepoint <= timepoint', async function () {
+        const tx = await this.votes.delegate(this.accounts[0], ethers.Typed.address(this.accounts[1]));
+        const timepoint = await time.clockFromReceipt[mode](tx);
+
+        await expect(this.votes.getPastDelegate(this.accounts[0], timepoint + 1n))
+          .to.be.revertedWithCustomError(this.votes, 'ERC5805FutureLookup')
+          .withArgs(timepoint + 1n, timepoint);
+      });
+    });
+
+    describe(`checkpoint balances with ${mode}`, function () {
+      beforeEach(async function () {
+        Object.assign(this, await loadFixture(fixture));
+      });
+
+      it('checkpoint balances', async function () {
+        const tx = await this.votes.$_mint(this.accounts[0].address, 100n);
+        const timepoint = await time.clockFromReceipt[mode](tx);
+        await mine(2);
+
+        expect(await this.votes.getPastBalanceOf(this.accounts[0].address, timepoint - 1n)).to.equal(0n);
+        expect(await this.votes.getPastBalanceOf(this.accounts[0].address, timepoint)).to.equal(100n);
+        expect(await this.votes.getPastBalanceOf(this.accounts[0].address, timepoint + 1n)).to.equal(100n);
+      });
+
+      it('reverts if current timepoint <= timepoint', async function () {
+        const tx = await this.votes.$_mint(this.accounts[0].address, 100n);
+        const timepoint = await time.clockFromReceipt[mode](tx);
+
+        await expect(this.votes.getPastBalanceOf(this.accounts[0], timepoint + 1n))
+          .to.be.revertedWithCustomError(this.votes, 'ERC5805FutureLookup')
+          .withArgs(timepoint + 1n, timepoint);
+      });
+    });
+  }
+});

+ 7 - 0
test/helpers/eip712-types.js

@@ -32,6 +32,13 @@ module.exports = mapValues(
       reason: 'string',
       reason: 'string',
       params: 'bytes',
       params: 'bytes',
     },
     },
+    OverrideBallot: {
+      proposalId: 'uint256',
+      support: 'uint8',
+      voter: 'address',
+      nonce: 'uint256',
+      reason: 'string',
+    },
     Delegation: {
     Delegation: {
       delegatee: 'address',
       delegatee: 'address',
       nonce: 'uint256',
       nonce: 'uint256',

+ 17 - 0
test/helpers/governance.js

@@ -128,6 +128,23 @@ class GovernorHelper {
     return await this.governor[method](...args);
     return await this.governor[method](...args);
   }
   }
 
 
+  async overrideVote(vote = {}) {
+    let method = 'castOverrideVote';
+    let args = [this.id, vote.support];
+
+    vote.reason = vote.reason ?? '';
+
+    if (vote.signature) {
+      let message = this.forgeMessage(vote);
+      message.reason = message.reason ?? '';
+      const sign = await vote.signature(this.governor, message);
+      method = 'castOverrideVoteBySig';
+      args.push(vote.voter, vote.reason ?? '', sign);
+    }
+
+    return await this.governor[method](...args);
+  }
+
   /// Clock helpers
   /// Clock helpers
   async waitForSnapshot(offset = 0n) {
   async waitForSnapshot(offset = 0n) {
     const timepoint = await this.governor.proposalSnapshot(this.id);
     const timepoint = await this.governor.proposalSnapshot(this.id);