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..45bc1fe94a4 --- /dev/null +++ b/contracts/governance/utils/IMultiVotes.sol @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Contracts (last updated v5.4.0) (governance/utils/VotesExtended.sol) +pragma solidity ^0.8.20; + +import {IVotes} from "./IVotes.sol"; + +/** + * @dev Common interface for {ERC20MultiVotes} and other {MultiVotes}-enabled contracts. + */ +interface IMultiVotes is IVotes { + + /** + * @dev Delegation units exceeded, introducing a risk of votes overflowing. + */ + error MultiVotesExceededAvailableUnits(uint256 units, uint256 left); + + /** + * @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. + * + * NOTE: Without a limit on partial delegations applyed, this call may consume too much gas and fail. + * Furthermore consider received list order pseudo-random + */ + function multiDelegates(address account) 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..0699a42adb5 --- /dev/null +++ b/contracts/governance/utils/MultiVotes.sol @@ -0,0 +1,275 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Contracts (last updated v5.4.0) (governance/utils/VotesExtended.sol) +pragma solidity ^0.8.20; + +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.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 carefull of + * multiDelegates getter is considered possibily failing for out of gas if too many partial delegates are assigned + * If you implement a limit for maximum delegates for each delegator, multiDelegates can be considered always working. + */ +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 override virtual { + 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 override virtual { + if(from != address(0)) { + uint256 freeUnits = getFreeUnits(from); + require(amount <= freeUnits, MultiVotesExceededAvailableUnits(amount, freeUnits)); + } + super._transferVotingUnits(from, to, amount); + } + + /** + * @dev Returns `account` partial delegations. + * + * NOTE: Without a limit on partial delegations applyed, this call may consume too much gas and fail. + * Furthermore consider received list order pseudo-random + */ + function multiDelegates(address account) public view virtual returns (address[] memory) { + return _delegatesList[account]; + } + + /** + * @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 exhisting. + * + * 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 everytime a value is added to _delegatesList + * _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..b10386dabd8 100644 --- a/contracts/governance/utils/Votes.sol +++ b/contracts/governance/utils/Votes.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 avaiable `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..d108e2b4c3c --- /dev/null +++ b/contracts/mocks/MultiVotesMock.sol @@ -0,0 +1,45 @@ +// 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/VotesMock.sol b/contracts/mocks/VotesMock.sol index afef5ab7cf0..8e93b5cc280 100644 --- a/contracts/mocks/VotesMock.sol +++ b/contracts/mocks/VotesMock.sol @@ -25,8 +25,8 @@ abstract contract VotesMock is Votes { } function _burn(address account, uint256 votes) internal { - _votingUnits[account] += votes; _transferVotingUnits(account, address(0), votes); + _votingUnits[account] -= votes; } } diff --git a/test/governance/utils/MultiVotes.behaivor.js b/test/governance/utils/MultiVotes.behaivor.js new file mode 100644 index 00000000000..eb51d286b9c --- /dev/null +++ b/test/governance/utils/MultiVotes.behaivor.js @@ -0,0 +1,462 @@ +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'); + +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 }); +} + +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 exhisting 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 avaiable 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); + 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); + 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); + 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); + 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); + 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; + + 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); + 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); + 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); + 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('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 avaiable 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..b8a0112a739 --- /dev/null +++ b/test/governance/utils/MultiVotes.test.js @@ -0,0 +1,82 @@ +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.behaivor'); + +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); + }); + }); + + }); + + } +}); diff --git a/test/governance/utils/Votes.behavior.js b/test/governance/utils/Votes.behavior.js index 099770132d5..34a7d935679 100644 --- a/test/governance/utils/Votes.behavior.js +++ b/test/governance/utils/Votes.behavior.js @@ -275,6 +275,21 @@ function shouldBehaveLikeVotes(tokens, { mode = 'blocknumber', fungible = true } }); }); + describe('burning', async function () { + beforeEach(async function () { + await this.votes.$_mint(this.delegator, 100); + }); + + it('burns', async function () { + await this.votes.$_burn(this.delegator, 50); + expect(await this.votes.$_getVotingUnits(this.delegator)).to.equal(50); + }) + + it('rejects more than avaiable burn', async function () { + await expect(this.votes.$_burn(this.delegator, 101)).to.be.reverted; + }) + }) + // The following tests are an adaptation of // https://github.com/compound-finance/compound-protocol/blob/master/tests/Governance/CompTest.js. describe('Compound test suite', function () { diff --git a/test/helpers/eip712-types.js b/test/helpers/eip712-types.js index fb6fe3aebaf..44f39c1674b 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',