From ad42de78681ead4e31ecc93c2c86f0506849c72e Mon Sep 17 00:00:00 2001 From: Sp3rick Date: Wed, 9 Jul 2025 19:06:56 +0200 Subject: [PATCH 1/2] Add ERC20MultiVotes and MultiVotes --- .changeset/silent-terms-beg.md | 5 + contracts/governance/README.adoc | 2 + contracts/governance/utils/IMultiVotes.sol | 73 ++ contracts/governance/utils/MultiVotes.sol | 289 +++++ contracts/governance/utils/Votes.sol | 11 +- contracts/mocks/MultiVotesMock.sol | 42 + .../token/ERC20MultiVotesTimestampMock.sol | 17 + .../ERC20/extensions/ERC20MultiVotes.sol | 84 ++ test/governance/utils/MultiVotes.behavior.js | 488 ++++++++ test/governance/utils/MultiVotes.test.js | 146 +++ test/helpers/eip712-types.js | 1 + .../ERC20/extensions/ERC20MultiVotes.test.js | 1090 +++++++++++++++++ 12 files changed, 2246 insertions(+), 2 deletions(-) create mode 100644 .changeset/silent-terms-beg.md create mode 100644 contracts/governance/utils/IMultiVotes.sol create mode 100644 contracts/governance/utils/MultiVotes.sol create mode 100644 contracts/mocks/MultiVotesMock.sol create mode 100644 contracts/mocks/token/ERC20MultiVotesTimestampMock.sol create mode 100644 contracts/token/ERC20/extensions/ERC20MultiVotes.sol create mode 100644 test/governance/utils/MultiVotes.behavior.js create mode 100644 test/governance/utils/MultiVotes.test.js create mode 100644 test/token/ERC20/extensions/ERC20MultiVotes.test.js diff --git a/.changeset/silent-terms-beg.md b/.changeset/silent-terms-beg.md new file mode 100644 index 00000000000..2c8c15dbd3b --- /dev/null +++ b/.changeset/silent-terms-beg.md @@ -0,0 +1,5 @@ +--- +'openzeppelin-solidity': minor +--- + +Add ERC20MultiVotes with MultiVotes for partial delegations support diff --git a/contracts/governance/README.adoc b/contracts/governance/README.adoc index 3131a0be050..585b63d8df2 100644 --- a/contracts/governance/README.adoc +++ b/contracts/governance/README.adoc @@ -110,6 +110,8 @@ NOTE: Functions of the `Governor` contract do not include access control. If you {{VotesExtended}} +{{MultiVotes}} + == 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}. diff --git a/contracts/governance/utils/IMultiVotes.sol b/contracts/governance/utils/IMultiVotes.sol new file mode 100644 index 00000000000..687923581aa --- /dev/null +++ b/contracts/governance/utils/IMultiVotes.sol @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Contracts (last updated v5.5.0) (governance/utils/IMultiVotes.sol) +pragma solidity ^0.8.26; + +import {IVotes} from "./IVotes.sol"; + +/** + * @dev Common interface for {ERC20MultiVotes} and other {MultiVotes}-enabled contracts. + */ +interface IMultiVotes is IVotes { + /** + * @dev Invalid, start should be equal or smaller than end. + */ + error StartIsBiggerThanEnd(uint256 start, uint256 end); + + /** + * @dev Requested more units than actually available. + */ + error MultiVotesExceededAvailableUnits(uint256 requested, uint256 available); + + /** + * @dev Mismatch between number of given delegates and correspective units. + */ + error MultiVotesDelegatesAndUnitsMismatch(uint256 delegatesLength, uint256 unitsLength); + + /** + * @dev Invalid operation, you should give at least one delegate. + */ + error MultiVotesNoDelegatesGiven(); + + /** + * @dev Emitted when units assigned to a partial delegate are modified. + */ + event DelegateModified(address indexed delegator, address indexed delegate, uint256 fromUnits, uint256 toUnits); + + /** + * @dev Returns `account` partial delegations list starting from `start` to `end`. + * + * NOTE: Order may unexpectedly change if called in different transactions. + * Trust the returned array only if you obtain it within a single transaction. + */ + function multiDelegates(address account, uint256 start, uint256 end) external view returns (address[] memory); + + /** + * @dev Set delegates list with units assigned for each one + */ + function multiDelegate(address[] calldata delegatess, uint256[] calldata units) external; + + /** + * @dev Multi delegate votes from signer to `delegatess`. + */ + function multiDelegateBySig( + address[] calldata delegatess, + uint256[] calldata units, + uint256 nonce, + uint256 expiry, + uint8 v, + bytes32 r, + bytes32 s + ) external; + + /** + * @dev Returns number of units a partial delegate of `account` has. + * + * NOTE: This function returns only the partial delegation value, defaulted units are not counted + */ + function getDelegatedUnits(address account, address delegatee) external view returns (uint256); + + /** + * @dev Returns number of unassigned units that `account` has. Free units are assigned to the Votes single delegate selected. + */ + function getFreeUnits(address account) external view returns (uint256); +} diff --git a/contracts/governance/utils/MultiVotes.sol b/contracts/governance/utils/MultiVotes.sol new file mode 100644 index 00000000000..1d3c0115fa6 --- /dev/null +++ b/contracts/governance/utils/MultiVotes.sol @@ -0,0 +1,289 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Contracts (last updated v5.5.0) (governance/utils/MultiVotes.sol) +pragma solidity ^0.8.26; + +import {Checkpoints} from "../../utils/structs/Checkpoints.sol"; +import {Votes} from "./Votes.sol"; +import {SafeCast} from "../../utils/math/SafeCast.sol"; +import {ECDSA} from "../../utils/cryptography/ECDSA.sol"; +import {IMultiVotes} from "./IMultiVotes.sol"; + +/** + * @dev Extension of {Votes} with support for partial delegations. + * You can give a fixed amount of voting power to each delegate and select one as `defaulted` using {Votes} methods + * `defaulted` takes all of the remaining votes. + * + * NOTE: If inheriting from this contract there are things you should be careful of + * multiDelegates getter is considered possibly failing for out of gas if too many partial delegates are assigned + * If a limit on the number of delegates per delegator is enforced, {multiDelegates} can be considered reliable. + */ +abstract contract MultiVotes is Votes, IMultiVotes { + bytes32 private constant MULTI_DELEGATION_TYPEHASH = + keccak256("MultiDelegation(address[] delegatees,uint256[] units,uint256 nonce,uint256 expiry)"); + + /** + * NOTE: If you work directly with these mappings be careful. + * Only _delegatesList is assured to have up to date and coherent data. + * Values on _delegatesIndex and _delegatesUnits may be left dangling to save on gas. + * So always use _accountHasDelegate() before giving trust to _delegatesIndex and _delegatesUnits values. + */ + mapping(address account => address[]) private _delegatesList; + mapping(address account => mapping(address delegatee => uint256)) private _delegatesIndex; + mapping(address account => mapping(address delegatee => uint256)) private _delegatesUnits; + + mapping(address account => uint256) private _usedUnits; + + /** + * @inheritdoc Votes + */ + function _delegate(address account, address delegatee) internal virtual override { + address oldDelegate = delegates(account); + _setDelegate(account, delegatee); + + emit DelegateChanged(account, oldDelegate, delegatee); + _moveDelegateVotes(oldDelegate, delegatee, getFreeUnits(account)); + } + + /** + * @inheritdoc Votes + */ + function _transferVotingUnits(address from, address to, uint256 amount) internal virtual override { + if (from != address(0)) { + uint256 freeUnits = getFreeUnits(from); + require(amount <= freeUnits, MultiVotesExceededAvailableUnits(amount, freeUnits)); + } + super._transferVotingUnits(from, to, amount); + } + + /** + * @dev Returns `account` partial delegations list starting from `start` to `end`. + * + * NOTE: Order may unexpectedly change if called in different transactions. + * Trust the returned array only if you obtain it within a single transaction. + */ + function multiDelegates( + address account, + uint256 start, + uint256 end + ) public view virtual returns (address[] memory) { + uint256 maxLength = _delegatesList[account].length; + require(end >= start, StartIsBiggerThanEnd(start, end)); + if (start >= maxLength) { + address[] memory empty = new address[](0); + return empty; + } + + if (end >= maxLength) { + end = maxLength - 1; + } + uint256 length = (end + 1) - start; + address[] memory list = new address[](length); + + for (uint256 i; i < length; i++) { + list[i] = _delegatesList[account][start + i]; + } + + return list; + } + + /** + * @dev Set delegates list with units assigned for each one + */ + function multiDelegate(address[] calldata delegatees, uint256[] calldata units) public virtual { + address account = _msgSender(); + _multiDelegate(account, delegatees, units); + } + + /** + * @dev Multi delegate votes from signer to `delegatees`. + */ + function multiDelegateBySig( + address[] calldata delegatees, + uint256[] calldata units, + uint256 nonce, + uint256 expiry, + uint8 v, + bytes32 r, + bytes32 s + ) public virtual { + if (block.timestamp > expiry) { + revert VotesExpiredSignature(expiry); + } + + bytes32 delegatesHash = keccak256(abi.encodePacked(delegatees)); + bytes32 unitsHash = keccak256(abi.encodePacked(units)); + bytes32 structHash = keccak256(abi.encode(MULTI_DELEGATION_TYPEHASH, delegatesHash, unitsHash, nonce, expiry)); + + address signer = ECDSA.recover(_hashTypedDataV4(structHash), v, r, s); + + _useCheckedNonce(signer, nonce); + _multiDelegate(signer, delegatees, units); + } + + /** + * @dev Add delegates to the multi delegation list or modify units of already existing. + * + * Emits multiple events {IMultiVotes-DelegateAdded} and {IMultiVotes-DelegateModified}. + */ + function _multiDelegate( + address account, + address[] calldata delegatees, + uint256[] calldata unitsList + ) internal virtual { + require( + delegatees.length == unitsList.length, + MultiVotesDelegatesAndUnitsMismatch(delegatees.length, unitsList.length) + ); + require(delegatees.length > 0, MultiVotesNoDelegatesGiven()); + + uint256 givenUnits; + uint256 removedUnits; + for (uint256 i; i < delegatees.length; i++) { + address delegatee = delegatees[i]; + uint256 units = unitsList[i]; + + if (units != 0) { + if (_accountHasDelegate(account, delegatee)) { + (uint256 difference, bool refunded) = _modifyDelegate(account, delegatee, units); + refunded ? givenUnits += difference : removedUnits += difference; + continue; + } + + _addDelegate(account, delegatee, units); + givenUnits += units; + } else { + removedUnits += _removeDelegate(account, delegatee); + } + } + + if (removedUnits >= givenUnits) { + uint256 refundedUnits; + refundedUnits = removedUnits - givenUnits; + /** + * Cannot Underflow: code logic assures that _usedUnits[account] is just a sum of active delegates units + * and that every units change of delegate on `account`, updates coherently _usedUnits + * so refundedUnits cannot be higher than _usedUnits[account] + */ + unchecked { + _usedUnits[account] -= refundedUnits; + } + _moveDelegateVotes(address(0), delegates(account), refundedUnits); + } else { + uint256 addedUnits = givenUnits - removedUnits; + uint256 availableUnits = getFreeUnits(account); + require(availableUnits >= addedUnits, MultiVotesExceededAvailableUnits(addedUnits, availableUnits)); + + _usedUnits[account] += addedUnits; + _moveDelegateVotes(delegates(account), address(0), addedUnits); + } + } + + /** + * @dev Helper for _multiDelegate that adds a delegate to multi delegations. + * + * Emits event {IMultiVotes-DelegateModified}. + * + * NOTE: this function does not automatically update _usedUnits and should never receive 0 `units` value + */ + function _addDelegate(address account, address delegatee, uint256 units) private { + _delegatesUnits[account][delegatee] = units; + _delegatesIndex[account][delegatee] = _delegatesList[account].length; + _delegatesList[account].push(delegatee); + emit DelegateModified(account, delegatee, 0, units); + + _moveDelegateVotes(address(0), delegatee, units); + } + + /** + * @dev Helper for _multiDelegate to modify a specific delegate. Returns difference and if it's refunded units. + * + * Emits event {IMultiVotes-DelegateModified}. + * + * NOTE: this function does not automatically update _usedUnits and should never receive 0 `units` value + */ + function _modifyDelegate( + address account, + address delegatee, + uint256 units + ) private returns (uint256 difference, bool refunded) { + uint256 oldUnits = _delegatesUnits[account][delegatee]; + + if (oldUnits == units) return (0, false); + + if (oldUnits > units) { + difference = oldUnits - units; + _moveDelegateVotes(delegatee, address(0), difference); + } else { + difference = units - oldUnits; + _moveDelegateVotes(address(0), delegatee, difference); + refunded = true; + } + + _delegatesUnits[account][delegatee] = units; + emit DelegateModified(account, delegatee, oldUnits, units); + return (difference, refunded); + } + + /** + * @dev Helper for _multiDelegate to remove a delegate from multi delegations list. Returns removed units. + * + * Emits event {IMultiVotes-DelegateModified}. + * + * NOTE: this function does not automatically update _usedUnits + */ + function _removeDelegate(address account, address delegatee) private returns (uint256) { + if (!_accountHasDelegate(account, delegatee)) return 0; + + uint256 delegateIndex = _delegatesIndex[account][delegatee]; + uint256 lastDelegateIndex = _delegatesList[account].length - 1; + address lastDelegate = _delegatesList[account][lastDelegateIndex]; + uint256 refundedUnits = _delegatesUnits[account][delegatee]; + + _delegatesList[account][delegateIndex] = lastDelegate; + _delegatesIndex[account][lastDelegate] = delegateIndex; + _delegatesList[account].pop(); + emit DelegateModified(account, delegatee, refundedUnits, 0); + + _moveDelegateVotes(delegatee, address(0), refundedUnits); + return refundedUnits; + } + + /** + * @dev Returns number of units a partial delegate of `account` has. + * + * NOTE: This function returns only the partial delegation value, defaulted units are not counted + */ + function getDelegatedUnits(address account, address delegatee) public view virtual returns (uint256) { + if (!_accountHasDelegate(account, delegatee)) { + return 0; + } + return _delegatesUnits[account][delegatee]; + } + + /** + * @dev Returns number of unassigned units that `account` has. Free units are assigned to the Votes single delegate selected. + */ + function getFreeUnits(address account) public view virtual returns (uint256) { + return _getVotingUnits(account) - _usedUnits[account]; + } + + /** + * @dev Returns true if account has a specific delegate. + * + * NOTE: This works only assuming that every time a value is added to _delegatesList + * the corresponding entries in _delegatesUnits and _delegatesIndex are updated. + */ + function _accountHasDelegate(address account, address delegatee) internal view virtual returns (bool) { + uint256 delegateIndex = _delegatesIndex[account][delegatee]; + + if (_delegatesList[account].length <= delegateIndex) { + return false; + } + + if (delegatee == _delegatesList[account][delegateIndex]) { + return true; + } else { + return false; + } + } +} diff --git a/contracts/governance/utils/Votes.sol b/contracts/governance/utils/Votes.sol index 02c68d028c0..fd60a95ab66 100644 --- a/contracts/governance/utils/Votes.sol +++ b/contracts/governance/utils/Votes.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -// OpenZeppelin Contracts (last updated v5.2.0) (governance/utils/Votes.sol) +// OpenZeppelin Contracts (last updated v5.5.0) (governance/utils/Votes.sol) pragma solidity ^0.8.24; import {IERC5805} from "../../interfaces/IERC5805.sol"; @@ -162,7 +162,7 @@ abstract contract Votes is Context, EIP712, Nonces, IERC5805 { } /** - * @dev Delegate all of `account`'s voting units to `delegatee`. + * @dev Delegate all available `account`'s voting units to `delegatee`. * * Emits events {IVotes-DelegateChanged} and {IVotes-DelegateVotesChanged}. */ @@ -174,6 +174,13 @@ abstract contract Votes is Context, EIP712, Nonces, IERC5805 { _moveDelegateVotes(oldDelegate, delegatee, _getVotingUnits(account)); } + /** + @dev Setter of _delegatee for inheriting contracts + */ + function _setDelegate(address account, address delegatee) internal virtual { + _delegatee[account] = delegatee; + } + /** * @dev Transfers, mints, or burns voting units. To register a mint, `from` should be zero. To register a burn, `to` * should be zero. Total supply of voting units will be adjusted with mints and burns. diff --git a/contracts/mocks/MultiVotesMock.sol b/contracts/mocks/MultiVotesMock.sol new file mode 100644 index 00000000000..b174f60e000 --- /dev/null +++ b/contracts/mocks/MultiVotesMock.sol @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.20; + +import {MultiVotes} from "../governance/utils/MultiVotes.sol"; + +abstract contract MultiVotesMock is MultiVotes { + 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 { + _transferVotingUnits(account, address(0), votes); + _votingUnits[account] -= votes; + } +} + +abstract contract MultiVotesTimestampMock is MultiVotesMock { + 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"; + } +} diff --git a/contracts/mocks/token/ERC20MultiVotesTimestampMock.sol b/contracts/mocks/token/ERC20MultiVotesTimestampMock.sol new file mode 100644 index 00000000000..3587ed65434 --- /dev/null +++ b/contracts/mocks/token/ERC20MultiVotesTimestampMock.sol @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.26; + +import {ERC20MultiVotes} from "../../token/ERC20/extensions/ERC20MultiVotes.sol"; +import {SafeCast} from "../../utils/math/SafeCast.sol"; + +abstract contract ERC20MultiVotesTimestampMock is ERC20MultiVotes { + 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"; + } +} diff --git a/contracts/token/ERC20/extensions/ERC20MultiVotes.sol b/contracts/token/ERC20/extensions/ERC20MultiVotes.sol new file mode 100644 index 00000000000..c198ab41cff --- /dev/null +++ b/contracts/token/ERC20/extensions/ERC20MultiVotes.sol @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Contracts (last updated v5.5.0) (governance/utils/VotesExtended.sol) +pragma solidity ^0.8.26; + +import {ERC20} from "../ERC20.sol"; +import {MultiVotes} from "../../../governance/utils/MultiVotes.sol"; +import {Checkpoints} from "../../../utils/structs/Checkpoints.sol"; + +/** + * @dev Extension of ERC-20 to support Compound-like voting and delegation with partial delegations included. + * This version is more generic than Compound's, and supports token supply up to 2^208^ - 1, while COMP is limited to 2^96^ - 1. + * + * NOTE: This contract does not provide interface compatibility with Compound's COMP token. + * + * This extension keeps a history (checkpoints) of each account's vote power. + * The default delegate that receives all available voting power is assigned by calling {MultiVotes-delegate} + * or by providing a signature to be used with {Votes-delegateBySig}. Partial static delegations can be assigned + * by calling {MultiVotes-multiDelegate}. + * Voting power can be queried through the public accessors {MultiVotes-getVotes} and {MultiVotes-getPastVotes}. + * + * By default, undelegated voting power is assigned to the zero address, this may require users to delegate + * voting power to themselves + */ +abstract contract ERC20MultiVotes is ERC20, MultiVotes { + /** + * @dev Total supply cap has been exceeded, introducing a risk of votes overflowing. + */ + error ERC20ExceededSafeSupply(uint256 increasedSupply, uint256 cap); + + /** + * @dev Maximum token supply. Defaults to `type(uint208).max` (2^208^ - 1). + * + * This maximum is enforced in {_update}. It limits the total supply of the token, which is otherwise a uint256, + * so that checkpoints can be stored in the Trace208 structure used by {Votes}. Increasing this value will not + * remove the underlying limitation, and will cause {_update} to fail because of a math overflow in + * {Votes-_transferVotingUnits}. An override could be used to further restrict the total supply (to a lower value) if + * additional logic requires it. When resolving override conflicts on this function, the minimum should be + * returned. + */ + function _maxSupply() internal view virtual returns (uint256) { + return type(uint208).max; + } + + /** + * @dev Move voting power when tokens are transferred. + * + * Emits a {IMultiVotes-DelegateVotesChanged} event. + */ + function _update(address from, address to, uint256 value) internal virtual override { + if (from == address(0)) { + uint256 supply = totalSupply() + value; + uint256 cap = _maxSupply(); + if (supply > cap) { + revert ERC20ExceededSafeSupply(supply, cap); + } + } + _transferVotingUnits(from, to, value); + super._update(from, to, value); + } + + /** + * @dev Returns the voting units of an `account`. + * + * WARNING: Overriding this function may compromise the internal vote accounting. + * `ERC20Votes` assumes tokens map to voting units 1:1 and this is not easy to change. + */ + function _getVotingUnits(address account) internal view virtual override returns (uint256) { + return balanceOf(account); + } + + /** + * @dev Get number of checkpoints for `account`. + */ + function numCheckpoints(address account) public view virtual returns (uint32) { + return _numCheckpoints(account); + } + + /** + * @dev Get the `pos`-th checkpoint for `account`. + */ + function checkpoints(address account, uint32 pos) public view virtual returns (Checkpoints.Checkpoint208 memory) { + return _checkpoints(account, pos); + } +} diff --git a/test/governance/utils/MultiVotes.behavior.js b/test/governance/utils/MultiVotes.behavior.js new file mode 100644 index 00000000000..2898cc77d94 --- /dev/null +++ b/test/governance/utils/MultiVotes.behavior.js @@ -0,0 +1,488 @@ +const { ethers } = require('hardhat'); +const { expect } = require('chai'); +const { mine } = require('@nomicfoundation/hardhat-network-helpers'); + +const { getDomain, MultiDelegation } = require('../../helpers/eip712'); +const time = require('../../helpers/time'); + +const { shouldBehaveLikeVotes } = require('./Votes.behavior'); + +function shouldBehaveLikeMultiVotes(tokens, { mode = 'blocknumber', fungible = true }) { + beforeEach(async function () { + [this.delegator, this.delegatee, this.bob, this.alice, this.other] = this.accounts; + this.domain = await getDomain(this.votes); + }); + + shouldBehaveLikeVotes(tokens, { mode, fungible }); + + describe('run multivotes workflow', function () { + beforeEach(async function () { + await mine(); + await this.votes.$_mint(this.delegator, 110); + await this.votes.$_mint(this.bob, 110); + await this.votes.$_burn(this.delegator, 10); + await this.votes.$_burn(this.bob, 10); + }); + + it('not existing delegates has zero assigned units', async function () { + expect(await this.votes.getDelegatedUnits(this.delegator, this.delegatee)).to.be.equal(0); + }); + + it('defaulted delegate starts with zero address', async function () { + expect(await this.votes.delegates(this.delegator)).to.equal('0x0000000000000000000000000000000000000000'); + }); + + describe('delegation with signature', function () { + it('rejects delegates and units mismatch', async function () { + expect(this.votes.connect(this.delegator).multiDelegate([this.delegatee], [1, 15])) + .to.be.revertedWithCustomError(this.votes, 'MultiVotesDelegatesAndUnitsMismatch') + .withArgs(1, 2); + + expect(this.votes.connect(this.delegator).multiDelegate([this.delegatee, this.bob], [1])) + .to.be.revertedWithCustomError(this.votes, 'MultiVotesDelegatesAndUnitsMismatch') + .withArgs(2, 1); + }); + + it('rejects no delegates given', async function () { + await expect(this.votes.connect(this.delegator).multiDelegate([], [])).to.be.revertedWithCustomError( + this.votes, + 'MultiVotesNoDelegatesGiven', + ); + }); + + it('rejects delegation exceeding available units', async function () { + await expect(this.votes.connect(this.delegator).multiDelegate([this.delegatee, this.bob], [90, 15])) + .to.be.revertedWithCustomError(this.votes, 'MultiVotesExceededAvailableUnits') + .withArgs(105, 100); + }); + + it('partial delegation', async function () { + const tx = await this.votes.connect(this.delegator).multiDelegate([this.delegatee, this.bob], [1, 15]); + await expect(tx) + .to.emit(this.votes, 'DelegateModified') + .withArgs(this.delegator, this.delegatee, 0, 1) + .to.emit(this.votes, 'DelegateModified') + .withArgs(this.delegator, this.bob, 0, 15) + .to.emit(this.votes, 'DelegateVotesChanged') + .withArgs(this.delegatee, 0, 1) + .to.emit(this.votes, 'DelegateVotesChanged') + .withArgs(this.bob, 0, 15); + + let multiDelegates = await this.votes.multiDelegates(this.delegator, 0, 100); + expect([...multiDelegates]).to.have.members([this.delegatee.address, this.bob.address]); + + expect(await this.votes.getDelegatedUnits(this.delegator, this.delegatee)).to.equal(1); + expect(await this.votes.getDelegatedUnits(this.delegator, this.bob)).to.equal(15); + expect(await this.votes.getFreeUnits(this.delegator)).to.equal(84); + + expect(await this.votes.getVotes(this.delegatee)).to.equal(1); + expect(await this.votes.getVotes(this.bob)).to.equal(15); + }); + + it('partial delegation stacking', async function () { + await this.votes.connect(this.delegator).multiDelegate([this.delegatee, this.bob], [1, 15]); + const tx = await this.votes.connect(this.delegator).multiDelegate([this.alice], [20]); + await expect(tx) + .to.emit(this.votes, 'DelegateModified') + .withArgs(this.delegator, this.alice, 0, 20) + .to.emit(this.votes, 'DelegateVotesChanged') + .withArgs(this.alice, 0, 20); + + let multiDelegates = await this.votes.multiDelegates(this.delegator, 0, 100); + expect([...multiDelegates]).to.have.members([this.delegatee.address, this.bob.address, this.alice.address]); + + expect(await this.votes.getDelegatedUnits(this.delegator, this.delegatee)).to.equal(1); + expect(await this.votes.getDelegatedUnits(this.delegator, this.bob)).to.equal(15); + expect(await this.votes.getDelegatedUnits(this.delegator, this.alice)).to.equal(20); + expect(await this.votes.getFreeUnits(this.delegator)).to.equal(64); + + expect(await this.votes.getVotes(this.delegatee)).to.equal(1); + expect(await this.votes.getVotes(this.bob)).to.equal(15); + expect(await this.votes.getVotes(this.alice)).to.equal(20); + }); + + it('partial delegation votes stacking', async function () { + await this.votes.connect(this.delegator).multiDelegate([this.alice], [20]); + const tx = await this.votes.connect(this.bob).multiDelegate([this.alice], [95]); + await expect(tx) + .to.emit(this.votes, 'DelegateModified') + .withArgs(this.bob, this.alice, 0, 95) + .to.emit(this.votes, 'DelegateVotesChanged') + .withArgs(this.alice, 20, 115); + + expect(await this.votes.getDelegatedUnits(this.delegator, this.alice)).to.equal(20); + expect(await this.votes.getDelegatedUnits(this.bob, this.alice)).to.equal(95); + expect(await this.votes.getFreeUnits(this.delegator)).to.equal(80); + expect(await this.votes.getFreeUnits(this.bob)).to.equal(5); + + expect(await this.votes.getVotes(this.alice)).to.equal(115); + }); + + it('partial delegation removal', async function () { + await this.votes.connect(this.delegator).multiDelegate([this.delegatee, this.bob], [1, 15]); + const tx = await this.votes.connect(this.delegator).multiDelegate([this.delegatee], [0]); + await expect(tx) + .to.emit(this.votes, 'DelegateModified') + .withArgs(this.delegator, this.delegatee, 1, 0) + .to.emit(this.votes, 'DelegateVotesChanged') + .withArgs(this.delegatee, 1, 0); + + let multiDelegates = await this.votes.multiDelegates(this.delegator, 0, 100); + expect([...multiDelegates]).to.have.members([this.bob.address]); + + expect(await this.votes.getDelegatedUnits(this.delegator, this.delegatee)).to.equal(0); + expect(await this.votes.getDelegatedUnits(this.delegator, this.bob)).to.equal(15); + expect(await this.votes.getFreeUnits(this.delegator)).to.equal(85); + + expect(await this.votes.getVotes(this.delegatee)).to.equal(0); + expect(await this.votes.getVotes(this.bob)).to.equal(15); + }); + + it('partial delegation units change', async function () { + await this.votes.connect(this.delegator).multiDelegate([this.delegatee, this.bob], [1, 15]); + const tx = await this.votes.connect(this.delegator).multiDelegate([this.delegatee, this.bob], [20, 10]); + await expect(tx) + .to.emit(this.votes, 'DelegateModified') + .withArgs(this.delegator, this.delegatee, 1, 20) + .to.emit(this.votes, 'DelegateModified') + .withArgs(this.delegator, this.bob, 15, 10) + .to.emit(this.votes, 'DelegateVotesChanged') + .withArgs(this.delegatee, 1, 20) + .to.emit(this.votes, 'DelegateVotesChanged') + .withArgs(this.bob, 15, 10); + + let multiDelegates = await this.votes.multiDelegates(this.delegator, 0, 100); + expect([...multiDelegates]).to.have.members([this.delegatee.address, this.bob.address]); + + expect(await this.votes.getDelegatedUnits(this.delegator, this.delegatee)).to.equal(20); + expect(await this.votes.getDelegatedUnits(this.delegator, this.bob)).to.equal(10); + expect(await this.votes.getFreeUnits(this.delegator)).to.equal(70); + + expect(await this.votes.getVotes(this.delegatee)).to.equal(20); + expect(await this.votes.getVotes(this.bob)).to.equal(10); + }); + + it('partial delegation unchanged', async function () { + await this.votes.connect(this.delegator).multiDelegate([this.delegatee, this.bob], [1, 15]); + const tx = await this.votes.connect(this.delegator).multiDelegate([this.delegatee, this.other], [1, 0]); + await expect(tx).to.not.emit(this.votes, 'DelegateModified').to.not.emit(this.votes, 'DelegateVotesChanged'); + + let multiDelegates = await this.votes.multiDelegates(this.delegator, 0, 100); + expect([...multiDelegates]).to.have.members([this.delegatee.address, this.bob.address]); + + expect(await this.votes.getDelegatedUnits(this.delegator, this.delegatee)).to.equal(1); + expect(await this.votes.getDelegatedUnits(this.delegator, this.bob)).to.equal(15); + expect(await this.votes.getFreeUnits(this.delegator)).to.equal(84); + + expect(await this.votes.getVotes(this.delegatee)).to.equal(1); + expect(await this.votes.getVotes(this.bob)).to.equal(15); + }); + + it('defaulted delegation', async function () { + await this.votes.connect(this.delegator).multiDelegate([this.other], [10]); + + await expect(this.votes.connect(this.delegator).delegate(this.delegatee)) + .to.emit(this.votes, 'DelegateChanged') + .withArgs(this.delegator, ethers.ZeroAddress, this.delegatee) + .to.emit(this.votes, 'DelegateVotesChanged') + .withArgs(this.delegatee, 0, 90); + + expect(await this.votes.getDelegatedUnits(this.delegator, this.delegatee)).to.equal(0); + expect(await this.votes.getVotes(this.delegatee)).to.equal(90); + }); + + it('defaulted delegation change', async function () { + await this.votes.connect(this.delegator).multiDelegate([this.other], [10]); + + await this.votes.connect(this.delegator).delegate(this.delegatee); + await expect(this.votes.connect(this.delegator).delegate(this.alice)) + .to.emit(this.votes, 'DelegateChanged') + .withArgs(this.delegator, this.delegatee, this.alice) + .to.emit(this.votes, 'DelegateVotesChanged') + .withArgs(this.delegatee, 90, 0) + .to.emit(this.votes, 'DelegateVotesChanged') + .withArgs(this.alice, 0, 90); + + expect(await this.votes.getDelegatedUnits(this.delegator, this.other)).to.equal(10); + expect(await this.votes.getVotes(this.delegatee)).to.equal(0); + expect(await this.votes.getVotes(this.alice)).to.equal(90); + }); + + it('defaulted delegation alongside partial delegations', async function () { + await this.votes.connect(this.delegator).multiDelegate([this.delegatee, this.other], [5, 5]); + await expect(this.votes.connect(this.delegator).delegate(this.delegatee)) + .to.emit(this.votes, 'DelegateChanged') + .withArgs(this.delegator, ethers.ZeroAddress, this.delegatee) + .to.emit(this.votes, 'DelegateVotesChanged') + .withArgs(this.delegatee, 5, 95); + + expect(await this.votes.getDelegatedUnits(this.delegator, this.delegatee)).to.equal(5); + expect(await this.votes.getFreeUnits(this.delegator)).to.equal(90); + expect(await this.votes.getVotes(this.delegatee)).to.equal(95); + expect(await this.votes.getVotes(ethers.ZeroAddress)).to.equal(0); + }); + + describe('with signature', function () { + const nonce = 0n; + + const MULTI_DELEGATION_TYPE = + 'MultiDelegation(address[] delegatees,uint256[] units,uint256 nonce,uint256 expiry)'; + const MULTI_DELEGATION_TYPEHASH = ethers.keccak256(ethers.toUtf8Bytes(MULTI_DELEGATION_TYPE)); + const abiCoder = new ethers.AbiCoder(); + const getSigner = (delegatees, units, nonce, expiry, v, r, s, domain) => { + const delegatesHash = ethers.keccak256(ethers.solidityPacked(['address[]'], [delegatees])); + const unitsHash = ethers.keccak256(ethers.solidityPacked(['uint256[]'], [units])); + const structHash = ethers.keccak256( + abiCoder.encode( + ['bytes32', 'bytes32', 'bytes32', 'uint256', 'uint256'], + [MULTI_DELEGATION_TYPEHASH, delegatesHash, unitsHash, nonce, expiry], + ), + ); + const domainSeparator = ethers.TypedDataEncoder.hashDomain(domain); + const digest = ethers.keccak256( + ethers.solidityPacked(['string', 'bytes32', 'bytes32'], ['\x19\x01', domainSeparator, structHash]), + ); + return ethers.recoverAddress(digest, { v, r, s }); + }; + + it('accept signed partial delegation', async function () { + const { r, s, v } = await this.delegator + .signTypedData( + this.domain, + { MultiDelegation }, + { + delegatees: [this.delegatee.address], + units: [15], + nonce, + expiry: ethers.MaxUint256, + }, + ) + .then(ethers.Signature.from); + + const tx = await this.votes.multiDelegateBySig([this.delegatee], [15], nonce, ethers.MaxUint256, v, r, s); + const timepoint = await time.clockFromReceipt[mode](tx); + + await expect(tx) + .to.emit(this.votes, 'DelegateModified') + .withArgs(this.delegator, this.delegatee, 0, 15) + .to.emit(this.votes, 'DelegateVotesChanged') + .withArgs(this.delegatee, 0, 15); + + let multiDelegates = await this.votes.multiDelegates(this.delegator, 0, 100); + expect([...multiDelegates]).to.have.members([this.delegatee.address]); + expect(await this.votes.getDelegatedUnits(this.delegator, this.delegatee)).to.equal(15); + + expect(await this.votes.getVotes(this.delegator.address)).to.equal(0n); + expect(await this.votes.getVotes(this.delegatee)).to.equal(15); + expect(await this.votes.getPastVotes(this.delegatee, timepoint - 5n)).to.equal(0n); + await mine(); + expect(await this.votes.getPastVotes(this.delegatee, timepoint)).to.equal(15); + }); + + it('rejects reused signature', async function () { + const { r, s, v } = await this.delegator + .signTypedData( + this.domain, + { MultiDelegation }, + { + delegatees: [this.delegatee.address], + units: [15], + nonce, + expiry: ethers.MaxUint256, + }, + ) + .then(ethers.Signature.from); + + await this.votes.multiDelegateBySig([this.delegatee], [15], nonce, ethers.MaxUint256, v, r, s); + + await expect(this.votes.multiDelegateBySig([this.delegatee], [15], nonce, ethers.MaxUint256, v, r, s)) + .to.be.revertedWithCustomError(this.votes, 'InvalidAccountNonce') + .withArgs(this.delegator, nonce + 1n); + }); + + it('rejects bad delegatees', async function () { + const { r, s, v } = await this.delegator + .signTypedData( + this.domain, + { MultiDelegation }, + { + delegatees: [this.delegatee.address], + units: [15], + nonce, + expiry: ethers.MaxUint256, + }, + ) + .then(ethers.Signature.from); + + const badSigner = getSigner([this.other.address], [15], nonce, ethers.MaxUint256, v, r, s, this.domain); + await this.votes.$_mint(badSigner, 100); + + const tx = await this.votes.multiDelegateBySig([this.other], [15], nonce, ethers.MaxUint256, v, r, s); + const receipt = await tx.wait(); + + const [delegateModified] = receipt.logs.filter( + log => this.votes.interface.parseLog(log)?.name === 'DelegateModified', + ); + const [delegateVotesChanged] = receipt.logs.filter( + log => this.votes.interface.parseLog(log)?.name === 'DelegateVotesChanged', + ); + + const log1 = this.votes.interface.parseLog(delegateModified); + expect(log1.args.delegator).to.not.be.equal(this.delegator); + expect(log1.args.delegate).to.equal(this.other); + expect(log1.args.fromUnits).to.equal(0); + expect(log1.args.toUnits).to.equal(15); + + const log2 = this.votes.interface.parseLog(delegateVotesChanged); + expect(log2.args.delegate).to.equal(this.other); + expect(log2.args.previousVotes).to.equal(0); + expect(log2.args.newVotes).to.equal(15); + + let multiDelegates = await this.votes.multiDelegates(this.delegator, 0, 100); + expect([...multiDelegates]).to.have.members([]); + expect(await this.votes.getDelegatedUnits(this.delegator, this.other)).to.equal(0); + expect(await this.votes.getVotes(this.other.address)).to.equal(15); + }); + + it('rejects bad units', async function () { + const { r, s, v } = await this.delegator + .signTypedData( + this.domain, + { MultiDelegation }, + { + delegatees: [this.delegatee.address], + units: [15], + nonce, + expiry: ethers.MaxUint256, + }, + ) + .then(ethers.Signature.from); + + const badSigner = getSigner([this.delegatee.address], [8], nonce, ethers.MaxUint256, v, r, s, this.domain); + await this.votes.$_mint(badSigner, 100); + + const tx = await this.votes.multiDelegateBySig([this.delegatee], [8], nonce, ethers.MaxUint256, v, r, s); + const receipt = await tx.wait(); + + const [delegateModified] = receipt.logs.filter( + log => this.votes.interface.parseLog(log)?.name === 'DelegateModified', + ); + const [delegateVotesChanged] = receipt.logs.filter( + log => this.votes.interface.parseLog(log)?.name === 'DelegateVotesChanged', + ); + + const log1 = this.votes.interface.parseLog(delegateModified); + expect(log1).to.exist; + expect(log1.args.delegator).to.not.be.equal(this.delegator); + expect(log1.args.delegate).to.equal(this.delegatee); + expect(log1.args.fromUnits).to.equal(0); + expect(log1.args.toUnits).to.equal(8); + + const log2 = this.votes.interface.parseLog(delegateVotesChanged); + expect(log2).to.exist; + expect(log2.args.delegate).to.equal(this.delegatee); + expect(log2.args.previousVotes).to.equal(0); + expect(log2.args.newVotes).to.equal(8); + + let multiDelegates = await this.votes.multiDelegates(this.delegator, 0, 100); + expect([...multiDelegates]).to.have.members([]); + expect(await this.votes.getDelegatedUnits(this.delegator, this.delegatee)).to.equal(0); + expect(await this.votes.getVotes(this.delegatee.address)).to.equal(8); + }); + + it('rejects bad nonce', async function () { + const { r, s, v } = await this.delegator + .signTypedData( + this.domain, + { MultiDelegation }, + { + delegatees: [this.delegatee.address], + units: [15], + nonce: nonce + 1n, + expiry: ethers.MaxUint256, + }, + ) + .then(ethers.Signature.from); + + await expect(this.votes.multiDelegateBySig([this.delegatee], [15], nonce + 1n, ethers.MaxUint256, v, r, s)) + .to.be.revertedWithCustomError(this.votes, 'InvalidAccountNonce') + .withArgs(this.delegator, 0); + }); + + it('rejects expired permit', async function () { + const expiry = (await time.clock.timestamp()) - 1n; + const { r, s, v } = await this.delegator + .signTypedData( + this.domain, + { MultiDelegation }, + { + delegatees: [this.delegatee.address], + units: [15], + nonce, + expiry: expiry, + }, + ) + .then(ethers.Signature.from); + + await expect(this.votes.multiDelegateBySig([this.delegatee], [15], nonce, expiry, v, r, s)) + .to.be.revertedWithCustomError(this.votes, 'VotesExpiredSignature') + .withArgs(expiry); + }); + }); + }); + + describe('multiDelegates', function () { + it('returns empty array if no partial delegation is active', async function () { + expect(await this.votes.multiDelegates(this.delegator, 0, 100)).to.deep.equal([]); + }); + + it('rejects if start is bigger than end', async function () { + expect(this.votes.multiDelegates(this.delegator, 1, 0)) + .to.be.revertedWithCustomError(this.votes, 'StartIsBiggerThanEnd') + .withArgs(1, 0); + }); + + it('returns empty array if starts is bigger than delegations list', async function () { + await this.votes.connect(this.delegator).multiDelegate([this.delegatee, this.bob], [1, 15]); + expect(await this.votes.multiDelegates(this.delegator, 5, 100)).to.deep.equal([]); + }); + + it('returns delegates list', async function () { + await this.votes.connect(this.delegator).multiDelegate([this.delegatee, this.bob, this.alice], [1, 15, 84]); + const multiDelegates = await this.votes.multiDelegates(this.delegator, 0, 2); + expect([...multiDelegates]).to.have.members([this.delegatee.address, this.bob.address, this.alice.address]); + }); + + it('cuts end if its bigger than delegations list', async function () { + await this.votes.connect(this.delegator).multiDelegate([this.delegatee, this.bob, this.alice], [1, 15, 84]); + const multiDelegates = await this.votes.multiDelegates(this.delegator, 0, 100); + expect([...multiDelegates]).to.have.members([this.delegatee.address, this.bob.address, this.alice.address]); + }); + }); + + describe('burning', async function () { + it('burns', async function () { + await this.votes.$_burn(this.delegator, 50); + expect(await this.votes.getFreeUnits(this.delegator)).to.equal(50); + expect(await this.votes.$_getVotingUnits(this.delegator)).to.equal(50); + }); + + it('rejects more than available burn', async function () { + await expect(this.votes.$_burn(this.delegator, 101)) + .to.be.revertedWithCustomError(this.votes, 'MultiVotesExceededAvailableUnits') + .withArgs(101, 100); + }); + + it('rejects burn of assigned units', async function () { + await this.votes.connect(this.delegator).multiDelegate([this.delegatee, this.other], [5, 35]); + await expect(this.votes.$_burn(this.delegator, 61)) + .to.be.revertedWithCustomError(this.votes, 'MultiVotesExceededAvailableUnits') + .withArgs(61, 60); + }); + }); + }); +} + +module.exports = { + shouldBehaveLikeMultiVotes, +}; diff --git a/test/governance/utils/MultiVotes.test.js b/test/governance/utils/MultiVotes.test.js new file mode 100644 index 00000000000..7324e00e06a --- /dev/null +++ b/test/governance/utils/MultiVotes.test.js @@ -0,0 +1,146 @@ +const { ethers } = require('hardhat'); +const { expect } = require('chai'); +const { loadFixture, mine } = require('@nomicfoundation/hardhat-network-helpers'); + +const { zip } = require('../../helpers/iterate'); + +const { shouldBehaveLikeMultiVotes } = require('./MultiVotes.behavior'); + +const MODES = { + blocknumber: '$MultiVotesMock', + timestamp: '$MultiVotesTimestampMock', +}; + +const AMOUNTS = [ethers.parseEther('10000000'), 10n, 20n]; + +describe('MultiVotes', 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 = 'Multi delegate 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)); + }); + + shouldBehaveLikeMultiVotes(AMOUNTS, { mode, fungible: true }); + + describe('performs critical operations', function () { + beforeEach(async function () { + [this.delegator, this.delegatee, this.bob, this.alice, this.other] = this.accounts; + await mine(); + await this.votes.$_mint(this.delegator, 100); + await this.votes.$_mint(this.bob, 100); + }); + + it('mints alongside defaulted and partial delegation', async function () { + await this.votes.connect(this.delegator).multiDelegate([this.delegatee, this.bob], [1, 15]); + await this.votes.connect(this.delegator).delegate(this.delegatee); + await this.votes.$_mint(this.delegator, 200); + + expect(await this.votes.$_getVotingUnits(this.delegator)).to.equal(300); + expect(await this.votes.getFreeUnits(this.delegator)).to.equal(284); + expect(await this.votes.getVotes(this.delegatee)).to.equal(285); + }); + + it('keeps coherent _accountHasDelegate state', async function () { + await this.votes.connect(this.delegator).multiDelegate([this.delegatee], [1]); + expect(await this.votes.$_accountHasDelegate(this.delegator, this.delegatee)).to.equal(true); + expect(await this.votes.$_accountHasDelegate(this.delegator, this.bob)).to.equal(false); + + await this.votes.connect(this.delegator).multiDelegate([this.bob], [15]); + expect(await this.votes.$_accountHasDelegate(this.delegator, this.bob)).to.equal(true); + expect(await this.votes.$_accountHasDelegate(this.delegator, this.delegatee)).to.equal(true); + + await this.votes.connect(this.delegator).multiDelegate([this.delegatee], [0]); + expect(await this.votes.$_accountHasDelegate(this.delegator, this.delegatee)).to.equal(false); + expect(await this.votes.$_accountHasDelegate(this.delegator, this.bob)).to.equal(true); + + await this.votes.connect(this.delegator).multiDelegate([this.delegatee, this.bob], [50, 0]); + expect(await this.votes.$_accountHasDelegate(this.delegator, this.delegatee)).to.equal(true); + expect(await this.votes.$_accountHasDelegate(this.delegator, this.bob)).to.equal(false); + }); + + it('keep coherent defaulted delegate state', async function () { + await this.votes.connect(this.delegator).delegate(this.delegatee); + await this.votes.connect(this.delegator).delegate(this.delegatee); + await this.votes.connect(this.delegator).multiDelegate([this.delegatee, this.other], [5, 5]); + await this.votes.connect(this.delegator).multiDelegate([this.delegatee, this.other], [5, 5]); + expect(await this.votes.getVotes(this.delegatee)).to.equal(95); + + await this.votes.connect(this.delegator).multiDelegate([this.delegatee], [0]); + expect(await this.votes.getVotes(this.delegatee)).to.equal(95); + + await this.votes.connect(this.delegator).multiDelegate([this.bob, this.alice], [20, 10]); + expect(await this.votes.getVotes(this.delegatee)).to.equal(65); + + await this.votes.connect(this.bob).multiDelegate([this.delegatee], [30]); + expect(await this.votes.getVotes(this.delegatee)).to.equal(95); + + await this.votes.connect(this.bob).delegate(this.delegatee); + expect(await this.votes.getVotes(this.delegatee)).to.equal(165); + + await this.votes.connect(this.bob).multiDelegate([this.delegatee], [20]); + expect(await this.votes.getVotes(this.delegatee)).to.equal(165); + + await this.votes.connect(this.bob).delegate(ethers.ZeroAddress); + expect(await this.votes.getVotes(this.delegatee)).to.equal(85); + + await this.votes.connect(this.bob).delegate(this.delegatee); + await this.votes.connect(this.bob).delegate(this.alice); + expect(await this.votes.getVotes(this.delegatee)).to.equal(85); + expect(await this.votes.getVotes(this.alice)).to.equal(90); + + await this.votes.connect(this.bob).multiDelegate([this.delegatee], [10]); + expect(await this.votes.getVotes(this.delegatee)).to.equal(75); + + await this.votes.connect(this.delegator).delegate(this.delegatee); + await this.votes + .connect(this.delegator) + .multiDelegate([this.delegatee, this.alice, this.bob, this.other], [100, 0, 0, 0]); + expect(await this.votes.getVotes(this.delegatee)).to.equal(110); + + await this.votes + .connect(this.delegator) + .multiDelegate([this.delegatee, this.alice, this.bob, this.other], [0, 5, 5, 5]); + await expect( + this.votes + .connect(this.delegator) + .multiDelegate([this.delegatee, this.alice, this.bob, this.other], [89, 4, 6, 2]), + ) + .to.be.revertedWithCustomError(this.votes, 'MultiVotesExceededAvailableUnits') + .withArgs(86, 85); + await this.votes + .connect(this.delegator) + .multiDelegate([this.delegatee, this.alice, this.bob, this.other], [0, 0, 0, 0]); + + await mine(); + + await this.votes.connect(this.delegator).multiDelegate([this.delegatee, this.alice], [50, 50]); + await expect(this.votes.connect(this.delegator).multiDelegate([this.alice, this.delegatee], [0, 101])) + .to.be.revertedWithCustomError(this.votes, 'MultiVotesExceededAvailableUnits') + .withArgs(1, 0); + + await this.votes.connect(this.delegator).multiDelegate([this.delegatee, this.alice], [50, 50]); + await expect(this.votes.connect(this.delegator).multiDelegate([this.alice, this.delegatee], [3, 98])) + .to.be.revertedWithCustomError(this.votes, 'MultiVotesExceededAvailableUnits') + .withArgs(1, 0); + }); + }); + }); + } +}); diff --git a/test/helpers/eip712-types.js b/test/helpers/eip712-types.js index fb6fe3aebaf..b568ea70ee1 100644 --- a/test/helpers/eip712-types.js +++ b/test/helpers/eip712-types.js @@ -23,6 +23,7 @@ module.exports = mapValues( }, OverrideBallot: { proposalId: 'uint256', support: 'uint8', voter: 'address', nonce: 'uint256', reason: 'string' }, Delegation: { delegatee: 'address', nonce: 'uint256', expiry: 'uint256' }, + MultiDelegation: { delegatees: 'address[]', units: 'uint256[]', nonce: 'uint256', expiry: 'uint256' }, ForwardRequest: { from: 'address', to: 'address', diff --git a/test/token/ERC20/extensions/ERC20MultiVotes.test.js b/test/token/ERC20/extensions/ERC20MultiVotes.test.js new file mode 100644 index 00000000000..901c9b2e062 --- /dev/null +++ b/test/token/ERC20/extensions/ERC20MultiVotes.test.js @@ -0,0 +1,1090 @@ +const { ethers } = require('hardhat'); +const { expect } = require('chai'); +const { loadFixture, mine } = require('@nomicfoundation/hardhat-network-helpers'); + +const { getDomain, Delegation, MultiDelegation } = require('../../../helpers/eip712'); +const { batchInBlock } = require('../../../helpers/txpool'); +const time = require('../../../helpers/time'); + +const { shouldBehaveLikeMultiVotes } = require('../../../governance/utils/MultiVotes.behavior'); + +const TOKENS = [ + { Token: '$ERC20MultiVotes', mode: 'blocknumber' }, + { Token: '$ERC20MultiVotesTimestampMock', mode: 'timestamp' }, +]; + +const name = 'My Token'; +const symbol = 'MTKN'; +const version = '1'; +const supply = ethers.parseEther('10000000'); + +describe('ERC20MultiVotes', function () { + for (const { Token, mode } of TOKENS) { + const fixture = async () => { + // accounts is required by shouldBehaveLikeMultiVotes + const accounts = await ethers.getSigners(); + const [holder, recipient, delegatee, other1, other2] = accounts; + + const token = await ethers.deployContract(Token, [name, symbol, name, version]); + const domain = await getDomain(token); + + return { accounts, holder, recipient, delegatee, other1, other2, token, domain }; + }; + + describe(`vote with ${mode}`, function () { + beforeEach(async function () { + Object.assign(this, await loadFixture(fixture)); + this.votes = this.token; + }); + + // includes ERC6372 behavior check + shouldBehaveLikeMultiVotes([1, 17, 42], { mode, fungible: true }); + + it('initial nonce is 0', async function () { + expect(await this.token.nonces(this.holder)).to.equal(0n); + }); + + it('minting restriction', async function () { + const value = 2n ** 208n; + await expect(this.token.$_mint(this.holder, value)) + .to.be.revertedWithCustomError(this.token, 'ERC20ExceededSafeSupply') + .withArgs(value, value - 1n); + }); + + it('recent checkpoints', async function () { + await this.token.connect(this.holder).delegate(this.holder); + for (let i = 0; i < 6; i++) { + await this.token.$_mint(this.holder, 1n); + } + const timepoint = await time.clock[mode](); + expect(await this.token.numCheckpoints(this.holder)).to.equal(6n); + // recent + expect(await this.token.getPastVotes(this.holder, timepoint - 1n)).to.equal(5n); + // non-recent + expect(await this.token.getPastVotes(this.holder, timepoint - 6n)).to.equal(0n); + }); + + it('initial free units is 0', async function () { + expect(await this.token.getFreeUnits(this.holder)).to.equal(0); + }); + + describe('set defaulted delegation', function () { + describe('call', function () { + it('defaulted delegation with balance', async function () { + await this.token.$_mint(this.holder, supply); + expect(await this.token.delegates(this.holder)).to.equal(ethers.ZeroAddress); + + const tx = await this.token.connect(this.holder).delegate(this.holder); + const timepoint = await time.clockFromReceipt[mode](tx); + + await expect(tx) + .to.emit(this.token, 'DelegateChanged') + .withArgs(this.holder, ethers.ZeroAddress, this.holder) + .to.emit(this.token, 'DelegateVotesChanged') + .withArgs(this.holder, 0n, supply); + + expect(await this.token.delegates(this.holder)).to.equal(this.holder); + expect(await this.token.getVotes(this.holder)).to.equal(supply); + expect(await this.token.getPastVotes(this.holder, timepoint - 1n)).to.equal(0n); + await mine(); + expect(await this.token.getPastVotes(this.holder, timepoint)).to.equal(supply); + }); + + it('defaulted delegation without balance', async function () { + expect(await this.token.delegates(this.holder)).to.equal(ethers.ZeroAddress); + + await expect(this.token.connect(this.holder).delegate(this.holder)) + .to.emit(this.token, 'DelegateChanged') + .withArgs(this.holder, ethers.ZeroAddress, this.holder) + .to.not.emit(this.token, 'DelegateVotesChanged'); + + expect(await this.token.delegates(this.holder)).to.equal(this.holder); + }); + }); + + describe('with signature', function () { + const nonce = 0n; + + beforeEach(async function () { + await this.token.$_mint(this.holder, supply); + }); + + it('accept signed delegation', async function () { + const { r, s, v } = await this.holder + .signTypedData( + this.domain, + { Delegation }, + { + delegatee: this.holder.address, + nonce, + expiry: ethers.MaxUint256, + }, + ) + .then(ethers.Signature.from); + + expect(await this.token.delegates(this.holder)).to.equal(ethers.ZeroAddress); + + const tx = await this.token.delegateBySig(this.holder, nonce, ethers.MaxUint256, v, r, s); + const timepoint = await time.clockFromReceipt[mode](tx); + + await expect(tx) + .to.emit(this.token, 'DelegateChanged') + .withArgs(this.holder, ethers.ZeroAddress, this.holder) + .to.emit(this.token, 'DelegateVotesChanged') + .withArgs(this.holder, 0n, supply); + + expect(await this.token.delegates(this.holder)).to.equal(this.holder); + + expect(await this.token.getVotes(this.holder)).to.equal(supply); + expect(await this.token.getPastVotes(this.holder, timepoint - 1n)).to.equal(0n); + await mine(); + expect(await this.token.getPastVotes(this.holder, timepoint)).to.equal(supply); + }); + + it('rejects reused signature', async function () { + const { r, s, v } = await this.holder + .signTypedData( + this.domain, + { Delegation }, + { + delegatee: this.holder.address, + nonce, + expiry: ethers.MaxUint256, + }, + ) + .then(ethers.Signature.from); + + await this.token.delegateBySig(this.holder, nonce, ethers.MaxUint256, v, r, s); + + await expect(this.token.delegateBySig(this.holder, nonce, ethers.MaxUint256, v, r, s)) + .to.be.revertedWithCustomError(this.token, 'InvalidAccountNonce') + .withArgs(this.holder, nonce + 1n); + }); + + it('rejects bad delegatee', async function () { + const { r, s, v } = await this.holder + .signTypedData( + this.domain, + { Delegation }, + { + delegatee: this.holder.address, + nonce, + expiry: ethers.MaxUint256, + }, + ) + .then(ethers.Signature.from); + + const tx = await this.token.delegateBySig(this.delegatee, nonce, ethers.MaxUint256, v, r, s); + + const { args } = await tx + .wait() + .then(receipt => receipt.logs.find(event => event.fragment.name == 'DelegateChanged')); + expect(args[0]).to.not.equal(this.holder); + expect(args[1]).to.equal(ethers.ZeroAddress); + expect(args[2]).to.equal(this.delegatee); + }); + + it('rejects bad nonce', async function () { + const { r, s, v, serialized } = await this.holder + .signTypedData( + this.domain, + { Delegation }, + { + delegatee: this.holder.address, + nonce, + expiry: ethers.MaxUint256, + }, + ) + .then(ethers.Signature.from); + + const recovered = ethers.verifyTypedData( + this.domain, + { Delegation }, + { + delegatee: this.holder.address, + nonce: nonce + 1n, + expiry: ethers.MaxUint256, + }, + serialized, + ); + + await expect(this.token.delegateBySig(this.holder, nonce + 1n, ethers.MaxUint256, v, r, s)) + .to.be.revertedWithCustomError(this.token, 'InvalidAccountNonce') + .withArgs(recovered, nonce); + }); + + it('rejects expired permit', async function () { + const expiry = (await time.clock.timestamp()) - time.duration.weeks(1); + + const { r, s, v } = await this.holder + .signTypedData( + this.domain, + { Delegation }, + { + delegatee: this.holder.address, + nonce, + expiry, + }, + ) + .then(ethers.Signature.from); + + await expect(this.token.delegateBySig(this.holder, nonce, expiry, v, r, s)) + .to.be.revertedWithCustomError(this.token, 'VotesExpiredSignature') + .withArgs(expiry); + }); + }); + }); + + describe('set partial delegation', function () { + describe('call', function () { + it('partial delegation with balance', async function () { + await this.token.$_mint(this.holder, supply); + expect(await this.token.getDelegatedUnits(this.holder, this.holder)).to.equal(0); + + const tx = await this.token.multiDelegate([this.holder], [supply]); + const timepoint = await time.clockFromReceipt[mode](tx); + + await expect(tx) + .to.emit(this.token, 'DelegateModified') + .withArgs(this.holder, this.holder, 0, supply) + .to.emit(this.token, 'DelegateVotesChanged') + .withArgs(this.holder, 0n, supply); + + let multiDelegates = await this.token.multiDelegates(this.holder, 0, 100); + expect([...multiDelegates]).to.have.members([this.holder.address]); + expect(await this.token.getDelegatedUnits(this.holder, this.holder)).to.equal(supply); + expect(await this.token.getVotes(this.holder)).to.equal(supply); + expect(await this.token.getPastVotes(this.holder, timepoint - 1n)).to.equal(0n); + await mine(); + expect(await this.token.getPastVotes(this.holder, timepoint)).to.equal(supply); + }); + + it('partial delegation without balance', async function () { + await expect(this.token.multiDelegate([this.holder], [supply])) + .to.revertedWithCustomError(this.token, 'MultiVotesExceededAvailableUnits') + .withArgs(supply, 0); + }); + + describe('with signature', function () { + const nonce = 0n; + + const MULTI_DELEGATION_TYPE = + 'MultiDelegation(address[] delegatees,uint256[] units,uint256 nonce,uint256 expiry)'; + const MULTI_DELEGATION_TYPEHASH = ethers.keccak256(ethers.toUtf8Bytes(MULTI_DELEGATION_TYPE)); + const abiCoder = new ethers.AbiCoder(); + const getSigner = (delegatees, units, nonce, expiry, v, r, s, domain) => { + const delegatesHash = ethers.keccak256(ethers.solidityPacked(['address[]'], [delegatees])); + const unitsHash = ethers.keccak256(ethers.solidityPacked(['uint256[]'], [units])); + const structHash = ethers.keccak256( + abiCoder.encode( + ['bytes32', 'bytes32', 'bytes32', 'uint256', 'uint256'], + [MULTI_DELEGATION_TYPEHASH, delegatesHash, unitsHash, nonce, expiry], + ), + ); + const domainSeparator = ethers.TypedDataEncoder.hashDomain(domain); + const digest = ethers.keccak256( + ethers.solidityPacked(['string', 'bytes32', 'bytes32'], ['\x19\x01', domainSeparator, structHash]), + ); + return ethers.recoverAddress(digest, { v, r, s }); + }; + + beforeEach(async function () { + await this.token.$_mint(this.holder, supply); + }); + + it('accept signed partial delegation', async function () { + const { r, s, v } = await this.holder + .signTypedData( + this.domain, + { MultiDelegation }, + { + delegatees: [this.delegatee.address], + units: [supply], + nonce, + expiry: ethers.MaxUint256, + }, + ) + .then(ethers.Signature.from); + + const tx = await this.token.multiDelegateBySig( + [this.delegatee], + [supply], + nonce, + ethers.MaxUint256, + v, + r, + s, + ); + const timepoint = await time.clockFromReceipt[mode](tx); + + await expect(tx) + .to.emit(this.token, 'DelegateModified') + .withArgs(this.holder, this.delegatee, 0, supply) + .to.emit(this.token, 'DelegateVotesChanged') + .withArgs(this.delegatee, 0, supply); + + let multiDelegates = await this.token.multiDelegates(this.holder, 0, 100); + expect([...multiDelegates]).to.have.members([this.delegatee.address]); + expect(await this.token.getDelegatedUnits(this.holder, this.delegatee)).to.equal(supply); + + expect(await this.token.getVotes(this.holder.address)).to.equal(0n); + expect(await this.token.getVotes(this.delegatee)).to.equal(supply); + expect(await this.token.getPastVotes(this.delegatee, timepoint - 3n)).to.equal(0n); + await mine(); + expect(await this.token.getPastVotes(this.delegatee, timepoint)).to.equal(supply); + }); + + it('rejects reused signature', async function () { + const { r, s, v } = await this.holder + .signTypedData( + this.domain, + { MultiDelegation }, + { + delegatees: [this.delegatee.address], + units: [supply], + nonce, + expiry: ethers.MaxUint256, + }, + ) + .then(ethers.Signature.from); + + await this.token.multiDelegateBySig([this.delegatee], [supply], nonce, ethers.MaxUint256, v, r, s); + + await expect(this.token.multiDelegateBySig([this.delegatee], [supply], nonce, ethers.MaxUint256, v, r, s)) + .to.be.revertedWithCustomError(this.token, 'InvalidAccountNonce') + .withArgs(this.holder, nonce + 1n); + }); + + it('rejects bad delegatees', async function () { + const { r, s, v } = await this.holder + .signTypedData( + this.domain, + { MultiDelegation }, + { + delegatees: [this.delegatee.address], + units: [supply], + nonce, + expiry: ethers.MaxUint256, + }, + ) + .then(ethers.Signature.from); + + const badSigner = getSigner( + [this.other.address], + [supply], + nonce, + ethers.MaxUint256, + v, + r, + s, + this.domain, + ); + await this.token.$_mint(badSigner, supply); + + const tx = await this.token.multiDelegateBySig([this.other], [supply], nonce, ethers.MaxUint256, v, r, s); + const receipt = await tx.wait(); + + const [delegateModified] = receipt.logs.filter( + log => this.token.interface.parseLog(log)?.name === 'DelegateModified', + ); + const [delegateVotesChanged] = receipt.logs.filter( + log => this.token.interface.parseLog(log)?.name === 'DelegateVotesChanged', + ); + + const log1 = this.token.interface.parseLog(delegateModified); + expect(log1.args.delegator).to.not.be.equal(this.holder); + expect(log1.args.delegate).to.equal(this.other); + expect(log1.args.fromUnits).to.equal(0); + expect(log1.args.toUnits).to.equal(supply); + + const log2 = this.token.interface.parseLog(delegateVotesChanged); + expect(log2.args.delegate).to.equal(this.other); + expect(log2.args.previousVotes).to.equal(0); + expect(log2.args.newVotes).to.equal(supply); + + let multiDelegates = await this.token.multiDelegates(this.holder, 0, 100); + expect([...multiDelegates]).to.have.members([]); + expect(await this.token.getDelegatedUnits(this.holder, this.other)).to.equal(0); + expect(await this.token.getVotes(this.other.address)).to.equal(supply); + }); + + it('rejects bad units', async function () { + const { r, s, v } = await this.holder + .signTypedData( + this.domain, + { MultiDelegation }, + { + delegatees: [this.delegatee.address], + units: [supply], + nonce, + expiry: ethers.MaxUint256, + }, + ) + .then(ethers.Signature.from); + + const badSigner = getSigner( + [this.delegatee.address], + [supply - 1n], + nonce, + ethers.MaxUint256, + v, + r, + s, + this.domain, + ); + await this.token.$_mint(badSigner, supply); + + const tx = await this.token.multiDelegateBySig( + [this.delegatee], + [supply - 1n], + nonce, + ethers.MaxUint256, + v, + r, + s, + ); + const receipt = await tx.wait(); + + const [delegateModified] = receipt.logs.filter( + log => this.token.interface.parseLog(log)?.name === 'DelegateModified', + ); + const [delegateVotesChanged] = receipt.logs.filter( + log => this.token.interface.parseLog(log)?.name === 'DelegateVotesChanged', + ); + + const log1 = this.token.interface.parseLog(delegateModified); + expect(log1).to.exist; + expect(log1.args.delegator).to.not.be.equal(this.holder); + expect(log1.args.delegate).to.equal(this.delegatee); + expect(log1.args.fromUnits).to.equal(0); + expect(log1.args.toUnits).to.equal(supply - 1n); + + const log2 = this.token.interface.parseLog(delegateVotesChanged); + expect(log2).to.exist; + expect(log2.args.delegate).to.equal(this.delegatee); + expect(log2.args.previousVotes).to.equal(0); + expect(log2.args.newVotes).to.equal(supply - 1n); + + let multiDelegates = await this.token.multiDelegates(this.holder, 0, 100); + expect([...multiDelegates]).to.have.members([]); + expect(await this.token.getDelegatedUnits(this.holder, this.delegatee)).to.equal(0); + expect(await this.token.getVotes(this.delegatee.address)).to.equal(supply - 1n); + }); + + it('rejects bad nonce', async function () { + const { r, s, v } = await this.holder + .signTypedData( + this.domain, + { MultiDelegation }, + { + delegatees: [this.delegatee.address], + units: [supply], + nonce: nonce + 1n, + expiry: ethers.MaxUint256, + }, + ) + .then(ethers.Signature.from); + + await expect( + this.token.multiDelegateBySig([this.delegatee], [supply], nonce + 1n, ethers.MaxUint256, v, r, s), + ) + .to.be.revertedWithCustomError(this.token, 'InvalidAccountNonce') + .withArgs(this.holder, 0); + }); + + it('rejects expired permit', async function () { + const expiry = (await time.clock.timestamp()) - 1n; + const { r, s, v } = await this.holder + .signTypedData( + this.domain, + { MultiDelegation }, + { + delegatees: [this.delegatee.address], + units: [supply], + nonce, + expiry: expiry, + }, + ) + .then(ethers.Signature.from); + + await expect(this.token.multiDelegateBySig([this.delegatee], [supply], nonce, expiry, v, r, s)) + .to.be.revertedWithCustomError(this.token, 'VotesExpiredSignature') + .withArgs(expiry); + }); + }); + }); + }); + + describe('change defaulted delegation', function () { + beforeEach(async function () { + await this.token.$_mint(this.holder, supply); + await this.token.connect(this.holder).delegate(this.holder); + }); + + it('call', async function () { + expect(await this.token.delegates(this.holder)).to.equal(this.holder); + + const tx = await this.token.connect(this.holder).delegate(this.delegatee); + const timepoint = await time.clockFromReceipt[mode](tx); + + await expect(tx) + .to.emit(this.token, 'DelegateChanged') + .withArgs(this.holder, this.holder, this.delegatee) + .to.emit(this.token, 'DelegateVotesChanged') + .withArgs(this.holder, supply, 0n) + .to.emit(this.token, 'DelegateVotesChanged') + .withArgs(this.delegatee, 0n, supply); + + expect(await this.token.delegates(this.holder)).to.equal(this.delegatee); + + expect(await this.token.getVotes(this.holder)).to.equal(0n); + expect(await this.token.getVotes(this.delegatee)).to.equal(supply); + expect(await this.token.getPastVotes(this.holder, timepoint - 1n)).to.equal(supply); + expect(await this.token.getPastVotes(this.delegatee, timepoint - 1n)).to.equal(0n); + await mine(); + expect(await this.token.getPastVotes(this.holder, timepoint)).to.equal(0n); + expect(await this.token.getPastVotes(this.delegatee, timepoint)).to.equal(supply); + }); + }); + + describe('change partial delegation', function () { + beforeEach(async function () { + await this.token.$_mint(this.holder, supply); + await this.token.connect(this.holder).multiDelegate([this.delegatee], [supply / 2n]); + }); + + describe('call', async function () { + it('increment units', async function () { + const tx = await this.token.connect(this.holder).multiDelegate([this.delegatee], [supply - supply / 4n]); + + await expect(tx) + .to.emit(this.token, 'DelegateModified') + .withArgs(this.holder, this.delegatee, supply / 2n, supply - supply / 4n) + .to.emit(this.token, 'DelegateVotesChanged') + .withArgs(this.delegatee, supply / 2n, supply - supply / 4n); + + let multiDelegates = await this.token.multiDelegates(this.holder, 0, 100); + expect([...multiDelegates]).to.have.members([this.delegatee.address]); + expect(await this.token.getDelegatedUnits(this.holder, this.delegatee)).to.equal(supply - supply / 4n); + expect(await this.token.getVotes(this.delegatee)).to.equal(supply - supply / 4n); + + this.delegateeVotes = supply - supply / 4n; + }); + + it('decrement units', async function () { + const tx = await this.token.connect(this.holder).multiDelegate([this.delegatee], [supply / 4n]); + + await expect(tx) + .to.emit(this.token, 'DelegateModified') + .withArgs(this.holder, this.delegatee, supply / 2n, supply / 4n) + .to.emit(this.token, 'DelegateVotesChanged') + .withArgs(this.delegatee, supply / 2n, supply / 4n); + + let multiDelegates = await this.token.multiDelegates(this.holder, 0, 100); + expect([...multiDelegates]).to.have.members([this.delegatee.address]); + expect(await this.token.getDelegatedUnits(this.holder, this.delegatee)).to.equal(supply / 4n); + expect(await this.token.getVotes(this.delegatee)).to.equal(supply / 4n); + + this.delegateeVotes = supply / 4n; + }); + + it('remove', async function () { + const tx = await this.token.connect(this.holder).multiDelegate([this.delegatee], [0]); + + await expect(tx) + .to.emit(this.token, 'DelegateModified') + .withArgs(this.holder, this.delegatee, supply / 2n, 0) + .to.emit(this.token, 'DelegateVotesChanged') + .withArgs(this.delegatee, supply / 2n, 0); + + let multiDelegates = await this.token.multiDelegates(this.holder, 0, 100); + expect([...multiDelegates]).to.have.members([]); + expect(await this.token.getDelegatedUnits(this.holder, this.delegatee)).to.equal(0); + expect(await this.token.getVotes(this.delegatee)).to.equal(0); + + this.delegateeVotes = 0; + }); + + afterEach(async function () { + expect(await this.token.getVotes(this.delegatee)).to.equal(this.delegateeVotes); + + // need to advance 2 blocks to see the effect of a transfer on "getPastVotes" + const timepoint = await time.clock[mode](); + await mine(); + expect(await this.token.getPastVotes(this.delegatee, timepoint)).to.equal(this.delegateeVotes); + }); + }); + }); + + describe('transfers', function () { + beforeEach(async function () { + await this.token.$_mint(this.holder, supply); + }); + + it('no delegation', async function () { + await expect(this.token.connect(this.holder).transfer(this.recipient, 1n)) + .to.emit(this.token, 'Transfer') + .withArgs(this.holder, this.recipient, 1n) + .to.not.emit(this.token, 'DelegateVotesChanged'); + + this.holderVotes = 0n; + this.recipientVotes = 0n; + }); + + it('0 with sender/receiver defaulted', async function () { + await this.token.connect(this.holder).delegate(this.holder); + await this.token.connect(this.recipient).delegate(this.recipient); + + await expect(this.token.connect(this.holder).transfer(this.recipient, 0n)) + .to.emit(this.token, 'Transfer') + .withArgs(this.holder, this.recipient, 0n) + .to.not.emit(this.token, 'DelegateVotesChanged'); + + this.holderVotes = supply; + this.recipientVotes = 0n; + }); + + it('0 with sender/receiver partial', async function () { + await this.token.connect(this.holder).transfer(this.recipient, 100n); + await this.token.connect(this.recipient).multiDelegate([this.recipient], [50]); + await this.token.connect(this.holder).multiDelegate([this.holder], [supply / 2n]); + + await expect(this.token.connect(this.holder).transfer(this.recipient, 0n)) + .to.emit(this.token, 'Transfer') + .withArgs(this.holder, this.recipient, 0n) + .to.not.emit(this.token, 'DelegateVotesChanged'); + + this.holderVotes = supply / 2n; + this.recipientVotes = 50n; + }); + + it('sender defaulted delegation', async function () { + await this.token.connect(this.holder).delegate(this.holder); + + const tx = await this.token.connect(this.holder).transfer(this.recipient, 1n); + await expect(tx) + .to.emit(this.token, 'Transfer') + .withArgs(this.holder, this.recipient, 1n) + .to.emit(this.token, 'DelegateVotesChanged') + .withArgs(this.holder, supply, supply - 1n); + + const { logs } = await tx.wait(); + const { index } = logs.find(event => event.fragment.name == 'Transfer'); + for (const event of logs.filter(event => event.fragment.name == 'DelegateVotesChanged')) { + expect(event.index).to.lt(index); + } + + this.holderVotes = supply - 1n; + this.recipientVotes = 0n; + }); + + it('sender partial full delegation', async function () { + await this.token.connect(this.holder).multiDelegate([this.holder], [supply]); + + const tx = this.token.connect(this.holder).transfer(this.recipient, 1n); + await expect(tx).to.revertedWithCustomError(this.token, 'MultiVotesExceededAvailableUnits').withArgs(1, 0); + + this.holderVotes = supply; + this.recipientVotes = 0n; + }); + + it('sender partial delegation', async function () { + await this.token.connect(this.holder).multiDelegate([this.holder], [supply / 2n]); + + const tx = this.token.connect(this.holder).transfer(this.recipient, 1n); + await expect(tx) + .to.emit(this.token, 'Transfer') + .withArgs(this.holder, this.recipient, 1n) + .to.not.emit(this.token, 'DelegateVotesChanged'); + + this.holderVotes = supply / 2n; + this.recipientVotes = 0n; + }); + + it('receiver defaulted delegation', async function () { + await this.token.connect(this.recipient).delegate(this.recipient); + + const tx = await this.token.connect(this.holder).transfer(this.recipient, 1n); + await expect(tx) + .to.emit(this.token, 'Transfer') + .withArgs(this.holder, this.recipient, 1n) + .to.emit(this.token, 'DelegateVotesChanged') + .withArgs(this.recipient, 0n, 1n); + + const { logs } = await tx.wait(); + const { index } = logs.find(event => event.fragment.name == 'Transfer'); + for (const event of logs.filter(event => event.fragment.name == 'DelegateVotesChanged')) { + expect(event.index).to.lt(index); + } + + this.holderVotes = 0n; + this.recipientVotes = 1n; + }); + + it('receiver partial full delegation', async function () { + await this.token.connect(this.holder).transfer(this.recipient, 100n); + await this.token.connect(this.recipient).multiDelegate([this.recipient], [100]); + + const tx = await this.token.connect(this.holder).transfer(this.recipient, 1n); + await expect(tx) + .to.emit(this.token, 'Transfer') + .withArgs(this.holder, this.recipient, 1n) + .to.not.emit(this.token, 'DelegateVotesChanged'); + + const tx1 = this.token.connect(this.recipient).multiDelegate([this.other], [2]); + await expect(tx1).to.revertedWithCustomError(this.token, 'MultiVotesExceededAvailableUnits').withArgs(2, 1); + + const tx2 = await this.token.connect(this.recipient).multiDelegate([this.other], [1]); + await expect(tx2) + .to.emit(this.token, 'DelegateModified') + .withArgs(this.recipient, this.other, 0, 1) + .to.emit(this.token, 'DelegateVotesChanged') + .withArgs(this.other, 0, 1); + + this.holderVotes = 0n; + this.recipientVotes = 100n; + }); + + it('receiver partial delegation', async function () { + await this.token.connect(this.holder).transfer(this.recipient, 100n); + await this.token.connect(this.recipient).multiDelegate([this.recipient], [50]); + + const tx = await this.token.connect(this.holder).transfer(this.recipient, 1n); + await expect(tx) + .to.emit(this.token, 'Transfer') + .withArgs(this.holder, this.recipient, 1n) + .to.not.emit(this.token, 'DelegateVotesChanged'); + + const tx1 = await this.token.connect(this.recipient).multiDelegate([this.other], [51]); + await expect(tx1) + .to.emit(this.token, 'DelegateModified') + .withArgs(this.recipient, this.other, 0, 51) + .to.emit(this.token, 'DelegateVotesChanged') + .withArgs(this.other, 0, 51); + + this.holderVotes = 0n; + this.recipientVotes = 50n; + }); + + it('sender defaulted and partial full delegation', async function () { + await this.token.connect(this.holder).multiDelegate([this.holder], [supply]); + await this.token.connect(this.holder).delegate(this.holder); + + const tx = this.token.connect(this.holder).transfer(this.recipient, 1n); + await expect(tx).to.revertedWithCustomError(this.token, 'MultiVotesExceededAvailableUnits').withArgs(1, 0); + + this.holderVotes = supply; + this.recipientVotes = 0; + }); + + it('sender defaulted and partial delegation', async function () { + await this.token.connect(this.holder).delegate(this.holder); + await this.token.connect(this.holder).multiDelegate([this.holder], [supply / 2n]); + + const tx = await this.token.connect(this.holder).transfer(this.recipient, 1n); + await expect(tx) + .to.emit(this.token, 'Transfer') + .withArgs(this.holder, this.recipient, 1n) + .to.emit(this.token, 'DelegateVotesChanged') + .withArgs(this.holder, supply, supply - 1n); + + this.holderVotes = supply - 1n; + this.recipientVotes = 0; + }); + + it('receiver defaulted and partial full delegation', async function () { + await this.token.connect(this.holder).transfer(this.recipient, 100n); + await this.token.connect(this.recipient).multiDelegate([this.recipient], [100]); + await this.token.connect(this.recipient).delegate(this.recipient); + + const tx = await this.token.connect(this.holder).transfer(this.recipient, 1n); + await expect(tx) + .to.emit(this.token, 'Transfer') + .withArgs(this.holder, this.recipient, 1n) + .to.emit(this.token, 'DelegateVotesChanged') + .withArgs(this.recipient, 100, 101); + + const tx1 = this.token.connect(this.recipient).transfer(this.other, 2n); + await expect(tx1).to.revertedWithCustomError(this.token, 'MultiVotesExceededAvailableUnits').withArgs(2, 1); + + const tx2 = await this.token.connect(this.recipient).transfer(this.other, 1n); + await expect(tx2) + .to.emit(this.token, 'Transfer') + .withArgs(this.recipient, this.other, 1n) + .to.emit(this.token, 'DelegateVotesChanged') + .withArgs(this.recipient, 101, 100); + + this.holderVotes = 0; + this.recipientVotes = 100; + }); + + it('receiver defaulted and partial delegation', async function () { + await this.token.connect(this.holder).transfer(this.recipient, 100n); + await this.token.connect(this.recipient).multiDelegate([this.recipient], [50]); + await this.token.connect(this.recipient).delegate(this.recipient); + + const tx = await this.token.connect(this.holder).transfer(this.recipient, 1n); + await expect(tx) + .to.emit(this.token, 'Transfer') + .withArgs(this.holder, this.recipient, 1n) + .to.emit(this.token, 'DelegateVotesChanged') + .withArgs(this.recipient, 100, 101); + + const tx1 = await this.token.connect(this.recipient).transfer(this.other, 51n); + await expect(tx1) + .to.emit(this.token, 'Transfer') + .withArgs(this.recipient, this.other, 51n) + .to.emit(this.token, 'DelegateVotesChanged') + .withArgs(this.recipient, 101, 50); + + this.holderVotes = 0; + this.recipientVotes = 50; + }); + + it('sender and receiver defaulted delegation', async function () { + await this.token.connect(this.holder).delegate(this.holder); + await this.token.connect(this.recipient).delegate(this.recipient); + + const tx = await this.token.connect(this.holder).transfer(this.recipient, 1n); + await expect(tx) + .to.emit(this.token, 'Transfer') + .withArgs(this.holder, this.recipient, 1n) + .to.emit(this.token, 'DelegateVotesChanged') + .withArgs(this.holder, supply, supply - 1n) + .to.emit(this.token, 'DelegateVotesChanged') + .withArgs(this.recipient, 0n, 1n); + + const { logs } = await tx.wait(); + const { index } = logs.find(event => event.fragment.name == 'Transfer'); + for (const event of logs.filter(event => event.fragment.name == 'DelegateVotesChanged')) { + expect(event.index).to.lt(index); + } + + this.holderVotes = supply - 1n; + this.recipientVotes = 1n; + }); + + it('sender and receiver defaulted + partial delegation', async function () { + await this.token.connect(this.holder).transfer(this.recipient, 100n); + await this.token.connect(this.recipient).multiDelegate([this.recipient], [50]); + await this.token.connect(this.recipient).delegate(this.recipient); + + await this.token.connect(this.holder).multiDelegate([this.holder], [supply / 2n]); + await this.token.connect(this.holder).delegate(this.holder); + + const tx = await this.token.connect(this.holder).transfer(this.recipient, 1n); + await expect(tx) + .to.emit(this.token, 'Transfer') + .withArgs(this.holder, this.recipient, 1n) + .to.emit(this.token, 'DelegateVotesChanged') + .withArgs(this.holder, supply - 100n, supply - 101n) + .to.emit(this.token, 'DelegateVotesChanged') + .withArgs(this.recipient, 100, 101); + + this.holderVotes = supply - 101n; + this.recipientVotes = 101; + }); + + afterEach(async function () { + expect(await this.token.getVotes(this.holder)).to.equal(this.holderVotes); + expect(await this.token.getVotes(this.recipient)).to.equal(this.recipientVotes); + + // need to advance 2 blocks to see the effect of a transfer on "getPastVotes" + const timepoint = await time.clock[mode](); + await mine(); + expect(await this.token.getPastVotes(this.holder, timepoint)).to.equal(this.holderVotes); + expect(await this.token.getPastVotes(this.recipient, timepoint)).to.equal(this.recipientVotes); + }); + }); + + // The following tests are a adaptation of https://github.com/compound-finance/compound-protocol/blob/master/tests/Governance/CompTest.js. + describe('Compound test suite', function () { + beforeEach(async function () { + await this.token.$_mint(this.holder, supply); + }); + + describe('balanceOf', function () { + it('grants to initial account', async function () { + expect(await this.token.balanceOf(this.holder)).to.equal(supply); + }); + }); + + describe('numCheckpoints', function () { + it('returns the number of checkpoints for a delegate', async function () { + await this.token.connect(this.holder).transfer(this.recipient, 100n); //give an account a few tokens for readability + expect(await this.token.numCheckpoints(this.other1)).to.equal(0n); + + const t1 = await this.token.connect(this.recipient).delegate(this.other1); + t1.timepoint = await time.clockFromReceipt[mode](t1); + expect(await this.token.numCheckpoints(this.other1)).to.equal(1n); + + const t2 = await this.token.connect(this.recipient).transfer(this.other2, 10); + t2.timepoint = await time.clockFromReceipt[mode](t2); + expect(await this.token.numCheckpoints(this.other1)).to.equal(2n); + + const t3 = await this.token.connect(this.recipient).transfer(this.other2, 10); + t3.timepoint = await time.clockFromReceipt[mode](t3); + expect(await this.token.numCheckpoints(this.other1)).to.equal(3n); + + const t4 = await this.token.connect(this.holder).transfer(this.recipient, 20); + t4.timepoint = await time.clockFromReceipt[mode](t4); + expect(await this.token.numCheckpoints(this.other1)).to.equal(4n); + + expect(await this.token.checkpoints(this.other1, 0n)).to.deep.equal([t1.timepoint, 100n]); + expect(await this.token.checkpoints(this.other1, 1n)).to.deep.equal([t2.timepoint, 90n]); + expect(await this.token.checkpoints(this.other1, 2n)).to.deep.equal([t3.timepoint, 80n]); + expect(await this.token.checkpoints(this.other1, 3n)).to.deep.equal([t4.timepoint, 100n]); + await mine(); + expect(await this.token.getPastVotes(this.other1, t1.timepoint)).to.equal(100n); + expect(await this.token.getPastVotes(this.other1, t2.timepoint)).to.equal(90n); + expect(await this.token.getPastVotes(this.other1, t3.timepoint)).to.equal(80n); + expect(await this.token.getPastVotes(this.other1, t4.timepoint)).to.equal(100n); + }); + + it('does not add more than one checkpoint in a block', async function () { + await this.token.connect(this.holder).transfer(this.recipient, 100n); + expect(await this.token.numCheckpoints(this.other1)).to.equal(0n); + + const [t1, t2, t3] = await batchInBlock([ + () => this.token.connect(this.recipient).delegate(this.other1, { gasLimit: 200000 }), + () => this.token.connect(this.recipient).transfer(this.other2, 10n, { gasLimit: 200000 }), + () => this.token.connect(this.recipient).transfer(this.other2, 10n, { gasLimit: 200000 }), + ]); + t1.timepoint = await time.clockFromReceipt[mode](t1); + t2.timepoint = await time.clockFromReceipt[mode](t2); + t3.timepoint = await time.clockFromReceipt[mode](t3); + + expect(await this.token.numCheckpoints(this.other1)).to.equal(1); + expect(await this.token.checkpoints(this.other1, 0n)).to.be.deep.equal([t1.timepoint, 80n]); + + const t4 = await this.token.connect(this.holder).transfer(this.recipient, 20n); + t4.timepoint = await time.clockFromReceipt[mode](t4); + + expect(await this.token.numCheckpoints(this.other1)).to.equal(2n); + expect(await this.token.checkpoints(this.other1, 1n)).to.be.deep.equal([t4.timepoint, 100n]); + }); + }); + + describe('getPastVotes', function () { + it('reverts if block number >= current block', async function () { + const clock = await this.token.clock(); + await expect(this.token.getPastVotes(this.other1, 50_000_000_000n)) + .to.be.revertedWithCustomError(this.token, 'ERC5805FutureLookup') + .withArgs(50_000_000_000n, clock); + }); + + it('returns 0 if there are no checkpoints', async function () { + expect(await this.token.getPastVotes(this.other1, 0n)).to.equal(0n); + }); + + it('returns the latest block if >= last checkpoint block', async function () { + const tx = await this.token.connect(this.holder).delegate(this.other1); + const timepoint = await time.clockFromReceipt[mode](tx); + await mine(2); + + expect(await this.token.getPastVotes(this.other1, timepoint)).to.equal(supply); + expect(await this.token.getPastVotes(this.other1, timepoint + 1n)).to.equal(supply); + }); + + it('returns zero if < first checkpoint block', async function () { + await mine(); + const tx = await this.token.connect(this.holder).delegate(this.other1); + const timepoint = await time.clockFromReceipt[mode](tx); + await mine(2); + + expect(await this.token.getPastVotes(this.other1, timepoint - 1n)).to.equal(0n); + expect(await this.token.getPastVotes(this.other1, timepoint + 1n)).to.equal(supply); + }); + + it('generally returns the voting balance at the appropriate checkpoint', async function () { + const t1 = await this.token.connect(this.holder).delegate(this.other1); + await mine(2); + const t2 = await this.token.connect(this.holder).transfer(this.other2, 10); + await mine(2); + const t3 = await this.token.connect(this.holder).transfer(this.other2, 10); + await mine(2); + const t4 = await this.token.connect(this.other2).transfer(this.holder, 20); + await mine(2); + + t1.timepoint = await time.clockFromReceipt[mode](t1); + t2.timepoint = await time.clockFromReceipt[mode](t2); + t3.timepoint = await time.clockFromReceipt[mode](t3); + t4.timepoint = await time.clockFromReceipt[mode](t4); + + expect(await this.token.getPastVotes(this.other1, t1.timepoint - 1n)).to.equal(0n); + expect(await this.token.getPastVotes(this.other1, t1.timepoint)).to.equal(supply); + expect(await this.token.getPastVotes(this.other1, t1.timepoint + 1n)).to.equal(supply); + expect(await this.token.getPastVotes(this.other1, t2.timepoint)).to.equal(supply - 10n); + expect(await this.token.getPastVotes(this.other1, t2.timepoint + 1n)).to.equal(supply - 10n); + expect(await this.token.getPastVotes(this.other1, t3.timepoint)).to.equal(supply - 20n); + expect(await this.token.getPastVotes(this.other1, t3.timepoint + 1n)).to.equal(supply - 20n); + expect(await this.token.getPastVotes(this.other1, t4.timepoint)).to.equal(supply); + expect(await this.token.getPastVotes(this.other1, t4.timepoint + 1n)).to.equal(supply); + }); + }); + }); + + describe('getPastTotalSupply', function () { + beforeEach(async function () { + await this.token.connect(this.holder).delegate(this.holder); + }); + + it('reverts if block number >= current block', async function () { + const clock = await this.token.clock(); + await expect(this.token.getPastTotalSupply(50_000_000_000n)) + .to.be.revertedWithCustomError(this.token, 'ERC5805FutureLookup') + .withArgs(50_000_000_000n, clock); + }); + + it('returns 0 if there are no checkpoints', async function () { + expect(await this.token.getPastTotalSupply(0n)).to.equal(0n); + }); + + it('returns the latest block if >= last checkpoint block', async function () { + const tx = await this.token.$_mint(this.holder, supply); + const timepoint = await time.clockFromReceipt[mode](tx); + await mine(2); + + expect(await this.token.getPastTotalSupply(timepoint)).to.equal(supply); + expect(await this.token.getPastTotalSupply(timepoint + 1n)).to.equal(supply); + }); + + it('returns zero if < first checkpoint block', async function () { + await mine(); + const tx = await this.token.$_mint(this.holder, supply); + const timepoint = await time.clockFromReceipt[mode](tx); + await mine(2); + + expect(await this.token.getPastTotalSupply(timepoint - 1n)).to.equal(0n); + expect(await this.token.getPastTotalSupply(timepoint + 1n)).to.equal(supply); + }); + + it('generally returns the voting balance at the appropriate checkpoint', async function () { + const t1 = await this.token.$_mint(this.holder, supply); + await mine(2); + const t2 = await this.token.$_burn(this.holder, 10n); + await mine(2); + const t3 = await this.token.$_burn(this.holder, 10n); + await mine(2); + const t4 = await this.token.$_mint(this.holder, 20n); + await mine(2); + + t1.timepoint = await time.clockFromReceipt[mode](t1); + t2.timepoint = await time.clockFromReceipt[mode](t2); + t3.timepoint = await time.clockFromReceipt[mode](t3); + t4.timepoint = await time.clockFromReceipt[mode](t4); + + expect(await this.token.getPastTotalSupply(t1.timepoint - 1n)).to.equal(0n); + expect(await this.token.getPastTotalSupply(t1.timepoint)).to.equal(supply); + expect(await this.token.getPastTotalSupply(t1.timepoint + 1n)).to.equal(supply); + expect(await this.token.getPastTotalSupply(t2.timepoint)).to.equal(supply - 10n); + expect(await this.token.getPastTotalSupply(t2.timepoint + 1n)).to.equal(supply - 10n); + expect(await this.token.getPastTotalSupply(t3.timepoint)).to.equal(supply - 20n); + expect(await this.token.getPastTotalSupply(t3.timepoint + 1n)).to.equal(supply - 20n); + expect(await this.token.getPastTotalSupply(t4.timepoint)).to.equal(supply); + expect(await this.token.getPastTotalSupply(t4.timepoint + 1n)).to.equal(supply); + }); + }); + }); + } +}); From 334e13f1b84887088a5971924932dac77d6130d3 Mon Sep 17 00:00:00 2001 From: Sp3rick <62477178+Sp3rick@users.noreply.github.com> Date: Sun, 24 Aug 2025 12:48:00 +0000 Subject: [PATCH 2/2] Update contracts/governance/utils/IMultiVotes.sol Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com> --- contracts/governance/utils/IMultiVotes.sol | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/contracts/governance/utils/IMultiVotes.sol b/contracts/governance/utils/IMultiVotes.sol index 687923581aa..5a7f0702d9c 100644 --- a/contracts/governance/utils/IMultiVotes.sol +++ b/contracts/governance/utils/IMultiVotes.sol @@ -44,13 +44,13 @@ interface IMultiVotes is IVotes { /** * @dev Set delegates list with units assigned for each one */ - function multiDelegate(address[] calldata delegatess, uint256[] calldata units) external; + function multiDelegate(address[] calldata delegatees, uint256[] calldata units) external; /** - * @dev Multi delegate votes from signer to `delegatess`. + * @dev Multi delegate votes from signer to `delegatees`. */ function multiDelegateBySig( - address[] calldata delegatess, + address[] calldata delegatees, uint256[] calldata units, uint256 nonce, uint256 expiry,