瀏覽代碼

Add GovernorCountingFractional (#5045)

Co-authored-by: ernestognw <ernestognw@gmail.com>
Co-authored-by: Francisco <fg@frang.io>
Hadrien Croubois 1 年之前
父節點
當前提交
c1d6ad5a30

+ 5 - 0
.changeset/eight-eyes-burn.md

@@ -0,0 +1,5 @@
+---
+'openzeppelin-solidity': minor
+---
+
+`GovernorCountingFractional`: Add a governor counting module that allows distributing voting power amongst 3 options (For, Against, Abstain).

+ 1 - 0
CHANGELOG.md

@@ -3,6 +3,7 @@
 ### Breaking changes
 
 - `ERC1967Utils`: Removed duplicate declaration of the `Upgraded`, `AdminChanged` and `BeaconUpgraded` events. These events are still available through the `IERC1967` interface located under the `contracts/interfaces/` directory. Minimum pragma version is now 0.8.21.
+- `Governor`, `GovernorCountingSimple`: The `_countVotes` virtual function now returns an `uint256` with the total votes casted. This change allows for more flexibility for partial and fractional voting. Upgrading users may get a compilation error that can be fixed by adding a return statement to the `_countVotes` function. 
 
 ### Custom error changes
 

+ 7 - 7
contracts/governance/Governor.sol

@@ -255,9 +255,9 @@ abstract contract Governor is Context, ERC165, EIP712, Nonces, IGovernor, IERC72
         uint256 proposalId,
         address account,
         uint8 support,
-        uint256 weight,
+        uint256 totalWeight,
         bytes memory params
-    ) internal virtual;
+    ) internal virtual returns (uint256);
 
     /**
      * @dev Default additional encoded parameters used by castVote methods that don't include them
@@ -639,16 +639,16 @@ abstract contract Governor is Context, ERC165, EIP712, Nonces, IGovernor, IERC72
     ) internal virtual returns (uint256) {
         _validateStateBitmap(proposalId, _encodeStateBitmap(ProposalState.Active));
 
-        uint256 weight = _getVotes(account, proposalSnapshot(proposalId), params);
-        _countVote(proposalId, account, support, weight, params);
+        uint256 totalWeight = _getVotes(account, proposalSnapshot(proposalId), params);
+        uint256 votedWeight = _countVote(proposalId, account, support, totalWeight, params);
 
         if (params.length == 0) {
-            emit VoteCast(account, proposalId, support, weight, reason);
+            emit VoteCast(account, proposalId, support, votedWeight, reason);
         } else {
-            emit VoteCastWithParams(account, proposalId, support, weight, reason, params);
+            emit VoteCastWithParams(account, proposalId, support, votedWeight, reason, params);
         }
 
-        return weight;
+        return votedWeight;
     }
 
     /**

+ 5 - 0
contracts/governance/IGovernor.sol

@@ -83,6 +83,11 @@ interface IGovernor is IERC165, IERC6372 {
      */
     error GovernorInvalidVoteType();
 
+    /**
+     * @dev The provided params buffer is not supported by the counting module.
+     */
+    error GovernorInvalidVoteParams();
+
     /**
      * @dev Queue operation is not implemented for this governor. Execute should be called directly.
      */

+ 4 - 0
contracts/governance/README.adoc

@@ -28,6 +28,8 @@ Counting modules determine valid voting options.
 
 * {GovernorCountingSimple}: Simple voting mechanism with 3 voting 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).
+
 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.
@@ -62,6 +64,8 @@ NOTE: Functions of the `Governor` contract do not include access control. If you
 
 {{GovernorCountingSimple}}
 
+{{GovernorCountingFractional}}
+
 {{GovernorVotes}}
 
 {{GovernorVotesQuorumFraction}}

+ 193 - 0
contracts/governance/extensions/GovernorCountingFractional.sol

@@ -0,0 +1,193 @@
+// SPDX-License-Identifier: MIT
+
+pragma solidity ^0.8.20;
+
+import {Governor} from "../Governor.sol";
+import {GovernorCountingSimple} from "./GovernorCountingSimple.sol";
+import {Math} from "../../utils/math/Math.sol";
+
+/**
+ * @dev Extension of {Governor} for fractional voting.
+ *
+ * Similar to {GovernorCountingSimple}, this contract is a votes counting module for {Governor} that supports 3 options:
+ * Against, For, Abstain. Additionally, it includes a fourth option: Fractional, which allows voters to split their voting
+ * power amongst the other 3 options.
+ *
+ * Votes cast with the Fractional support must be accompanied by a `params` argument that is three packed `uint128` values
+ * representing the weight the delegate assigns to Against, For, and Abstain respectively. For those votes cast for the other
+ * 3 options, the `params` argument must be empty.
+ *
+ * This is mostly useful when the delegate is a contract that implements its own rules for voting. These delegate-contracts
+ * can cast fractional votes according to the preferences of multiple entities delegating their voting power.
+ *
+ * Some example use cases include:
+ *
+ * * Voting from tokens that are held by a DeFi pool
+ * * Voting from an L2 with tokens held by a bridge
+ * * Voting privately from a shielded pool using zero knowledge proofs.
+ *
+ * Based on ScopeLift's GovernorCountingFractional[https://github.com/ScopeLift/flexible-voting/blob/e5de2efd1368387b840931f19f3c184c85842761/src/GovernorCountingFractional.sol]
+ */
+abstract contract GovernorCountingFractional is Governor {
+    using Math for *;
+
+    uint8 internal constant VOTE_TYPE_FRACTIONAL = 255;
+
+    struct ProposalVote {
+        uint256 againstVotes;
+        uint256 forVotes;
+        uint256 abstainVotes;
+        mapping(address voter => uint256) usedVotes;
+    }
+
+    /**
+     * @dev Mapping from proposal ID to vote tallies for that proposal.
+     */
+    mapping(uint256 => ProposalVote) private _proposalVotes;
+
+    /**
+     * @dev A fractional vote params uses more votes than are available for that user.
+     */
+    error GovernorExceedRemainingWeight(address voter, uint256 usedVotes, uint256 remainingWeight);
+
+    /**
+     * @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,fractional&quorum=for,abstain&params=fractional";
+    }
+
+    /**
+     * @dev See {IGovernor-hasVoted}.
+     */
+    function hasVoted(uint256 proposalId, address account) public view virtual override returns (bool) {
+        return usedVotes(proposalId, account) > 0;
+    }
+
+    /**
+     * @dev Get the number of votes already cast by `account` for a proposal with `proposalId`. Useful for
+     * integrations that allow delegates to cast rolling, partial votes.
+     */
+    function usedVotes(uint256 proposalId, address account) public view virtual returns (uint256) {
+        return _proposalVotes[proposalId].usedVotes[account];
+    }
+
+    /**
+     * @dev Get current distribution of votes for a given proposal.
+     */
+    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);
+    }
+
+    /**
+     * @dev See {Governor-_quorumReached}.
+     */
+    function _quorumReached(uint256 proposalId) internal view virtual override returns (bool) {
+        ProposalVote storage proposalVote = _proposalVotes[proposalId];
+        return quorum(proposalSnapshot(proposalId)) <= proposalVote.forVotes + proposalVote.abstainVotes;
+    }
+
+    /**
+     * @dev See {Governor-_voteSucceeded}. In this module, forVotes must be > againstVotes.
+     */
+    function _voteSucceeded(uint256 proposalId) internal view virtual override returns (bool) {
+        ProposalVote storage proposalVote = _proposalVotes[proposalId];
+        return proposalVote.forVotes > proposalVote.againstVotes;
+    }
+
+    /**
+     * @dev See {Governor-_countVote}. Function that records the delegate's votes.
+     *
+     * Executing this function consumes (part of) the delegate's weight on the proposal. This weight can be
+     * distributed amongst the 3 options (Against, For, Abstain) by specifying a fractional `support`.
+     *
+     * This counting module supports two vote casting modes: nominal and fractional.
+     *
+     * - Nominal: A nominal vote is cast by setting `support` to one of the 3 bravo options (Against, For, Abstain).
+     * - Fractional: A fractional vote is cast by setting `support` to `type(uint8).max` (255).
+     *
+     * Casting a nominal vote requires `params` to be empty and consumes the delegate's full remaining weight on the
+     * proposal for the specified `support` option. This is similar to the {GovernorCountingSimple} module and follows
+     * the `VoteType` enum from Governor Bravo. As a consequence, no vote weight remains unspent so no further voting
+     * is possible (for this `proposalId` and this `account`).
+     *
+     * Casting a fractional vote consumes a fraction of the delegate's remaining weight on the proposal according to the
+     * weights the delegate assigns to each support option (Against, For, Abstain respectively). The sum total of the
+     * three decoded vote weights _must_ be less than or equal to the delegate's remaining weight on the proposal (i.e.
+     * their checkpointed total weight minus votes already cast on the proposal). This format can be produced using:
+     *
+     * `abi.encodePacked(uint128(againstVotes), uint128(forVotes), uint128(abstainVotes))`
+     *
+     * NOTE: Consider that fractional voting restricts the number of casted vote (in each category) to 128 bits.
+     * Depending on how many decimals the underlying token has, a single voter may require to split their vote into
+     * multiple vote operations. For precision higher than ~30 decimals, large token holders may require an
+     * potentially large number of calls to cast all their votes. The voter has the possibility to cast all the
+     * remaining votes in a single operation using the traditional "bravo" vote.
+     */
+    // slither-disable-next-line cyclomatic-complexity
+    function _countVote(
+        uint256 proposalId,
+        address account,
+        uint8 support,
+        uint256 totalWeight,
+        bytes memory params
+    ) internal virtual override returns (uint256) {
+        // Compute number of remaining votes. Returns 0 on overflow.
+        (, uint256 remainingWeight) = totalWeight.trySub(usedVotes(proposalId, account));
+        if (remainingWeight == 0) {
+            revert GovernorAlreadyCastVote(account);
+        }
+
+        uint256 againstVotes = 0;
+        uint256 forVotes = 0;
+        uint256 abstainVotes = 0;
+        uint256 usedWeight;
+
+        // For clarity of event indexing, fractional voting must be clearly advertised in the "support" field.
+        //
+        // Supported `support` value must be:
+        // - "Full" voting: `support = 0` (Against), `1` (For) or `2` (Abstain), with empty params.
+        // - "Fractional" voting: `support = 255`, with 48 bytes params.
+        if (support == uint8(GovernorCountingSimple.VoteType.Against)) {
+            if (params.length != 0) revert GovernorInvalidVoteParams();
+            usedWeight = againstVotes = remainingWeight;
+        } else if (support == uint8(GovernorCountingSimple.VoteType.For)) {
+            if (params.length != 0) revert GovernorInvalidVoteParams();
+            usedWeight = forVotes = remainingWeight;
+        } else if (support == uint8(GovernorCountingSimple.VoteType.Abstain)) {
+            if (params.length != 0) revert GovernorInvalidVoteParams();
+            usedWeight = abstainVotes = remainingWeight;
+        } else if (support == VOTE_TYPE_FRACTIONAL) {
+            // The `params` argument is expected to be three packed `uint128`:
+            // `abi.encodePacked(uint128(againstVotes), uint128(forVotes), uint128(abstainVotes))`
+            if (params.length != 0x30) revert GovernorInvalidVoteParams();
+
+            assembly ("memory-safe") {
+                againstVotes := shr(128, mload(add(params, 0x20)))
+                forVotes := shr(128, mload(add(params, 0x30)))
+                abstainVotes := shr(128, mload(add(params, 0x40)))
+                usedWeight := add(add(againstVotes, forVotes), abstainVotes) // inputs are uint128: cannot overflow
+            }
+
+            // check parsed arguments are valid
+            if (usedWeight > remainingWeight) {
+                revert GovernorExceedRemainingWeight(account, usedWeight, remainingWeight);
+            }
+        } else {
+            revert GovernorInvalidVoteType();
+        }
+
+        // update votes tracking
+        ProposalVote storage details = _proposalVotes[proposalId];
+        if (againstVotes > 0) details.againstVotes += againstVotes;
+        if (forVotes > 0) details.forVotes += forVotes;
+        if (abstainVotes > 0) details.abstainVotes += abstainVotes;
+        details.usedVotes[account] += usedWeight;
+
+        return usedWeight;
+    }
+}

+ 7 - 5
contracts/governance/extensions/GovernorCountingSimple.sol

@@ -77,9 +77,9 @@ abstract contract GovernorCountingSimple is Governor {
         uint256 proposalId,
         address account,
         uint8 support,
-        uint256 weight,
+        uint256 totalWeight,
         bytes memory // params
-    ) internal virtual override {
+    ) internal virtual override returns (uint256) {
         ProposalVote storage proposalVote = _proposalVotes[proposalId];
 
         if (proposalVote.hasVoted[account]) {
@@ -88,13 +88,15 @@ abstract contract GovernorCountingSimple is Governor {
         proposalVote.hasVoted[account] = true;
 
         if (support == uint8(VoteType.Against)) {
-            proposalVote.againstVotes += weight;
+            proposalVote.againstVotes += totalWeight;
         } else if (support == uint8(VoteType.For)) {
-            proposalVote.forVotes += weight;
+            proposalVote.forVotes += totalWeight;
         } else if (support == uint8(VoteType.Abstain)) {
-            proposalVote.abstainVotes += weight;
+            proposalVote.abstainVotes += totalWeight;
         } else {
             revert GovernorInvalidVoteType();
         }
+
+        return totalWeight;
     }
 }

+ 14 - 0
contracts/mocks/governance/GovernorFractionalMock.sol

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

+ 1 - 1
contracts/mocks/governance/GovernorWithParamsMock.sol

@@ -41,7 +41,7 @@ abstract contract GovernorWithParamsMock is GovernorVotes, GovernorCountingSimpl
         uint8 support,
         uint256 weight,
         bytes memory params
-    ) internal override(Governor, GovernorCountingSimple) {
+    ) internal override(Governor, GovernorCountingSimple) returns (uint256) {
         if (params.length > 0) {
             (uint256 _uintParam, string memory _strParam) = abi.decode(params, (uint256, string));
             emit CountParams(_uintParam, _strParam);

+ 1 - 1
test/governance/Governor.t.sol

@@ -51,5 +51,5 @@ contract GovernorInternalTest is Test, Governor {
 
     function _getVotes(address, uint256, bytes memory) internal pure virtual override returns (uint256) {}
 
-    function _countVote(uint256, address, uint8, uint256, bytes memory) internal virtual override {}
+    function _countVote(uint256, address, uint8, uint256, bytes memory) internal virtual override returns (uint256) {}
 }

+ 248 - 0
test/governance/extensions/GovernorCountingFractional.test.js

@@ -0,0 +1,248 @@
+const { ethers } = require('hardhat');
+const { expect } = require('chai');
+const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers');
+
+const { GovernorHelper } = require('../../helpers/governance');
+const { VoteType } = require('../../helpers/enums');
+const { zip } = require('../../helpers/iterate');
+const { sum } = require('../../helpers/math');
+
+const TOKENS = [
+  { Token: '$ERC20Votes', mode: 'blocknumber' },
+  { Token: '$ERC20VotesTimestampMock', mode: 'timestamp' },
+];
+
+const name = 'OZ-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');
+
+describe('GovernorCountingFractional', 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, version]);
+      const mock = await ethers.deployContract('$GovernorFractionalMock', [
+        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,fractional&quorum=for,abstain&params=fractional',
+        );
+      });
+
+      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('voting with a fraction of the weight', function () {
+        it('twice', async function () {
+          await this.helper.connect(this.proposer).propose();
+          await this.helper.waitForSnapshot();
+
+          expect(await this.mock.proposalVotes(this.proposal.id)).to.deep.equal([0n, 0n, 0n]);
+          expect(await this.mock.hasVoted(this.proposal.id, this.voter2)).to.equal(false);
+          expect(await this.mock.usedVotes(this.proposal.id, this.voter2)).to.equal(0n);
+
+          const steps = [
+            ['0', '2', '1'],
+            ['1', '0', '1'],
+          ].map(votes => votes.map(vote => ethers.parseEther(vote)));
+
+          for (const votes of steps) {
+            const params = ethers.solidityPacked(['uint128', 'uint128', 'uint128'], votes);
+            await expect(
+              this.helper.connect(this.voter2).vote({
+                support: VoteType.Parameters,
+                reason: 'no particular reason',
+                params,
+              }),
+            )
+              .to.emit(this.mock, 'VoteCastWithParams')
+              .withArgs(
+                this.voter2,
+                this.proposal.id,
+                VoteType.Parameters,
+                sum(...votes),
+                'no particular reason',
+                params,
+              );
+          }
+
+          expect(await this.mock.proposalVotes(this.proposal.id)).to.deep.equal(zip(...steps).map(v => sum(...v)));
+          expect(await this.mock.hasVoted(this.proposal.id, this.voter2)).to.equal(true);
+          expect(await this.mock.usedVotes(this.proposal.id, this.voter2)).to.equal(sum(...[].concat(...steps)));
+        });
+
+        it('fractional then nominal', async function () {
+          await this.helper.connect(this.proposer).propose();
+          await this.helper.waitForSnapshot();
+
+          expect(await this.mock.proposalVotes(this.proposal.id)).to.deep.equal([0n, 0n, 0n]);
+          expect(await this.mock.hasVoted(this.proposal.id, this.voter2)).to.equal(false);
+          expect(await this.mock.usedVotes(this.proposal.id, this.voter2)).to.equal(0n);
+
+          const weight = ethers.parseEther('7');
+          const fractional = ['1', '2', '1'].map(ethers.parseEther);
+
+          const params = ethers.solidityPacked(['uint128', 'uint128', 'uint128'], fractional);
+          await expect(
+            this.helper.connect(this.voter2).vote({
+              support: VoteType.Parameters,
+              reason: 'no particular reason',
+              params,
+            }),
+          )
+            .to.emit(this.mock, 'VoteCastWithParams')
+            .withArgs(
+              this.voter2,
+              this.proposal.id,
+              VoteType.Parameters,
+              sum(...fractional),
+              'no particular reason',
+              params,
+            );
+
+          await expect(this.helper.connect(this.voter2).vote({ support: VoteType.Against }))
+            .to.emit(this.mock, 'VoteCast')
+            .withArgs(this.voter2, this.proposal.id, VoteType.Against, weight - sum(...fractional), '');
+
+          expect(await this.mock.proposalVotes(this.proposal.id)).to.deep.equal([
+            weight - sum(...fractional.slice(1)),
+            ...fractional.slice(1),
+          ]);
+          expect(await this.mock.hasVoted(this.proposal.id, this.voter2)).to.equal(true);
+          expect(await this.mock.usedVotes(this.proposal.id, this.voter2)).to.equal(weight);
+        });
+
+        it('revert if params spend more than available', async function () {
+          await this.helper.connect(this.proposer).propose();
+          await this.helper.waitForSnapshot();
+
+          const weight = ethers.parseEther('7');
+          const fractional = ['0', '1000', '0'].map(ethers.parseEther);
+
+          await expect(
+            this.helper.connect(this.voter2).vote({
+              support: VoteType.Parameters,
+              reason: 'no particular reason',
+              params: ethers.solidityPacked(['uint128', 'uint128', 'uint128'], fractional),
+            }),
+          )
+            .to.be.revertedWithCustomError(this.mock, 'GovernorExceedRemainingWeight')
+            .withArgs(this.voter2, sum(...fractional), weight);
+        });
+
+        it('revert if no weight remaining', async function () {
+          await this.helper.connect(this.proposer).propose();
+          await this.helper.waitForSnapshot();
+          await this.helper.connect(this.voter2).vote({ support: VoteType.For });
+
+          await expect(
+            this.helper.connect(this.voter2).vote({
+              support: VoteType.Parameters,
+              reason: 'no particular reason',
+              params: ethers.solidityPacked(['uint128', 'uint128', 'uint128'], [0n, 1n, 0n]),
+            }),
+          )
+            .to.be.revertedWithCustomError(this.mock, 'GovernorAlreadyCastVote')
+            .withArgs(this.voter2);
+        });
+
+        it('revert if params are not properly formatted #1', async function () {
+          await this.helper.connect(this.proposer).propose();
+          await this.helper.waitForSnapshot();
+
+          await expect(
+            this.helper.connect(this.voter2).vote({
+              support: VoteType.Parameters,
+              reason: 'no particular reason',
+              params: ethers.solidityPacked(['uint128', 'uint128'], [0n, 1n]),
+            }),
+          ).to.be.revertedWithCustomError(this.mock, 'GovernorInvalidVoteParams');
+        });
+
+        it('revert if params are not properly formatted #2', async function () {
+          await this.helper.connect(this.proposer).propose();
+          await this.helper.waitForSnapshot();
+
+          await expect(
+            this.helper.connect(this.voter2).vote({
+              support: VoteType.Against,
+              reason: 'no particular reason',
+              params: ethers.solidityPacked(['uint128', 'uint128', 'uint128'], [0n, 1n, 0n]),
+            }),
+          ).to.be.revertedWithCustomError(this.mock, 'GovernorInvalidVoteParams');
+        });
+
+        it('revert if vote type is invalid', async function () {
+          await this.helper.connect(this.proposer).propose();
+          await this.helper.waitForSnapshot();
+
+          await expect(this.helper.connect(this.voter2).vote({ support: 128n })).to.be.revertedWithCustomError(
+            this.mock,
+            'GovernorInvalidVoteType',
+          );
+        });
+      });
+    });
+  }
+});

+ 1 - 1
test/helpers/enums.js

@@ -5,7 +5,7 @@ function Enum(...options) {
 module.exports = {
   Enum,
   ProposalState: Enum('Pending', 'Active', 'Canceled', 'Defeated', 'Succeeded', 'Queued', 'Expired', 'Executed'),
-  VoteType: Enum('Against', 'For', 'Abstain'),
+  VoteType: Object.assign(Enum('Against', 'For', 'Abstain'), { Parameters: 255n }),
   Rounding: Enum('Floor', 'Ceil', 'Trunc', 'Expand'),
   OperationState: Enum('Unset', 'Waiting', 'Ready', 'Done'),
   RevertType: Enum('None', 'RevertWithoutMessage', 'RevertWithMessage', 'RevertWithCustomError', 'Panic'),