From 225d30c762b36bce38e14a5f9f7f965f8114b712 Mon Sep 17 00:00:00 2001 From: YamenMerhi Date: Mon, 14 Oct 2024 11:23:54 +0300 Subject: [PATCH 1/3] feat: Add LSP7Votes and LSP8Votes extensions --- .../contracts/LSP7DigitalAssetCore.sol | 93 +++-- .../contracts/extensions/LSP7Votes.sol | 372 ++++++++++++++++++ .../contracts/extensions/LSP8Votes.sol | 42 ++ 3 files changed, 464 insertions(+), 43 deletions(-) create mode 100644 packages/lsp7-contracts/contracts/extensions/LSP7Votes.sol create mode 100644 packages/lsp8-contracts/contracts/extensions/LSP8Votes.sol diff --git a/packages/lsp7-contracts/contracts/LSP7DigitalAssetCore.sol b/packages/lsp7-contracts/contracts/LSP7DigitalAssetCore.sol index ec4e16065..63901c216 100644 --- a/packages/lsp7-contracts/contracts/LSP7DigitalAssetCore.sol +++ b/packages/lsp7-contracts/contracts/LSP7DigitalAssetCore.sol @@ -441,19 +441,7 @@ abstract contract LSP7DigitalAssetCore is ILSP7DigitalAsset { _beforeTokenTransfer(address(0), to, amount, data); - // tokens being minted - _existingTokens += amount; - - _tokenOwnerBalances[to] += amount; - - emit Transfer({ - operator: msg.sender, - from: address(0), - to: to, - amount: amount, - force: force, - data: data - }); + _update(address(0), to, amount, force, data); _afterTokenTransfer(address(0), to, amount, data); @@ -503,23 +491,7 @@ abstract contract LSP7DigitalAssetCore is ILSP7DigitalAsset { _beforeTokenTransfer(from, address(0), amount, data); - uint256 balance = _tokenOwnerBalances[from]; - if (amount > balance) { - revert LSP7AmountExceedsBalance(balance, from, amount); - } - // tokens being burnt - _existingTokens -= amount; - - _tokenOwnerBalances[from] -= amount; - - emit Transfer({ - operator: msg.sender, - from: from, - to: address(0), - amount: amount, - force: false, - data: data - }); + _update(from, address(0), amount, false, data); _afterTokenTransfer(from, address(0), amount, data); @@ -614,29 +586,64 @@ abstract contract LSP7DigitalAssetCore is ILSP7DigitalAsset { _beforeTokenTransfer(from, to, amount, data); - uint256 balance = _tokenOwnerBalances[from]; - if (amount > balance) { - revert LSP7AmountExceedsBalance(balance, from, amount); + _update(from, to, amount, force, data); + + _afterTokenTransfer(from, to, amount, data); + + bytes memory lsp1Data = abi.encode(msg.sender, from, to, amount, data); + + _notifyTokenSender(from, lsp1Data); + _notifyTokenReceiver(to, force, lsp1Data); + } + + /** + * @dev Transfers a `value` amount of tokens from `from` to `to`, or alternatively mints (or burns) if `from` + * (or `to`) is the zero address. All customizations to transfers, mints, and burns should be done by overriding + * this function. + * + * Emits a {Transfer} event. + */ + function _update( + address from, + address to, + uint256 value, + bool force, + bytes memory data + ) internal virtual { + if (from == address(0)) { + // Overflow check required: The rest of the code assumes that totalSupply never overflows + _existingTokens += value; + } else { + uint256 fromBalance = _tokenOwnerBalances[from]; + if (fromBalance < value) { + revert LSP7AmountExceedsBalance(fromBalance, from, value); + } + unchecked { + // Overflow not possible: value <= fromBalance <= totalSupply. + _tokenOwnerBalances[from] = fromBalance - value; + } } - _tokenOwnerBalances[from] -= amount; - _tokenOwnerBalances[to] += amount; + if (to == address(0)) { + unchecked { + // Overflow not possible: value <= totalSupply or value <= fromBalance <= totalSupply. + _existingTokens -= value; + } + } else { + unchecked { + // Overflow not possible: balance + value is at most totalSupply, which we know fits into a uint256. + _tokenOwnerBalances[to] += value; + } + } emit Transfer({ operator: msg.sender, from: from, to: to, - amount: amount, + amount: value, force: force, data: data }); - - _afterTokenTransfer(from, to, amount, data); - - bytes memory lsp1Data = abi.encode(msg.sender, from, to, amount, data); - - _notifyTokenSender(from, lsp1Data); - _notifyTokenReceiver(to, force, lsp1Data); } /** diff --git a/packages/lsp7-contracts/contracts/extensions/LSP7Votes.sol b/packages/lsp7-contracts/contracts/extensions/LSP7Votes.sol new file mode 100644 index 000000000..29310bdd6 --- /dev/null +++ b/packages/lsp7-contracts/contracts/extensions/LSP7Votes.sol @@ -0,0 +1,372 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.0; + +import "../LSP7DigitalAsset.sol"; +import "@openzeppelin/contracts/interfaces/IERC5805.sol"; +import "@openzeppelin/contracts/utils/math/Math.sol"; +import "@openzeppelin/contracts/utils/math/SafeCast.sol"; +import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; +import "@openzeppelin/contracts/utils/cryptography/EIP712.sol"; +import "@openzeppelin/contracts/utils/Counters.sol"; + +/** + * @dev Extension of LSP7 to support Compound-like voting and delegation. This version is more generic than Compound's, + * and supports token supply up to 2^224^ - 1, while COMP is limited to 2^96^ - 1. + * + * This extension keeps a history (checkpoints) of each account's vote power. Vote power can be delegated either + * by calling the {delegate} function directly, or by providing a signature to be used with {delegateBySig}. Voting + * power can be queried through the public accessors {getVotes} and {getPastVotes}. + * + * By default, token balance does not account for voting power. This makes transfers cheaper. The downside is that it + * requires users to delegate to themselves in order to activate checkpoints and have their voting power tracked. + */ +abstract contract LSP7Votes is LSP7DigitalAsset, EIP712, IERC5805 { + using Counters for Counters.Counter; + mapping(address => Counters.Counter) private _nonces; + + struct Checkpoint { + uint32 fromBlock; + uint224 votes; + } + + bytes32 private constant _DELEGATION_TYPEHASH = + keccak256("Delegation(address delegatee,uint256 nonce,uint256 expiry)"); + + mapping(address => address) private _delegates; + mapping(address => Checkpoint[]) private _checkpoints; + Checkpoint[] private _totalSupplyCheckpoints; + + /** + * @dev Clock used for flagging checkpoints. Can be overridden to implement timestamp based checkpoints (and voting). + */ + function clock() public view virtual override returns (uint48) { + return SafeCast.toUint48(block.number); + } + + /** + * @dev Description of the clock + */ + // solhint-disable-next-line func-name-mixedcase + function CLOCK_MODE() public view virtual override returns (string memory) { + // Check that the clock was not modified + require(clock() == block.number, "LSP7Votes: broken clock mode"); + return "mode=blocknumber&from=default"; + } + + // solhint-disable-next-line func-name-mixedcase + function DOMAIN_SEPARATOR() external view returns (bytes32) { + return _domainSeparatorV4(); + } + + /** + * @dev Get the `pos`-th checkpoint for `account`. + */ + function checkpoints( + address account, + uint32 pos + ) public view virtual returns (Checkpoint memory) { + return _checkpoints[account][pos]; + } + + /** + * @dev Get number of checkpoints for `account`. + */ + function numCheckpoints( + address account + ) public view virtual returns (uint32) { + return SafeCast.toUint32(_checkpoints[account].length); + } + + /** + * @dev Get the address `account` is currently delegating to. + */ + function delegates( + address account + ) public view virtual override returns (address) { + return _delegates[account]; + } + + /** + * @dev Gets the current votes balance for `account` + */ + function getVotes( + address account + ) public view virtual override returns (uint256) { + uint256 pos = _checkpoints[account].length; + unchecked { + return pos == 0 ? 0 : _checkpoints[account][pos - 1].votes; + } + } + + /** + * @dev Retrieve the number of votes for `account` at the end of `timepoint`. + * + * Requirements: + * + * - `timepoint` must be in the past + */ + function getPastVotes( + address account, + uint256 timepoint + ) public view virtual override returns (uint256) { + require(timepoint < clock(), "LSP7Votes: future lookup"); + return _checkpointsLookup(_checkpoints[account], timepoint); + } + + /** + * @dev Retrieve the `totalSupply` at the end of `timepoint`. Note, this value is the sum of all balances. + * It is NOT the sum of all the delegated votes! + * + * Requirements: + * + * - `timepoint` must be in the past + */ + function getPastTotalSupply( + uint256 timepoint + ) public view virtual override returns (uint256) { + require(timepoint < clock(), "LSP7Votes: future lookup"); + return _checkpointsLookup(_totalSupplyCheckpoints, timepoint); + } + + /** + * @dev Lookup a value in a list of (sorted) checkpoints. + */ + function _checkpointsLookup( + Checkpoint[] storage ckpts, + uint256 timepoint + ) private view returns (uint256) { + // We run a binary search to look for the last (most recent) checkpoint taken before (or at) `timepoint`. + // + // Initially we check if the block is recent to narrow the search range. + // During the loop, the index of the wanted checkpoint remains in the range [low-1, high). + // With each iteration, either `low` or `high` is moved towards the middle of the range to maintain the invariant. + // - If the middle checkpoint is after `timepoint`, we look in [low, mid) + // - If the middle checkpoint is before or equal to `timepoint`, we look in [mid+1, high) + // Once we reach a single value (when low == high), we've found the right checkpoint at the index high-1, if not + // out of bounds (in which case we're looking too far in the past and the result is 0). + // Note that if the latest checkpoint available is exactly for `timepoint`, we end up with an index that is + // past the end of the array, so we technically don't find a checkpoint after `timepoint`, but it works out + // the same. + uint256 length = ckpts.length; + + uint256 low = 0; + uint256 high = length; + + if (length > 5) { + uint256 mid = length - Math.sqrt(length); + if (_unsafeAccess(ckpts, mid).fromBlock > timepoint) { + high = mid; + } else { + low = mid + 1; + } + } + + while (low < high) { + uint256 mid = Math.average(low, high); + if (_unsafeAccess(ckpts, mid).fromBlock > timepoint) { + high = mid; + } else { + low = mid + 1; + } + } + + unchecked { + return high == 0 ? 0 : _unsafeAccess(ckpts, high - 1).votes; + } + } + + /** + * @dev Delegate votes from the sender to `delegatee`. + */ + function delegate(address delegatee) public virtual override { + _delegate(msg.sender, delegatee); + } + + /** + * @dev Delegates votes from signer to `delegatee` + */ + function delegateBySig( + address delegatee, + uint256 nonce, + uint256 expiry, + uint8 v, + bytes32 r, + bytes32 s + ) public virtual override { + require(block.timestamp <= expiry, "LSP7Votes: signature expired"); + address signer = ECDSA.recover( + _hashTypedDataV4( + keccak256( + abi.encode(_DELEGATION_TYPEHASH, delegatee, nonce, expiry) + ) + ), + v, + r, + s + ); + require(nonce == _useNonce(signer), "LSP7Votes: invalid nonce"); + _delegate(signer, delegatee); + } + + /** + * @dev Maximum token supply. Defaults to `type(uint224).max` (2^224^ - 1). + */ + function _maxSupply() internal view virtual returns (uint224) { + return type(uint224).max; + } + + /** + * @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. + */ + function _transferVotingUnits( + address from, + address to, + uint256 amount + ) internal virtual { + if (from == address(0)) { + _writeCheckpoint(_totalSupplyCheckpoints, _add, amount); + } + if (to == address(0)) { + _writeCheckpoint(_totalSupplyCheckpoints, _subtract, amount); + } + _moveVotingPower(delegates(from), delegates(to), amount); + } + + /** + * @dev Move voting power when tokens are transferred. + * + * Emits a {IVotes-DelegateVotesChanged} event. + */ + function _update( + address from, + address to, + uint256 value, + bool force, + bytes memory data + ) internal virtual override { + super._update(from, to, value, force, data); + if (from == address(0)) { + uint256 supply = totalSupply(); + uint256 cap = _maxSupply(); + require( + supply <= cap, + "LSP7Votes: total supply risks overflowing votes" + ); + } + _transferVotingUnits(from, to, value); + } + + /** + * @dev Change delegation for `delegator` to `delegatee`. + * + * Emits events {IVotes-DelegateChanged} and {IVotes-DelegateVotesChanged}. + */ + function _delegate(address delegator, address delegatee) internal virtual { + address currentDelegate = delegates(delegator); + uint256 delegatorBalance = balanceOf(delegator); + _delegates[delegator] = delegatee; + + emit DelegateChanged(delegator, currentDelegate, delegatee); + + _moveVotingPower(currentDelegate, delegatee, delegatorBalance); + } + + function _moveVotingPower( + address src, + address dst, + uint256 amount + ) private { + if (src != dst && amount > 0) { + if (src != address(0)) { + (uint256 oldWeight, uint256 newWeight) = _writeCheckpoint( + _checkpoints[src], + _subtract, + amount + ); + emit DelegateVotesChanged(src, oldWeight, newWeight); + } + + if (dst != address(0)) { + (uint256 oldWeight, uint256 newWeight) = _writeCheckpoint( + _checkpoints[dst], + _add, + amount + ); + emit DelegateVotesChanged(dst, oldWeight, newWeight); + } + } + } + + function _writeCheckpoint( + Checkpoint[] storage ckpts, + function(uint256, uint256) view returns (uint256) op, + uint256 delta + ) private returns (uint256 oldWeight, uint256 newWeight) { + uint256 pos = ckpts.length; + + unchecked { + Checkpoint memory oldCkpt = pos == 0 + ? Checkpoint(0, 0) + : _unsafeAccess(ckpts, pos - 1); + + oldWeight = oldCkpt.votes; + newWeight = op(oldWeight, delta); + + if (pos > 0 && oldCkpt.fromBlock == clock()) { + _unsafeAccess(ckpts, pos - 1).votes = SafeCast.toUint224( + newWeight + ); + } else { + ckpts.push( + Checkpoint({ + fromBlock: SafeCast.toUint32(clock()), + votes: SafeCast.toUint224(newWeight) + }) + ); + } + } + } + + function _add(uint256 a, uint256 b) private pure returns (uint256) { + return a + b; + } + + function _subtract(uint256 a, uint256 b) private pure returns (uint256) { + return a - b; + } + + /** + * @dev Access an element of the array without performing bounds check. The position is assumed to be within bounds. + */ + function _unsafeAccess( + Checkpoint[] storage ckpts, + uint256 pos + ) private pure returns (Checkpoint storage result) { + assembly { + mstore(0, ckpts.slot) + result.slot := add(keccak256(0, 0x20), pos) + } + } + + /** + * @dev Consumes a nonce. + * + * Returns the current value and increments nonce. + */ + function _useNonce( + address owner + ) internal virtual returns (uint256 current) { + Counters.Counter storage nonce = _nonces[owner]; + current = nonce.current(); + nonce.increment(); + } + + /** + * @dev Reads the current nonce + */ + function nonces(address owner) public view virtual returns (uint256) { + return _nonces[owner].current(); + } +} diff --git a/packages/lsp8-contracts/contracts/extensions/LSP8Votes.sol b/packages/lsp8-contracts/contracts/extensions/LSP8Votes.sol new file mode 100644 index 000000000..39caa9395 --- /dev/null +++ b/packages/lsp8-contracts/contracts/extensions/LSP8Votes.sol @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.0; + +import "../LSP8IdentifiableDigitalAsset.sol"; +import "@openzeppelin/contracts/governance/utils/Votes.sol"; + +/** + * @dev Extension of LSP8 to support voting and delegation as implemented by {Votes}, where each individual NFT counts + * as 1 vote unit. + * + * Tokens do not count as votes until they are delegated, because votes must be tracked which incurs an additional cost + * on every transfer. Token holders can either delegate to a trusted representative who will decide how to make use of + * the votes in governance decisions, or they can delegate to themselves to be their own representative. + */ +abstract contract LSP8Votes is LSP8IdentifiableDigitalAsset, Votes { + /** + * @dev Adjusts votes when tokens are transferred. + * + * Emits a {IVotes-DelegateVotesChanged} event. + */ + function _afterTokenTransfer( + address from, + address to, + bytes32 tokenId, + bytes memory data + ) internal virtual override { + _transferVotingUnits(from, to, 1); + super._afterTokenTransfer(from, to, tokenId, data); + } + + /** + * @dev Returns the balance of `account`. + * + * WARNING: Overriding this function will likely result in incorrect vote tracking. + */ + function _getVotingUnits( + address account + ) internal view virtual override returns (uint256) { + return balanceOf(account); + } +} From f6933989fa4afe9f10a10dd3999fde80c0cf9d80 Mon Sep 17 00:00:00 2001 From: YamenMerhi Date: Mon, 14 Oct 2024 11:24:10 +0300 Subject: [PATCH 2/3] test: Add LSP8Votes tests --- .../contracts/Mocks/MyGovernor.sol | 65 +++++++ .../contracts/Mocks/MyVotingNFT.sol | 25 +++ .../lsp8-contracts/tests/LSP8Votes.test.ts | 176 ++++++++++++++++++ 3 files changed, 266 insertions(+) create mode 100644 packages/lsp8-contracts/contracts/Mocks/MyGovernor.sol create mode 100644 packages/lsp8-contracts/contracts/Mocks/MyVotingNFT.sol create mode 100644 packages/lsp8-contracts/tests/LSP8Votes.test.ts diff --git a/packages/lsp8-contracts/contracts/Mocks/MyGovernor.sol b/packages/lsp8-contracts/contracts/Mocks/MyGovernor.sol new file mode 100644 index 000000000..d12aac202 --- /dev/null +++ b/packages/lsp8-contracts/contracts/Mocks/MyGovernor.sol @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import "@openzeppelin/contracts/governance/Governor.sol"; +import "@openzeppelin/contracts/governance/extensions/GovernorSettings.sol"; +import "@openzeppelin/contracts/governance/extensions/GovernorCountingSimple.sol"; +import "@openzeppelin/contracts/governance/extensions/GovernorVotes.sol"; +import "@openzeppelin/contracts/governance/extensions/GovernorVotesQuorumFraction.sol"; + +contract MyGovernor is + Governor, + GovernorSettings, + GovernorCountingSimple, + GovernorVotes, + GovernorVotesQuorumFraction +{ + constructor( + IVotes _token + ) + Governor("MyGovernor") + GovernorSettings(7200 /* 1 day */, 7200 /* 1 day */, 1) + GovernorVotes(_token) + GovernorVotesQuorumFraction(1) + {} + + // The following functions are overrides required by Solidity. + + function votingDelay() + public + view + override(IGovernor, GovernorSettings) + returns (uint256) + { + return super.votingDelay(); + } + + function votingPeriod() + public + view + override(IGovernor, GovernorSettings) + returns (uint256) + { + return super.votingPeriod(); + } + + function quorum( + uint256 blockNumber + ) + public + view + override(IGovernor, GovernorVotesQuorumFraction) + returns (uint256) + { + return super.quorum(blockNumber); + } + + function proposalThreshold() + public + view + override(Governor, GovernorSettings) + returns (uint256) + { + return super.proposalThreshold(); + } +} diff --git a/packages/lsp8-contracts/contracts/Mocks/MyVotingNFT.sol b/packages/lsp8-contracts/contracts/Mocks/MyVotingNFT.sol new file mode 100644 index 000000000..2f2e46012 --- /dev/null +++ b/packages/lsp8-contracts/contracts/Mocks/MyVotingNFT.sol @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.0; + +import "../extensions/LSP8Votes.sol"; + +/** + * @dev Mock of an LSP8Votes token + */ +contract MyVotingNFT is LSP8Votes { + constructor() + LSP8IdentifiableDigitalAsset( + "MyVotingToken", + "MYVTKN", + msg.sender, + 0, + 1 + ) + EIP712("MyVotingToken", "1") + {} + + function mint(address rec, bytes32 id) public { + _mint(rec, id, true, ""); + } +} diff --git a/packages/lsp8-contracts/tests/LSP8Votes.test.ts b/packages/lsp8-contracts/tests/LSP8Votes.test.ts new file mode 100644 index 000000000..a5474fd89 --- /dev/null +++ b/packages/lsp8-contracts/tests/LSP8Votes.test.ts @@ -0,0 +1,176 @@ +import { expect } from 'chai'; +import { ethers } from 'hardhat'; +import { SignerWithAddress } from '@nomiclabs/hardhat-ethers/signers'; +import { MyVotingNFT, MyVotingNFT__factory, MyGovernor, MyGovernor__factory } from '../types'; +import { time, mine } from '@nomicfoundation/hardhat-network-helpers'; + +describe('Comprehensive Governor and NFT Tests', () => { + let nft: MyVotingNFT; + let governor: MyGovernor; + let owner: SignerWithAddress; + let proposer: SignerWithAddress; + let voter1: SignerWithAddress; + let voter2: SignerWithAddress; + let voter3: SignerWithAddress; + let randomEOA: SignerWithAddress; + + const VOTING_DELAY = 7200; // 1 day in blocks + const VOTING_PERIOD = 7200; // 1 day in blocks + const PROPOSAL_THRESHOLD = 1; // 1 NFT + const QUORUM_FRACTION = 1; // 1% + + beforeEach(async () => { + [owner, proposer, voter1, voter2, voter3, randomEOA] = await ethers.getSigners(); + + nft = await new MyVotingNFT__factory(owner).deploy(); + governor = await new MyGovernor__factory(owner).deploy(nft.target); + + // Mint initial NFTs + await nft.mint(proposer.address, ethers.id('1')); + await nft.mint(proposer.address, ethers.id('2')); + await nft.mint(voter1.address, ethers.id('3')); + await nft.mint(voter2.address, ethers.id('4')); + await nft.mint(voter2.address, ethers.id('5')); + await nft.mint(voter3.address, ethers.id('6')); + await nft.mint(voter3.address, ethers.id('7')); + await nft.mint(voter3.address, ethers.id('8')); + }); + + describe('NFT and Governor Setup', () => { + it('should have correct initial settings', async () => { + expect(await governor.votingDelay()).to.equal(VOTING_DELAY); + expect(await governor.votingPeriod()).to.equal(VOTING_PERIOD); + expect(await governor.proposalThreshold()).to.equal(PROPOSAL_THRESHOLD); + expect(await governor.token()).to.equal(nft.target); + }); + + it('should have correct initial NFT distribution', async () => { + expect(await nft.balanceOf(proposer.address)).to.equal(2); + expect(await nft.balanceOf(voter1.address)).to.equal(1); + expect(await nft.balanceOf(voter2.address)).to.equal(2); + expect(await nft.balanceOf(voter3.address)).to.equal(3); + }); + + it('should have zero initial voting power for all accounts', async () => { + expect(await nft.getVotes(proposer.address)).to.equal(0); + expect(await nft.getVotes(voter1.address)).to.equal(0); + expect(await nft.getVotes(voter2.address)).to.equal(0); + expect(await nft.getVotes(voter3.address)).to.equal(0); + }); + }); + + describe('Proposal Creation', () => { + it('should fail to propose when balance is below threshold', async () => { + await expect( + governor.connect(voter1).propose([randomEOA.address], [0], ['0xaabbccdd'], 'Proposal #1'), + ).to.be.revertedWith('Governor: proposer votes below proposal threshold'); + }); + + it('should fail to propose when balance is sufficient but not delegated', async () => { + await expect( + governor.connect(proposer).propose([randomEOA.address], [0], ['0xaabbccdd'], 'Proposal #1'), + ).to.be.revertedWith('Governor: proposer votes below proposal threshold'); + }); + + it('should successfully propose after delegating', async () => { + await nft.connect(proposer).delegate(proposer.address); + + await expect( + governor.connect(proposer).propose([randomEOA.address], [0], ['0xaabbccdd'], 'Proposal #1'), + ).to.emit(governor, 'ProposalCreated'); + }); + }); + + describe('Voting Power and Delegation', () => { + it('should correctly report voting power after delegation', async () => { + await nft.connect(voter1).delegate(voter1.address); + expect(await nft.getVotes(voter1.address)).to.equal(1); + }); + + it('should correctly transfer voting power when transferring NFTs', async () => { + await nft.connect(voter1).delegate(voter1.address); + await nft + .connect(voter1) + .transfer(voter1.address, voter2.address, ethers.id('3'), true, '0x'); + + expect(await nft.getVotes(voter1.address)).to.equal(0); + expect(await nft.balanceOf(voter2.address)).to.equal(3); + }); + + it('should correctly report delegates', async () => { + await nft.connect(voter1).delegate(voter2.address); + expect(await nft.delegates(voter1.address)).to.equal(voter2.address); + }); + }); + + describe('Voting Process and Proposal Lifecycle', () => { + let proposalId; + + beforeEach(async () => { + // Setup for voting tests + await nft.connect(proposer).delegate(proposer.address); + await nft.connect(voter1).delegate(voter1.address); + await nft.connect(voter2).delegate(voter2.address); + await nft.connect(voter3).delegate(voter3.address); + + proposalId = await governor + .connect(proposer) + .propose.staticCall([randomEOA.address], [0], ['0xaabbccdd'], 'Proposal #1'); + + await governor + .connect(proposer) + .propose([randomEOA.address], [0], ['0xaabbccdd'], 'Proposal #1'); + }); + + it('should correctly count votes and update quorum', async () => { + await mine(VOTING_DELAY + 1); + await governor.connect(voter1).castVote(proposalId, 1); // Yes vote + await governor.connect(voter2).castVote(proposalId, 0); // No vote + await governor.connect(voter3).castVote(proposalId, 2); // Abstain + + const proposal = await governor.proposalVotes(proposalId); + expect(proposal.forVotes).to.equal(1); + expect(proposal.againstVotes).to.equal(2); + expect(proposal.abstainVotes).to.equal(3); + }); + + it('should correctly determine if quorum is reached', async () => { + const totalSupply = await nft.totalSupply(); + const quorumRequired = (totalSupply * BigInt(QUORUM_FRACTION)) / BigInt(100); + + await mine(VOTING_DELAY + 1); + await governor.connect(voter3).castVote(proposalId, 1); // This should meet quorum + + expect(await governor.quorum((await time.latestBlock()) - 1)).to.be.gte(quorumRequired); + }); + }); + + describe('Advanced Voting Power Scenarios', () => { + it('should correctly calculate voting power at past timepoints', async () => { + await nft.connect(voter1).delegate(voter1.address); + await mine(); // Mine a block to record the delegation + + const blockNumber1 = await ethers.provider.getBlockNumber(); + expect(await nft.getPastVotes(voter1.address, blockNumber1 - 1)).to.equal(1); + + await nft.mint(voter1.address, ethers.id('9')); + await mine(); // Mine a block to record the mint + + const blockNumber2 = await ethers.provider.getBlockNumber(); + expect(await nft.getPastVotes(voter1.address, blockNumber2 - 1)).to.equal(2); + }); + + it('should correctly report past total supply', async () => { + const initialSupply = await nft.totalSupply(); + const blockNumber1 = await ethers.provider.getBlockNumber(); + + await nft.mint(voter1.address, ethers.id('10')); + await mine(); // Mine a block to record the mint + + const blockNumber2 = await ethers.provider.getBlockNumber(); + + expect(await nft.getPastTotalSupply(blockNumber1)).to.equal(initialSupply); + expect(await nft.getPastTotalSupply(blockNumber2 - 1)).to.equal(initialSupply + BigInt(1)); + }); + }); +}); From 30dcf4a15954b9ae57b7bfd6104c31c8acb10e1c Mon Sep 17 00:00:00 2001 From: YamenMerhi Date: Mon, 14 Oct 2024 11:24:20 +0300 Subject: [PATCH 3/3] test: Add LSP7Votes test --- .../contracts/Mocks/MyGovernor.sol | 65 +++++ .../contracts/Mocks/MyVotingToken.sol | 19 ++ .../lsp7-contracts/tests/LSP7Votes.test.ts | 257 ++++++++++++++++++ 3 files changed, 341 insertions(+) create mode 100644 packages/lsp7-contracts/contracts/Mocks/MyGovernor.sol create mode 100644 packages/lsp7-contracts/contracts/Mocks/MyVotingToken.sol create mode 100644 packages/lsp7-contracts/tests/LSP7Votes.test.ts diff --git a/packages/lsp7-contracts/contracts/Mocks/MyGovernor.sol b/packages/lsp7-contracts/contracts/Mocks/MyGovernor.sol new file mode 100644 index 000000000..04750444b --- /dev/null +++ b/packages/lsp7-contracts/contracts/Mocks/MyGovernor.sol @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import "@openzeppelin/contracts/governance/Governor.sol"; +import "@openzeppelin/contracts/governance/extensions/GovernorSettings.sol"; +import "@openzeppelin/contracts/governance/extensions/GovernorCountingSimple.sol"; +import "@openzeppelin/contracts/governance/extensions/GovernorVotes.sol"; +import "@openzeppelin/contracts/governance/extensions/GovernorVotesQuorumFraction.sol"; + +contract MyGovernor is + Governor, + GovernorSettings, + GovernorCountingSimple, + GovernorVotes, + GovernorVotesQuorumFraction +{ + constructor( + IVotes _token + ) + Governor("MyGovernor") + GovernorSettings(7200 /* 1 day */, 7200 /* 1 day */, 1e18) + GovernorVotes(_token) + GovernorVotesQuorumFraction(1) + {} + + // The following functions are overrides required by Solidity. + + function votingDelay() + public + view + override(IGovernor, GovernorSettings) + returns (uint256) + { + return super.votingDelay(); + } + + function votingPeriod() + public + view + override(IGovernor, GovernorSettings) + returns (uint256) + { + return super.votingPeriod(); + } + + function quorum( + uint256 blockNumber + ) + public + view + override(IGovernor, GovernorVotesQuorumFraction) + returns (uint256) + { + return super.quorum(blockNumber); + } + + function proposalThreshold() + public + view + override(Governor, GovernorSettings) + returns (uint256) + { + return super.proposalThreshold(); + } +} diff --git a/packages/lsp7-contracts/contracts/Mocks/MyVotingToken.sol b/packages/lsp7-contracts/contracts/Mocks/MyVotingToken.sol new file mode 100644 index 000000000..5331b0b55 --- /dev/null +++ b/packages/lsp7-contracts/contracts/Mocks/MyVotingToken.sol @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.0; + +import "../extensions/LSP7Votes.sol"; + +/** + * @dev Mock of an LSP7Votes token + */ +contract MyVotingToken is LSP7Votes { + constructor() + LSP7DigitalAsset("MyVotingToken", "MYVTKN", msg.sender, 0, false) + EIP712("MyVotingToken", "1") + {} + + function mint(address rec, uint256 amount) public { + _mint(rec, amount, true, ""); + } +} diff --git a/packages/lsp7-contracts/tests/LSP7Votes.test.ts b/packages/lsp7-contracts/tests/LSP7Votes.test.ts new file mode 100644 index 000000000..ded8237ac --- /dev/null +++ b/packages/lsp7-contracts/tests/LSP7Votes.test.ts @@ -0,0 +1,257 @@ +import { expect } from 'chai'; +import { ethers } from 'hardhat'; +import { SignerWithAddress } from '@nomiclabs/hardhat-ethers/signers'; +import { MyVotingToken, MyVotingToken__factory, MyGovernor, MyGovernor__factory } from '../types'; +import { time, mine } from '@nomicfoundation/hardhat-network-helpers'; +import { bigint } from 'hardhat/internal/core/params/argumentTypes'; + +describe('Comprehensive Governor and Token Tests', () => { + let token: MyVotingToken; + let governor: MyGovernor; + let owner: SignerWithAddress; + let proposer: SignerWithAddress; + let voter1: SignerWithAddress; + let voter2: SignerWithAddress; + let voter3: SignerWithAddress; + let randomEOA: SignerWithAddress; + + const VOTING_DELAY = 7200; // 1 day in blocks + const VOTING_PERIOD = 7200; // 1 day in blocks + const PROPOSAL_THRESHOLD = ethers.parseEther('1'); // 1 full unit of token + const QUORUM_FRACTION = 1; // 1% + + beforeEach(async () => { + [owner, proposer, voter1, voter2, voter3, randomEOA] = await ethers.getSigners(); + + token = await new MyVotingToken__factory(owner).deploy(); + governor = await new MyGovernor__factory(owner).deploy(token.target); + + // Mint initial tokens + await token.mint(proposer.address, PROPOSAL_THRESHOLD * BigInt(2)); + await token.mint(voter1.address, ethers.parseEther('10')); + await token.mint(voter2.address, ethers.parseEther('20')); + await token.mint(voter3.address, ethers.parseEther('30')); + }); + + describe('Token and Governor Setup', () => { + it('should have correct initial settings', async () => { + expect(await governor.votingDelay()).to.equal(VOTING_DELAY); + expect(await governor.votingPeriod()).to.equal(VOTING_PERIOD); + expect(await governor.proposalThreshold()).to.equal(PROPOSAL_THRESHOLD); + expect(await governor.token()).to.equal(token.target); + }); + + it('should have correct initial token distribution', async () => { + expect(await token.balanceOf(proposer.address)).to.equal(PROPOSAL_THRESHOLD * BigInt(2)); + expect(await token.balanceOf(voter1.address)).to.equal(ethers.parseEther('10')); + expect(await token.balanceOf(voter2.address)).to.equal(ethers.parseEther('20')); + expect(await token.balanceOf(voter3.address)).to.equal(ethers.parseEther('30')); + }); + + it('should have zero initial voting power for all accounts', async () => { + expect(await token.getVotes(proposer.address)).to.equal(0); + expect(await token.getVotes(voter1.address)).to.equal(0); + expect(await token.getVotes(voter2.address)).to.equal(0); + expect(await token.getVotes(voter3.address)).to.equal(0); + }); + }); + + describe('Proposal Creation', () => { + it('should fail to propose when balance is below threshold', async () => { + await expect( + governor.connect(voter1).propose([randomEOA.address], [0], ['0xaabbccdd'], 'Proposal #1'), + ).to.be.revertedWith('Governor: proposer votes below proposal threshold'); + }); + + it('should fail to propose when balance is sufficient but not delegated', async () => { + await expect( + governor.connect(proposer).propose([randomEOA.address], [0], ['0xaabbccdd'], 'Proposal #1'), + ).to.be.revertedWith('Governor: proposer votes below proposal threshold'); + }); + + it('should successfully propose after delegating', async () => { + await token.connect(proposer).delegate(proposer.address); + + await expect( + governor.connect(proposer).propose([randomEOA.address], [0], ['0xaabbccdd'], 'Proposal #1'), + ).to.emit(governor, 'ProposalCreated'); + }); + + it('should fail to propose with empty proposal', async () => { + await token.connect(proposer).delegate(proposer.address); + + await expect( + governor.connect(proposer).propose([], [], [], 'Empty Proposal'), + ).to.be.revertedWith('Governor: empty proposal'); + }); + }); + + describe('Voting Power and Delegation', () => { + it('should correctly report voting power after delegation', async () => { + await token.connect(voter1).delegate(voter1.address); + expect(await token.getVotes(voter1.address)).to.equal(ethers.parseEther('10')); + }); + + it('should correctly transfer voting power when transferring tokens', async () => { + await token.connect(voter1).delegate(voter1.address); + await token + .connect(voter1) + .transfer(voter1.address, voter2.address, ethers.parseEther('5'), true, '0x'); + + expect(await token.getVotes(voter1.address)).to.equal(ethers.parseEther('5')); + expect(await token.balanceOf(voter2.address)).to.equal(ethers.parseEther('25')); + }); + + it('should correctly report delegates', async () => { + await token.connect(voter1).delegate(voter2.address); + expect(await token.delegates(voter1.address)).to.equal(voter2.address); + }); + + it('should allow changing delegates', async () => { + await token.connect(voter1).delegate(voter2.address); + await token.connect(voter1).delegate(voter3.address); + expect(await token.delegates(voter1.address)).to.equal(voter3.address); + }); + + it('should update voting power when receiving tokens', async () => { + await token.connect(voter1).delegate(voter1.address); + await token + .connect(voter2) + .transfer(voter2.address, voter1.address, ethers.parseEther('5'), true, '0x'); + expect(await token.getVotes(voter1.address)).to.equal(ethers.parseEther('15')); + }); + }); + + describe('Voting Process and Proposal Lifecycle', () => { + let proposalId; + + beforeEach(async () => { + // Setup for voting tests + await token.connect(proposer).delegate(proposer.address); + await token.connect(voter1).delegate(voter1.address); + await token.connect(voter2).delegate(voter2.address); + await token.connect(voter3).delegate(voter3.address); + + proposalId = await governor + .connect(proposer) + .propose.staticCall([randomEOA.address], [0], ['0xaabbccdd'], 'Proposal #1'); + + await governor + .connect(proposer) + .propose([randomEOA.address], [0], ['0xaabbccdd'], 'Proposal #1'); + }); + + it('should not allow voting before voting delay has passed', async () => { + await expect(governor.connect(voter1).castVote(proposalId, 1)).to.be.revertedWith( + 'Governor: vote not currently active', + ); + }); + + it('should allow voting after voting delay has passed', async () => { + await mine(VOTING_DELAY + 1); + await expect(governor.connect(voter1).castVote(proposalId, 1)).to.emit(governor, 'VoteCast'); + }); + + it('should correctly count votes and update quorum', async () => { + await mine(VOTING_DELAY + 1); + await governor.connect(voter1).castVote(proposalId, 1); // Yes vote + await governor.connect(voter2).castVote(proposalId, 0); // No vote + await governor.connect(voter3).castVote(proposalId, 2); // Abstain + + const proposal = await governor.proposalVotes(proposalId); + expect(proposal.forVotes).to.equal(ethers.parseEther('10')); + expect(proposal.againstVotes).to.equal(ethers.parseEther('20')); + expect(proposal.abstainVotes).to.equal(ethers.parseEther('30')); + }); + + it('should not allow voting after voting period has ended', async () => { + await mine(VOTING_DELAY + VOTING_PERIOD + 1); + await expect(governor.connect(voter1).castVote(proposalId, 1)).to.be.revertedWith( + 'Governor: vote not currently active', + ); + }); + + it('should correctly determine if quorum is reached', async () => { + const totalSupply = await token.totalSupply(); + const quorumRequired = (totalSupply * BigInt(QUORUM_FRACTION)) / BigInt(100); + + await mine(VOTING_DELAY + 1); + await governor.connect(voter3).castVote(proposalId, 1); // This should meet quorum + + expect(await governor.quorum((await time.latestBlock()) - 1)).to.be.lte(quorumRequired); + }); + + it('should allow proposal execution only after voting period and timelock', async () => { + await mine(VOTING_DELAY + 1); + await governor.connect(voter3).castVote(proposalId, 1); // Ensure quorum and pass + + // Try to execute immediately after voting period + await expect( + governor.execute([randomEOA.address], [0], ['0xaabbccdd'], ethers.id('Proposal #1')), + ).to.be.revertedWith('Governor: proposal not successful'); + + // Move past timelock period + await mine(await governor.votingPeriod()); + + // Now execution should succeed + await expect( + governor.execute([randomEOA.address], [0], ['0xaabbccdd'], ethers.id('Proposal #1')), + ).to.emit(governor, 'ProposalExecuted'); + }); + + it('should not allow double voting', async () => { + await mine(VOTING_DELAY + 1); + await governor.connect(voter1).castVote(proposalId, 1); + await expect(governor.connect(voter1).castVote(proposalId, 1)).to.be.revertedWith( + 'GovernorVotingSimple: vote already cast', + ); + }); + }); + + describe('Advanced Voting Power Scenarios', () => { + it('should correctly calculate voting power at past timepoints', async () => { + await token.connect(voter1).delegate(voter1.address); + await mine(); // Mine a block to record the delegation + + const blockNumber1 = await ethers.provider.getBlockNumber(); + expect(await token.getPastVotes(voter1.address, blockNumber1 - 1)).to.equal( + ethers.parseEther('10'), + ); + + await token.mint(voter1.address, ethers.parseEther('10')); + await mine(); // Mine a block to record the mint + + const blockNumber2 = await ethers.provider.getBlockNumber(); + expect(await token.getPastVotes(voter1.address, blockNumber2 - 1)).to.equal( + ethers.parseEther('20'), + ); + }); + + it('should correctly report past total supply', async () => { + const initialSupply = await token.totalSupply(); + const blockNumber1 = await ethers.provider.getBlockNumber(); + + await token.mint(voter1.address, ethers.parseEther('100')); + await mine(); // Mine a block to record the mint + + const blockNumber2 = await ethers.provider.getBlockNumber(); + + expect(await token.getPastTotalSupply(blockNumber1)).to.equal(initialSupply); + expect(await token.getPastTotalSupply(blockNumber2 - 1)).to.equal( + initialSupply + ethers.parseEther('100'), + ); + }); + + it('should not allow querying future timepoints', async () => { + const currentBlock = await ethers.provider.getBlockNumber(); + const futureBlock = currentBlock + 1000; + + await expect(token.getPastVotes(voter1.address, futureBlock)).to.be.revertedWith( + 'LSP7Votes: future lookup', + ); + await expect(token.getPastTotalSupply(futureBlock)).to.be.revertedWith( + 'LSP7Votes: future lookup', + ); + }); + }); +});