diff --git a/contracts/azorius/strategies/HatsProposalCreationWhitelist.sol b/contracts/azorius/strategies/HatsProposalCreationWhitelist.sol new file mode 100644 index 00000000..43b718f7 --- /dev/null +++ b/contracts/azorius/strategies/HatsProposalCreationWhitelist.sol @@ -0,0 +1,105 @@ +// SPDX-License-Identifier: MIT +pragma solidity =0.8.19; + +import {OwnableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; +import {IHats} from "../../interfaces/hats/IHats.sol"; + +abstract contract HatsProposalCreationWhitelist is OwnableUpgradeable { + event HatWhitelisted(uint256 hatId); + event HatRemovedFromWhitelist(uint256 hatId); + + IHats public hatsContract; + + /** Array to store whitelisted Hat IDs. */ + uint256[] private whitelistedHatIds; + + error InvalidHatsContract(); + error NoHatsWhitelisted(); + error HatAlreadyWhitelisted(); + error HatNotWhitelisted(); + + /** + * Sets up the contract with its initial parameters. + * + * @param initializeParams encoded initialization parameters: + * `address _hatsContract`, `uint256[] _initialWhitelistedHats` + */ + function setUp(bytes memory initializeParams) public virtual { + (address _hatsContract, uint256[] memory _initialWhitelistedHats) = abi + .decode(initializeParams, (address, uint256[])); + + if (_hatsContract == address(0)) revert InvalidHatsContract(); + hatsContract = IHats(_hatsContract); + + if (_initialWhitelistedHats.length == 0) revert NoHatsWhitelisted(); + for (uint256 i = 0; i < _initialWhitelistedHats.length; i++) { + _whitelistHat(_initialWhitelistedHats[i]); + } + } + + /** + * Adds a Hat to the whitelist for proposal creation. + * @param _hatId The ID of the Hat to whitelist + */ + function whitelistHat(uint256 _hatId) external onlyOwner { + _whitelistHat(_hatId); + } + + /** + * Internal function to add a Hat to the whitelist. + * @param _hatId The ID of the Hat to whitelist + */ + function _whitelistHat(uint256 _hatId) internal { + for (uint256 i = 0; i < whitelistedHatIds.length; i++) { + if (whitelistedHatIds[i] == _hatId) revert HatAlreadyWhitelisted(); + } + whitelistedHatIds.push(_hatId); + emit HatWhitelisted(_hatId); + } + + /** + * Removes a Hat from the whitelist for proposal creation. + * @param _hatId The ID of the Hat to remove from the whitelist + */ + function removeHatFromWhitelist(uint256 _hatId) external onlyOwner { + bool found = false; + for (uint256 i = 0; i < whitelistedHatIds.length; i++) { + if (whitelistedHatIds[i] == _hatId) { + whitelistedHatIds[i] = whitelistedHatIds[ + whitelistedHatIds.length - 1 + ]; + whitelistedHatIds.pop(); + found = true; + break; + } + } + if (!found) revert HatNotWhitelisted(); + + emit HatRemovedFromWhitelist(_hatId); + } + + /** + * @dev Checks if an address is authorized to create proposals. + * @param _address The address to check for proposal creation authorization. + * @return bool Returns true if the address is wearing any of the whitelisted Hats, false otherwise. + * @notice This function overrides the isProposer function from the parent contract. + * It iterates through all whitelisted Hat IDs and checks if the given address + * is wearing any of them using the Hats Protocol. + */ + function isProposer(address _address) public view virtual returns (bool) { + for (uint256 i = 0; i < whitelistedHatIds.length; i++) { + if (hatsContract.isWearerOfHat(_address, whitelistedHatIds[i])) { + return true; + } + } + return false; + } + + /** + * @dev Returns the IDs of all whitelisted Hats. + * @return uint256[] memory An array of whitelisted Hat IDs. + */ + function getWhitelistedHatIds() public view returns (uint256[] memory) { + return whitelistedHatIds; + } +} diff --git a/contracts/azorius/strategies/LinearERC20VotingExtensible.sol b/contracts/azorius/strategies/LinearERC20VotingExtensible.sol new file mode 100644 index 00000000..cdbf8b10 --- /dev/null +++ b/contracts/azorius/strategies/LinearERC20VotingExtensible.sol @@ -0,0 +1,333 @@ +// SPDX-License-Identifier: LGPL-3.0-only +pragma solidity =0.8.19; + +import {IVotes} from "@openzeppelin/contracts/governance/utils/IVotes.sol"; +import {BaseStrategy, IBaseStrategy} from "../BaseStrategy.sol"; +import {BaseQuorumPercent} from "../BaseQuorumPercent.sol"; +import {BaseVotingBasisPercent} from "../BaseVotingBasisPercent.sol"; + +/** + * An [Azorius](./Azorius.md) [BaseStrategy](./BaseStrategy.md) implementation that + * enables linear (i.e. 1 to 1) token voting. Each token delegated to a given address + * in an `ERC20Votes` token equals 1 vote for a Proposal. + * + * This contract is an extensible version of LinearERC20Voting, with all functions + * marked as `virtual`. This allows other contracts to inherit from it and override + * any part of its functionality. The existence of this contract enables the creation + * of more specialized voting strategies that build upon the basic linear ERC20 voting + * mechanism while allowing for customization of specific aspects as needed. + */ +abstract contract LinearERC20VotingExtensible is + BaseStrategy, + BaseQuorumPercent, + BaseVotingBasisPercent +{ + /** + * The voting options for a Proposal. + */ + enum VoteType { + NO, // disapproves of executing the Proposal + YES, // approves of executing the Proposal + ABSTAIN // neither YES nor NO, i.e. voting "present" + } + + /** + * Defines the current state of votes on a particular Proposal. + */ + struct ProposalVotes { + uint32 votingStartBlock; // block that voting starts at + uint32 votingEndBlock; // block that voting ends + uint256 noVotes; // current number of NO votes for the Proposal + uint256 yesVotes; // current number of YES votes for the Proposal + uint256 abstainVotes; // current number of ABSTAIN votes for the Proposal + mapping(address => bool) hasVoted; // whether a given address has voted yet or not + } + + IVotes public governanceToken; + + /** Number of blocks a new Proposal can be voted on. */ + uint32 public votingPeriod; + + /** Voting weight required to be able to submit Proposals. */ + uint256 public requiredProposerWeight; + + /** `proposalId` to `ProposalVotes`, the voting state of a Proposal. */ + mapping(uint256 => ProposalVotes) internal proposalVotes; + + event VotingPeriodUpdated(uint32 votingPeriod); + event RequiredProposerWeightUpdated(uint256 requiredProposerWeight); + event ProposalInitialized(uint32 proposalId, uint32 votingEndBlock); + event Voted( + address voter, + uint32 proposalId, + uint8 voteType, + uint256 weight + ); + + error InvalidProposal(); + error VotingEnded(); + error AlreadyVoted(); + error InvalidVote(); + error InvalidTokenAddress(); + + /** + * Sets up the contract with its initial parameters. + * + * @param initializeParams encoded initialization parameters: `address _owner`, + * `IVotes _governanceToken`, `address _azoriusModule`, `uint32 _votingPeriod`, + * `uint256 _requiredProposerWeight`, `uint256 _quorumNumerator`, + * `uint256 _basisNumerator` + */ + function setUp( + bytes memory initializeParams + ) public virtual override initializer { + ( + address _owner, + IVotes _governanceToken, + address _azoriusModule, + uint32 _votingPeriod, + uint256 _requiredProposerWeight, + uint256 _quorumNumerator, + uint256 _basisNumerator + ) = abi.decode( + initializeParams, + (address, IVotes, address, uint32, uint256, uint256, uint256) + ); + if (address(_governanceToken) == address(0)) + revert InvalidTokenAddress(); + + governanceToken = _governanceToken; + __Ownable_init(); + transferOwnership(_owner); + _setAzorius(_azoriusModule); + _updateQuorumNumerator(_quorumNumerator); + _updateBasisNumerator(_basisNumerator); + _updateVotingPeriod(_votingPeriod); + _updateRequiredProposerWeight(_requiredProposerWeight); + + emit StrategySetUp(_azoriusModule, _owner); + } + + /** + * Updates the voting time period for new Proposals. + * + * @param _votingPeriod voting time period (in blocks) + */ + function updateVotingPeriod( + uint32 _votingPeriod + ) external virtual onlyOwner { + _updateVotingPeriod(_votingPeriod); + } + + /** + * Updates the voting weight required to submit new Proposals. + * + * @param _requiredProposerWeight required token voting weight + */ + function updateRequiredProposerWeight( + uint256 _requiredProposerWeight + ) external virtual onlyOwner { + _updateRequiredProposerWeight(_requiredProposerWeight); + } + + /** + * Casts votes for a Proposal, equal to the caller's token delegation. + * + * @param _proposalId id of the Proposal to vote on + * @param _voteType Proposal support as defined in VoteType (NO, YES, ABSTAIN) + */ + function vote(uint32 _proposalId, uint8 _voteType) external virtual { + _vote( + _proposalId, + msg.sender, + _voteType, + getVotingWeight(msg.sender, _proposalId) + ); + } + + /** + * Returns the current state of the specified Proposal. + * + * @param _proposalId id of the Proposal + * @return noVotes current count of "NO" votes + * @return yesVotes current count of "YES" votes + * @return abstainVotes current count of "ABSTAIN" votes + * @return startBlock block number voting starts + * @return endBlock block number voting ends + */ + function getProposalVotes( + uint32 _proposalId + ) + external + view + virtual + returns ( + uint256 noVotes, + uint256 yesVotes, + uint256 abstainVotes, + uint32 startBlock, + uint32 endBlock, + uint256 votingSupply + ) + { + noVotes = proposalVotes[_proposalId].noVotes; + yesVotes = proposalVotes[_proposalId].yesVotes; + abstainVotes = proposalVotes[_proposalId].abstainVotes; + startBlock = proposalVotes[_proposalId].votingStartBlock; + endBlock = proposalVotes[_proposalId].votingEndBlock; + votingSupply = getProposalVotingSupply(_proposalId); + } + + /** @inheritdoc BaseStrategy*/ + function initializeProposal( + bytes memory _data + ) public virtual override onlyAzorius { + uint32 proposalId = abi.decode(_data, (uint32)); + uint32 _votingEndBlock = uint32(block.number) + votingPeriod; + + proposalVotes[proposalId].votingEndBlock = _votingEndBlock; + proposalVotes[proposalId].votingStartBlock = uint32(block.number); + + emit ProposalInitialized(proposalId, _votingEndBlock); + } + + /** + * Returns whether an address has voted on the specified Proposal. + * + * @param _proposalId id of the Proposal to check + * @param _address address to check + * @return bool true if the address has voted on the Proposal, otherwise false + */ + function hasVoted( + uint32 _proposalId, + address _address + ) public view virtual returns (bool) { + return proposalVotes[_proposalId].hasVoted[_address]; + } + + /** @inheritdoc BaseStrategy*/ + function isPassed( + uint32 _proposalId + ) public view virtual override returns (bool) { + return (block.number > proposalVotes[_proposalId].votingEndBlock && // voting period has ended + meetsQuorum( + getProposalVotingSupply(_proposalId), + proposalVotes[_proposalId].yesVotes, + proposalVotes[_proposalId].abstainVotes + ) && // yes + abstain votes meets the quorum + meetsBasis( + proposalVotes[_proposalId].yesVotes, + proposalVotes[_proposalId].noVotes + )); // yes votes meets the basis + } + + /** + * Returns a snapshot of total voting supply for a given Proposal. Because token supplies can change, + * it is necessary to calculate quorum from the supply available at the time of the Proposal's creation, + * not when it is being voted on passes / fails. + * + * @param _proposalId id of the Proposal + * @return uint256 voting supply snapshot for the given _proposalId + */ + function getProposalVotingSupply( + uint32 _proposalId + ) public view virtual returns (uint256) { + return + governanceToken.getPastTotalSupply( + proposalVotes[_proposalId].votingStartBlock + ); + } + + /** + * Calculates the voting weight an address has for a specific Proposal. + * + * @param _voter address of the voter + * @param _proposalId id of the Proposal + * @return uint256 the address' voting weight + */ + function getVotingWeight( + address _voter, + uint32 _proposalId + ) public view virtual returns (uint256) { + return + governanceToken.getPastVotes( + _voter, + proposalVotes[_proposalId].votingStartBlock + ); + } + + /** @inheritdoc BaseStrategy*/ + function isProposer( + address _address + ) public view virtual override returns (bool) { + return + governanceToken.getPastVotes(_address, block.number - 1) >= + requiredProposerWeight; + } + + /** @inheritdoc BaseStrategy*/ + function votingEndBlock( + uint32 _proposalId + ) public view virtual override returns (uint32) { + return proposalVotes[_proposalId].votingEndBlock; + } + + /** Internal implementation of `updateVotingPeriod`. */ + function _updateVotingPeriod(uint32 _votingPeriod) internal virtual { + votingPeriod = _votingPeriod; + emit VotingPeriodUpdated(_votingPeriod); + } + + /** Internal implementation of `updateRequiredProposerWeight`. */ + function _updateRequiredProposerWeight( + uint256 _requiredProposerWeight + ) internal virtual { + requiredProposerWeight = _requiredProposerWeight; + emit RequiredProposerWeightUpdated(_requiredProposerWeight); + } + + /** + * Internal function for casting a vote on a Proposal. + * + * @param _proposalId id of the Proposal + * @param _voter address casting the vote + * @param _voteType vote support, as defined in VoteType + * @param _weight amount of voting weight cast, typically the + * total number of tokens delegated + */ + function _vote( + uint32 _proposalId, + address _voter, + uint8 _voteType, + uint256 _weight + ) internal virtual { + if (proposalVotes[_proposalId].votingEndBlock == 0) + revert InvalidProposal(); + if (block.number > proposalVotes[_proposalId].votingEndBlock) + revert VotingEnded(); + if (proposalVotes[_proposalId].hasVoted[_voter]) revert AlreadyVoted(); + + proposalVotes[_proposalId].hasVoted[_voter] = true; + + if (_voteType == uint8(VoteType.NO)) { + proposalVotes[_proposalId].noVotes += _weight; + } else if (_voteType == uint8(VoteType.YES)) { + proposalVotes[_proposalId].yesVotes += _weight; + } else if (_voteType == uint8(VoteType.ABSTAIN)) { + proposalVotes[_proposalId].abstainVotes += _weight; + } else { + revert InvalidVote(); + } + + emit Voted(_voter, _proposalId, _voteType, _weight); + } + + /** @inheritdoc BaseQuorumPercent*/ + function quorumVotes( + uint32 _proposalId + ) public view virtual override returns (uint256) { + return + (quorumNumerator * getProposalVotingSupply(_proposalId)) / + QUORUM_DENOMINATOR; + } +} diff --git a/contracts/azorius/strategies/LinearERC20VotingWithHatsProposalCreation.sol b/contracts/azorius/strategies/LinearERC20VotingWithHatsProposalCreation.sol new file mode 100644 index 00000000..4fb8f79f --- /dev/null +++ b/contracts/azorius/strategies/LinearERC20VotingWithHatsProposalCreation.sol @@ -0,0 +1,82 @@ +// SPDX-License-Identifier: LGPL-3.0-only +pragma solidity =0.8.19; + +import {LinearERC20VotingExtensible} from "./LinearERC20VotingExtensible.sol"; +import {HatsProposalCreationWhitelist} from "./HatsProposalCreationWhitelist.sol"; +import {IHats} from "../../interfaces/hats/IHats.sol"; + +/** + * An [Azorius](./Azorius.md) [BaseStrategy](./BaseStrategy.md) implementation that + * enables linear (i.e. 1 to 1) ERC20 based token voting, with proposal creation + * restricted to users wearing whitelisted Hats. + */ +contract LinearERC20VotingWithHatsProposalCreation is + HatsProposalCreationWhitelist, + LinearERC20VotingExtensible +{ + /** + * Sets up the contract with its initial parameters. + * + * @param initializeParams encoded initialization parameters: `address _owner`, + * `address _governanceToken`, `address _azoriusModule`, `uint32 _votingPeriod`, + * `uint256 _quorumNumerator`, `uint256 _basisNumerator`, `address _hatsContract`, + * `uint256[] _initialWhitelistedHats` + */ + function setUp( + bytes memory initializeParams + ) + public + override(HatsProposalCreationWhitelist, LinearERC20VotingExtensible) + { + ( + address _owner, + address _governanceToken, + address _azoriusModule, + uint32 _votingPeriod, + uint256 _quorumNumerator, + uint256 _basisNumerator, + address _hatsContract, + uint256[] memory _initialWhitelistedHats + ) = abi.decode( + initializeParams, + ( + address, + address, + address, + uint32, + uint256, + uint256, + address, + uint256[] + ) + ); + + LinearERC20VotingExtensible.setUp( + abi.encode( + _owner, + _governanceToken, + _azoriusModule, + _votingPeriod, + 0, // requiredProposerWeight is zero because we only care about the hat check + _quorumNumerator, + _basisNumerator + ) + ); + + HatsProposalCreationWhitelist.setUp( + abi.encode(_hatsContract, _initialWhitelistedHats) + ); + } + + /** @inheritdoc HatsProposalCreationWhitelist*/ + function isProposer( + address _address + ) + public + view + override(HatsProposalCreationWhitelist, LinearERC20VotingExtensible) + returns (bool) + { + return HatsProposalCreationWhitelist.isProposer(_address); + } +} diff --git a/contracts/azorius/strategies/LinearERC721VotingExtensible.sol b/contracts/azorius/strategies/LinearERC721VotingExtensible.sol new file mode 100644 index 00000000..616f6784 --- /dev/null +++ b/contracts/azorius/strategies/LinearERC721VotingExtensible.sol @@ -0,0 +1,471 @@ +// SPDX-License-Identifier: LGPL-3.0-only +pragma solidity =0.8.19; + +import {IERC721} from "@openzeppelin/contracts/token/ERC721/IERC721.sol"; +import {IERC721VotingStrategy} from "../interfaces/IERC721VotingStrategy.sol"; +import {BaseVotingBasisPercent} from "../BaseVotingBasisPercent.sol"; +import {IAzorius} from "../interfaces/IAzorius.sol"; +import {BaseStrategy} from "../BaseStrategy.sol"; + +/** + * An Azorius strategy that allows multiple ERC721 tokens to be registered as governance tokens, + * each with their own voting weight. + * + * This is slightly different from ERC-20 voting, since there is no way to snapshot ERC721 holdings. + * Each ERC721 id can vote once, reguardless of what address held it when a proposal was created. + * + * Also, this uses "quorumThreshold" rather than LinearERC20Voting's quorumPercent, because the + * total supply of NFTs is not knowable within the IERC721 interface. This is similar to a multisig + * "total signers" required, rather than a percentage of the tokens. + * + * This contract is an extensible version of LinearERC721Voting, with all functions + * marked as `virtual`. This allows other contracts to inherit from it and override + * any part of its functionality. The existence of this contract enables the creation + * of more specialized voting strategies that build upon the basic linear ERC721 voting + * mechanism while allowing for customization of specific aspects as needed. + */ +abstract contract LinearERC721VotingExtensible is + BaseStrategy, + BaseVotingBasisPercent, + IERC721VotingStrategy +{ + /** + * The voting options for a Proposal. + */ + enum VoteType { + NO, // disapproves of executing the Proposal + YES, // approves of executing the Proposal + ABSTAIN // neither YES nor NO, i.e. voting "present" + } + + /** + * Defines the current state of votes on a particular Proposal. + */ + struct ProposalVotes { + uint32 votingStartBlock; // block that voting starts at + uint32 votingEndBlock; // block that voting ends + uint256 noVotes; // current number of NO votes for the Proposal + uint256 yesVotes; // current number of YES votes for the Proposal + uint256 abstainVotes; // current number of ABSTAIN votes for the Proposal + /** + * ERC-721 contract address to individual NFT id to bool + * of whether it has voted on this proposal. + */ + mapping(address => mapping(uint256 => bool)) hasVoted; + } + + /** `proposalId` to `ProposalVotes`, the voting state of a Proposal. */ + mapping(uint256 => ProposalVotes) public proposalVotes; + + /** The list of ERC-721 tokens that can vote. */ + address[] public tokenAddresses; + + /** ERC-721 address to its voting weight per NFT id. */ + mapping(address => uint256) public tokenWeights; + + /** Number of blocks a new Proposal can be voted on. */ + uint32 public votingPeriod; + + /** + * The total number of votes required to achieve quorum. + * "Quorum threshold" is used instead of a quorum percent because IERC721 has no + * totalSupply function, so the contract cannot determine this. + */ + uint256 public quorumThreshold; + + /** + * The minimum number of voting power required to create a new proposal. + */ + uint256 public proposerThreshold; + + event VotingPeriodUpdated(uint32 votingPeriod); + event QuorumThresholdUpdated(uint256 quorumThreshold); + event ProposerThresholdUpdated(uint256 proposerThreshold); + event ProposalInitialized(uint32 proposalId, uint32 votingEndBlock); + event Voted( + address voter, + uint32 proposalId, + uint8 voteType, + address[] tokenAddresses, + uint256[] tokenIds + ); + event GovernanceTokenAdded(address token, uint256 weight); + event GovernanceTokenRemoved(address token); + + error InvalidParams(); + error InvalidProposal(); + error VotingEnded(); + error InvalidVote(); + error InvalidTokenAddress(); + error NoVotingWeight(); + error TokenAlreadySet(); + error TokenNotSet(); + error IdAlreadyVoted(uint256 tokenId); + error IdNotOwned(uint256 tokenId); + + /** + * Sets up the contract with its initial parameters. + * + * @param initializeParams encoded initialization parameters: `address _owner`, + * `address[] memory _tokens`, `uint256[] memory _weights`, `address _azoriusModule`, + * `uint32 _votingPeriod`, `uint256 _quorumThreshold`, `uint256 _proposerThreshold`, + * `uint256 _basisNumerator` + */ + function setUp( + bytes memory initializeParams + ) public virtual override initializer { + ( + address _owner, + address[] memory _tokens, + uint256[] memory _weights, + address _azoriusModule, + uint32 _votingPeriod, + uint256 _quorumThreshold, + uint256 _proposerThreshold, + uint256 _basisNumerator + ) = abi.decode( + initializeParams, + ( + address, + address[], + uint256[], + address, + uint32, + uint256, + uint256, + uint256 + ) + ); + + if (_tokens.length != _weights.length) { + revert InvalidParams(); + } + + for (uint i = 0; i < _tokens.length; ) { + _addGovernanceToken(_tokens[i], _weights[i]); + unchecked { + ++i; + } + } + + __Ownable_init(); + transferOwnership(_owner); + _setAzorius(_azoriusModule); + _updateQuorumThreshold(_quorumThreshold); + _updateProposerThreshold(_proposerThreshold); + _updateBasisNumerator(_basisNumerator); + _updateVotingPeriod(_votingPeriod); + + emit StrategySetUp(_azoriusModule, _owner); + } + + /** + * Adds a new ERC-721 token as a governance token, along with its associated weight. + * + * @param _tokenAddress the address of the ERC-721 token + * @param _weight the number of votes each NFT id is worth + */ + function addGovernanceToken( + address _tokenAddress, + uint256 _weight + ) external virtual onlyOwner { + _addGovernanceToken(_tokenAddress, _weight); + } + + /** + * Updates the voting time period for new Proposals. + * + * @param _votingPeriod voting time period (in blocks) + */ + function updateVotingPeriod( + uint32 _votingPeriod + ) external virtual onlyOwner { + _updateVotingPeriod(_votingPeriod); + } + + /** + * Updates the quorum required for future Proposals. + * + * @param _quorumThreshold total voting weight required to achieve quorum + */ + function updateQuorumThreshold( + uint256 _quorumThreshold + ) external virtual onlyOwner { + _updateQuorumThreshold(_quorumThreshold); + } + + /** + * Updates the voting weight required to submit new Proposals. + * + * @param _proposerThreshold required voting weight + */ + function updateProposerThreshold( + uint256 _proposerThreshold + ) external virtual onlyOwner { + _updateProposerThreshold(_proposerThreshold); + } + + /** + * Returns whole list of governance tokens addresses + */ + function getAllTokenAddresses() + external + view + virtual + returns (address[] memory) + { + return tokenAddresses; + } + + /** + * Returns the current state of the specified Proposal. + * + * @param _proposalId id of the Proposal + * @return noVotes current count of "NO" votes + * @return yesVotes current count of "YES" votes + * @return abstainVotes current count of "ABSTAIN" votes + * @return startBlock block number voting starts + * @return endBlock block number voting ends + */ + function getProposalVotes( + uint32 _proposalId + ) + external + view + virtual + returns ( + uint256 noVotes, + uint256 yesVotes, + uint256 abstainVotes, + uint32 startBlock, + uint32 endBlock + ) + { + noVotes = proposalVotes[_proposalId].noVotes; + yesVotes = proposalVotes[_proposalId].yesVotes; + abstainVotes = proposalVotes[_proposalId].abstainVotes; + startBlock = proposalVotes[_proposalId].votingStartBlock; + endBlock = proposalVotes[_proposalId].votingEndBlock; + } + + /** + * Submits a vote on an existing Proposal. + * + * @param _proposalId id of the Proposal to vote on + * @param _voteType Proposal support as defined in VoteType (NO, YES, ABSTAIN) + * @param _tokenAddresses list of ERC-721 addresses that correspond to ids in _tokenIds + * @param _tokenIds list of unique token ids that correspond to their ERC-721 address in _tokenAddresses + */ + function vote( + uint32 _proposalId, + uint8 _voteType, + address[] memory _tokenAddresses, + uint256[] memory _tokenIds + ) external virtual { + if (_tokenAddresses.length != _tokenIds.length) revert InvalidParams(); + _vote(_proposalId, msg.sender, _voteType, _tokenAddresses, _tokenIds); + } + + /** @inheritdoc IERC721VotingStrategy*/ + function getTokenWeight( + address _tokenAddress + ) external view virtual override returns (uint256) { + return tokenWeights[_tokenAddress]; + } + + /** + * Returns whether an NFT id has already voted. + * + * @param _proposalId the id of the Proposal + * @param _tokenAddress the ERC-721 contract address + * @param _tokenId the unique id of the NFT + */ + function hasVoted( + uint32 _proposalId, + address _tokenAddress, + uint256 _tokenId + ) external view virtual returns (bool) { + return proposalVotes[_proposalId].hasVoted[_tokenAddress][_tokenId]; + } + + /** + * Removes the given ERC-721 token address from the list of governance tokens. + * + * @param _tokenAddress the ERC-721 token to remove + */ + function removeGovernanceToken( + address _tokenAddress + ) external virtual onlyOwner { + if (tokenWeights[_tokenAddress] == 0) revert TokenNotSet(); + + tokenWeights[_tokenAddress] = 0; + + uint256 length = tokenAddresses.length; + for (uint256 i = 0; i < length; ) { + if (_tokenAddress == tokenAddresses[i]) { + uint256 last = length - 1; + tokenAddresses[i] = tokenAddresses[last]; // move the last token into the position to remove + delete tokenAddresses[last]; // delete the last token + break; + } + unchecked { + ++i; + } + } + + emit GovernanceTokenRemoved(_tokenAddress); + } + + /** @inheritdoc BaseStrategy*/ + function initializeProposal( + bytes memory _data + ) public virtual override onlyAzorius { + uint32 proposalId = abi.decode(_data, (uint32)); + uint32 _votingEndBlock = uint32(block.number) + votingPeriod; + + proposalVotes[proposalId].votingEndBlock = _votingEndBlock; + proposalVotes[proposalId].votingStartBlock = uint32(block.number); + + emit ProposalInitialized(proposalId, _votingEndBlock); + } + + /** @inheritdoc BaseStrategy*/ + function isPassed( + uint32 _proposalId + ) public view virtual override returns (bool) { + return (block.number > proposalVotes[_proposalId].votingEndBlock && // voting period has ended + quorumThreshold <= + proposalVotes[_proposalId].yesVotes + + proposalVotes[_proposalId].abstainVotes && // yes + abstain votes meets the quorum + meetsBasis( + proposalVotes[_proposalId].yesVotes, + proposalVotes[_proposalId].noVotes + )); // yes votes meets the basis + } + + /** @inheritdoc BaseStrategy*/ + function isProposer( + address _address + ) public view virtual override returns (bool) { + uint256 totalWeight = 0; + for (uint i = 0; i < tokenAddresses.length; ) { + address tokenAddress = tokenAddresses[i]; + totalWeight += + IERC721(tokenAddress).balanceOf(_address) * + tokenWeights[tokenAddress]; + unchecked { + ++i; + } + } + return totalWeight >= proposerThreshold; + } + + /** @inheritdoc BaseStrategy*/ + function votingEndBlock( + uint32 _proposalId + ) public view virtual override returns (uint32) { + return proposalVotes[_proposalId].votingEndBlock; + } + + /** Internal implementation of `addGovernanceToken` */ + function _addGovernanceToken( + address _tokenAddress, + uint256 _weight + ) internal virtual { + if (!IERC721(_tokenAddress).supportsInterface(0x80ac58cd)) + revert InvalidTokenAddress(); + + if (_weight == 0) revert NoVotingWeight(); + + if (tokenWeights[_tokenAddress] > 0) revert TokenAlreadySet(); + + tokenAddresses.push(_tokenAddress); + tokenWeights[_tokenAddress] = _weight; + + emit GovernanceTokenAdded(_tokenAddress, _weight); + } + + /** Internal implementation of `updateVotingPeriod`. */ + function _updateVotingPeriod(uint32 _votingPeriod) internal virtual { + votingPeriod = _votingPeriod; + emit VotingPeriodUpdated(_votingPeriod); + } + + /** Internal implementation of `updateQuorumThreshold`. */ + function _updateQuorumThreshold(uint256 _quorumThreshold) internal virtual { + quorumThreshold = _quorumThreshold; + emit QuorumThresholdUpdated(quorumThreshold); + } + + /** Internal implementation of `updateProposerThreshold`. */ + function _updateProposerThreshold( + uint256 _proposerThreshold + ) internal virtual { + proposerThreshold = _proposerThreshold; + emit ProposerThresholdUpdated(_proposerThreshold); + } + + /** + * Internal function for casting a vote on a Proposal. + * + * @param _proposalId id of the Proposal + * @param _voter address casting the vote + * @param _voteType vote support, as defined in VoteType + * @param _tokenAddresses list of ERC-721 addresses that correspond to ids in _tokenIds + * @param _tokenIds list of unique token ids that correspond to their ERC-721 address in _tokenAddresses + */ + function _vote( + uint32 _proposalId, + address _voter, + uint8 _voteType, + address[] memory _tokenAddresses, + uint256[] memory _tokenIds + ) internal virtual { + uint256 weight; + + // verifies the voter holds the NFTs and returns the total weight associated with their tokens + // the frontend will need to determine whether an address can vote on a proposal, as it is possible + // to vote twice if you get more weight later on + for (uint256 i = 0; i < _tokenAddresses.length; ) { + address tokenAddress = _tokenAddresses[i]; + uint256 tokenId = _tokenIds[i]; + + if (_voter != IERC721(tokenAddress).ownerOf(tokenId)) { + revert IdNotOwned(tokenId); + } + + if ( + proposalVotes[_proposalId].hasVoted[tokenAddress][tokenId] == + true + ) { + revert IdAlreadyVoted(tokenId); + } + + weight += tokenWeights[tokenAddress]; + proposalVotes[_proposalId].hasVoted[tokenAddress][tokenId] = true; + unchecked { + ++i; + } + } + + if (weight == 0) revert NoVotingWeight(); + + ProposalVotes storage proposal = proposalVotes[_proposalId]; + + if (proposal.votingEndBlock == 0) revert InvalidProposal(); + + if (block.number > proposal.votingEndBlock) revert VotingEnded(); + + if (_voteType == uint8(VoteType.NO)) { + proposal.noVotes += weight; + } else if (_voteType == uint8(VoteType.YES)) { + proposal.yesVotes += weight; + } else if (_voteType == uint8(VoteType.ABSTAIN)) { + proposal.abstainVotes += weight; + } else { + revert InvalidVote(); + } + + emit Voted(_voter, _proposalId, _voteType, _tokenAddresses, _tokenIds); + } +} diff --git a/contracts/azorius/strategies/LinearERC721VotingWithHatsProposalCreation.sol b/contracts/azorius/strategies/LinearERC721VotingWithHatsProposalCreation.sol new file mode 100644 index 00000000..21743c3b --- /dev/null +++ b/contracts/azorius/strategies/LinearERC721VotingWithHatsProposalCreation.sol @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: MIT +pragma solidity =0.8.19; + +import {LinearERC721VotingExtensible} from "./LinearERC721VotingExtensible.sol"; +import {HatsProposalCreationWhitelist} from "./HatsProposalCreationWhitelist.sol"; +import {IHats} from "../../interfaces/hats/IHats.sol"; + +/** + * An [Azorius](./Azorius.md) [BaseStrategy](./BaseStrategy.md) implementation that + * enables linear (i.e. 1 to 1) ERC721 based token voting, with proposal creation + * restricted to users wearing whitelisted Hats. + */ +contract LinearERC721VotingWithHatsProposalCreation is + HatsProposalCreationWhitelist, + LinearERC721VotingExtensible +{ + /** + * Sets up the contract with its initial parameters. + * + * @param initializeParams encoded initialization parameters: `address _owner`, + * `address[] memory _tokens`, `uint256[] memory _weights`, `address _azoriusModule`, + * `uint32 _votingPeriod`, `uint256 _quorumThreshold`, `uint256 _basisNumerator`, + * `address _hatsContract`, `uint256[] _initialWhitelistedHats` + */ + function setUp( + bytes memory initializeParams + ) + public + override(HatsProposalCreationWhitelist, LinearERC721VotingExtensible) + { + ( + address _owner, + address[] memory _tokens, + uint256[] memory _weights, + address _azoriusModule, + uint32 _votingPeriod, + uint256 _quorumThreshold, + uint256 _basisNumerator, + address _hatsContract, + uint256[] memory _initialWhitelistedHats + ) = abi.decode( + initializeParams, + ( + address, + address[], + uint256[], + address, + uint32, + uint256, + uint256, + address, + uint256[] + ) + ); + + LinearERC721VotingExtensible.setUp( + abi.encode( + _owner, + _tokens, + _weights, + _azoriusModule, + _votingPeriod, + _quorumThreshold, + 0, // _proposerThreshold is zero because we only care about the hat check + _basisNumerator + ) + ); + + HatsProposalCreationWhitelist.setUp( + abi.encode(_hatsContract, _initialWhitelistedHats) + ); + } + + /** @inheritdoc HatsProposalCreationWhitelist*/ + function isProposer( + address _address + ) + public + view + override(HatsProposalCreationWhitelist, LinearERC721VotingExtensible) + returns (bool) + { + return HatsProposalCreationWhitelist.isProposer(_address); + } +} diff --git a/contracts/mocks/MockHatsProposalCreationWhitelist.sol b/contracts/mocks/MockHatsProposalCreationWhitelist.sol new file mode 100644 index 00000000..6dc09fca --- /dev/null +++ b/contracts/mocks/MockHatsProposalCreationWhitelist.sol @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: MIT +pragma solidity =0.8.19; + +import "../azorius/strategies/HatsProposalCreationWhitelist.sol"; + +contract MockHatsProposalCreationWhitelist is HatsProposalCreationWhitelist { + function setUp(bytes memory initializeParams) public override initializer { + __Ownable_init(); + super.setUp(initializeParams); + } +} diff --git a/deploy/core/020_deploy_LinearERC20VotingWithHatsProposalCreation.ts b/deploy/core/020_deploy_LinearERC20VotingWithHatsProposalCreation.ts new file mode 100644 index 00000000..073e5022 --- /dev/null +++ b/deploy/core/020_deploy_LinearERC20VotingWithHatsProposalCreation.ts @@ -0,0 +1,9 @@ +import { HardhatRuntimeEnvironment } from 'hardhat/types'; +import { DeployFunction } from 'hardhat-deploy/types'; +import { deployNonUpgradeable } from '../helpers/deployNonUpgradeable'; + +const func: DeployFunction = async (hre: HardhatRuntimeEnvironment) => { + await deployNonUpgradeable(hre, 'LinearERC20VotingWithHatsProposalCreation'); +}; + +export default func; diff --git a/deploy/core/021_deploy_LinearERC721VotingWithHatsProposalCreation.ts b/deploy/core/021_deploy_LinearERC721VotingWithHatsProposalCreation.ts new file mode 100644 index 00000000..c6561065 --- /dev/null +++ b/deploy/core/021_deploy_LinearERC721VotingWithHatsProposalCreation.ts @@ -0,0 +1,9 @@ +import { HardhatRuntimeEnvironment } from 'hardhat/types'; +import { DeployFunction } from 'hardhat-deploy/types'; +import { deployNonUpgradeable } from '../helpers/deployNonUpgradeable'; + +const func: DeployFunction = async (hre: HardhatRuntimeEnvironment) => { + await deployNonUpgradeable(hre, 'LinearERC721VotingWithHatsProposalCreation'); +}; + +export default func; diff --git a/test/Azorius-LinearERC20Voting.test.ts b/test/Azorius-LinearERC20Voting.test.ts index 3abc55ec..9c98ce7a 100644 --- a/test/Azorius-LinearERC20Voting.test.ts +++ b/test/Azorius-LinearERC20Voting.test.ts @@ -76,18 +76,6 @@ describe('Safe with Azorius module and linearERC20Voting', () => { mockStrategy2, ] = await hre.ethers.getSigners(); - // Get Gnosis Safe Proxy factory - gnosisSafeProxyFactory = await hre.ethers.getContractAt( - 'GnosisSafeProxyFactory', - await gnosisSafeProxyFactory.getAddress(), - ); - - // Get module proxy factory - moduleProxyFactory = await hre.ethers.getContractAt( - 'ModuleProxyFactory', - await moduleProxyFactory.getAddress(), - ); - createGnosisSetupCalldata = // eslint-disable-next-line camelcase GnosisSafeL2__factory.createInterface().encodeFunctionData('setup', [ diff --git a/test/Azorius-LinearERC20VotingWithHatsProposalCreation.test.ts b/test/Azorius-LinearERC20VotingWithHatsProposalCreation.test.ts new file mode 100644 index 00000000..bad3bf9e --- /dev/null +++ b/test/Azorius-LinearERC20VotingWithHatsProposalCreation.test.ts @@ -0,0 +1,268 @@ +import { SignerWithAddress } from '@nomicfoundation/hardhat-ethers/signers'; +import { expect } from 'chai'; +// eslint-disable-next-line import/no-extraneous-dependencies +import { ethers } from 'ethers'; +import hre from 'hardhat'; + +import { + GnosisSafe, + GnosisSafeProxyFactory, + LinearERC20VotingWithHatsProposalCreation, + LinearERC20VotingWithHatsProposalCreation__factory, + Azorius, + Azorius__factory, + VotesERC20, + VotesERC20__factory, + ModuleProxyFactory, + GnosisSafeL2__factory, +} from '../typechain-types'; + +import { + getGnosisSafeL2Singleton, + getGnosisSafeProxyFactory, + getModuleProxyFactory, +} from './GlobalSafeDeployments.test'; +import { + calculateProxyAddress, + predictGnosisSafeAddress, + buildSafeTransaction, + safeSignTypedData, + buildSignatureBytes, +} from './helpers'; + +describe('LinearERC20VotingWithHatsProposalCreation', () => { + // Deployed contracts + let gnosisSafe: GnosisSafe; + let azorius: Azorius; + let azoriusMastercopy: Azorius; + let linearERC20VotingWithHats: LinearERC20VotingWithHatsProposalCreation; + let linearERC20VotingWithHatsMastercopy: LinearERC20VotingWithHatsProposalCreation; + let votesERC20Mastercopy: VotesERC20; + let votesERC20: VotesERC20; + let gnosisSafeProxyFactory: GnosisSafeProxyFactory; + let moduleProxyFactory: ModuleProxyFactory; + + // Wallets + let deployer: SignerWithAddress; + let gnosisSafeOwner: SignerWithAddress; + + // Gnosis + let createGnosisSetupCalldata: string; + + const saltNum = BigInt('0x856d90216588f9ffc124d1480a440e1c012c7a816952bc968d737bae5d4e139c'); + + beforeEach(async () => { + gnosisSafeProxyFactory = getGnosisSafeProxyFactory(); + moduleProxyFactory = getModuleProxyFactory(); + const gnosisSafeL2Singleton = getGnosisSafeL2Singleton(); + + const abiCoder = new ethers.AbiCoder(); + + [deployer, gnosisSafeOwner] = await hre.ethers.getSigners(); + + createGnosisSetupCalldata = + // eslint-disable-next-line camelcase + GnosisSafeL2__factory.createInterface().encodeFunctionData('setup', [ + [gnosisSafeOwner.address], + 1, + ethers.ZeroAddress, + ethers.ZeroHash, + ethers.ZeroAddress, + ethers.ZeroAddress, + 0, + ethers.ZeroAddress, + ]); + + const predictedGnosisSafeAddress = await predictGnosisSafeAddress( + createGnosisSetupCalldata, + saltNum, + await gnosisSafeL2Singleton.getAddress(), + gnosisSafeProxyFactory, + ); + + // Deploy Gnosis Safe + await gnosisSafeProxyFactory.createProxyWithNonce( + await gnosisSafeL2Singleton.getAddress(), + createGnosisSetupCalldata, + saltNum, + ); + + gnosisSafe = await hre.ethers.getContractAt('GnosisSafe', predictedGnosisSafeAddress); + + // Deploy Votes ERC-20 mastercopy contract + votesERC20Mastercopy = await new VotesERC20__factory(deployer).deploy(); + + const votesERC20SetupCalldata = + // eslint-disable-next-line camelcase + VotesERC20__factory.createInterface().encodeFunctionData('setUp', [ + abiCoder.encode(['string', 'string', 'address[]', 'uint256[]'], ['DCNT', 'DCNT', [], []]), + ]); + + await moduleProxyFactory.deployModule( + await votesERC20Mastercopy.getAddress(), + votesERC20SetupCalldata, + '10031021', + ); + + const predictedVotesERC20Address = await calculateProxyAddress( + moduleProxyFactory, + await votesERC20Mastercopy.getAddress(), + votesERC20SetupCalldata, + '10031021', + ); + + votesERC20 = await hre.ethers.getContractAt('VotesERC20', predictedVotesERC20Address); + // Deploy Azorius module + azoriusMastercopy = await new Azorius__factory(deployer).deploy(); + + const azoriusSetupCalldata = + // eslint-disable-next-line camelcase + Azorius__factory.createInterface().encodeFunctionData('setUp', [ + abiCoder.encode( + ['address', 'address', 'address', 'address[]', 'uint32', 'uint32'], + [ + gnosisSafeOwner.address, + await gnosisSafe.getAddress(), + await gnosisSafe.getAddress(), + [], + 60, // timelock period in blocks + 60, // execution period in blocks + ], + ), + ]); + + await moduleProxyFactory.deployModule( + await azoriusMastercopy.getAddress(), + azoriusSetupCalldata, + '10031021', + ); + + const predictedAzoriusAddress = await calculateProxyAddress( + moduleProxyFactory, + await azoriusMastercopy.getAddress(), + azoriusSetupCalldata, + '10031021', + ); + + azorius = await hre.ethers.getContractAt('Azorius', predictedAzoriusAddress); + + // Deploy LinearERC20VotingWithHatsProposalCreation + linearERC20VotingWithHatsMastercopy = + await new LinearERC20VotingWithHatsProposalCreation__factory(deployer).deploy(); + + const mockHatsContractAddress = '0x1234567890123456789012345678901234567890'; + + const linearERC20VotingWithHatsSetupCalldata = + LinearERC20VotingWithHatsProposalCreation__factory.createInterface().encodeFunctionData( + 'setUp', + [ + abiCoder.encode( + [ + 'address', + 'address', + 'address', + 'uint32', + 'uint256', + 'uint256', + 'address', + 'uint256[]', + ], + [ + gnosisSafeOwner.address, + await votesERC20.getAddress(), + await azorius.getAddress(), + 60, + 500000, + 500000, + mockHatsContractAddress, + [1n], // Use a mock hat ID + ], + ), + ], + ); + + await moduleProxyFactory.deployModule( + await linearERC20VotingWithHatsMastercopy.getAddress(), + linearERC20VotingWithHatsSetupCalldata, + '10031021', + ); + + const predictedLinearERC20VotingWithHatsAddress = await calculateProxyAddress( + moduleProxyFactory, + await linearERC20VotingWithHatsMastercopy.getAddress(), + linearERC20VotingWithHatsSetupCalldata, + '10031021', + ); + + linearERC20VotingWithHats = await hre.ethers.getContractAt( + 'LinearERC20VotingWithHatsProposalCreation', + predictedLinearERC20VotingWithHatsAddress, + ); + + // Enable the strategy on Azorius + await azorius + .connect(gnosisSafeOwner) + .enableStrategy(await linearERC20VotingWithHats.getAddress()); + + // Create transaction on Gnosis Safe to setup Azorius module + const enableAzoriusModuleData = gnosisSafe.interface.encodeFunctionData('enableModule', [ + await azorius.getAddress(), + ]); + + const enableAzoriusModuleTx = buildSafeTransaction({ + to: await gnosisSafe.getAddress(), + data: enableAzoriusModuleData, + safeTxGas: 1000000, + nonce: await gnosisSafe.nonce(), + }); + + const sigs = [await safeSignTypedData(gnosisSafeOwner, gnosisSafe, enableAzoriusModuleTx)]; + + const signatureBytes = buildSignatureBytes(sigs); + + // Execute transaction that adds the Azorius module to the Safe + await expect( + gnosisSafe.execTransaction( + enableAzoriusModuleTx.to, + enableAzoriusModuleTx.value, + enableAzoriusModuleTx.data, + enableAzoriusModuleTx.operation, + enableAzoriusModuleTx.safeTxGas, + enableAzoriusModuleTx.baseGas, + enableAzoriusModuleTx.gasPrice, + enableAzoriusModuleTx.gasToken, + enableAzoriusModuleTx.refundReceiver, + signatureBytes, + ), + ).to.emit(gnosisSafe, 'ExecutionSuccess'); + }); + + it('Gets correctly initialized', async () => { + expect(await linearERC20VotingWithHats.owner()).to.eq(gnosisSafeOwner.address); + expect(await linearERC20VotingWithHats.governanceToken()).to.eq(await votesERC20.getAddress()); + expect(await linearERC20VotingWithHats.azoriusModule()).to.eq(await azorius.getAddress()); + expect(await linearERC20VotingWithHats.hatsContract()).to.eq( + '0x1234567890123456789012345678901234567890', + ); + }); + + it('Cannot call setUp function again', async () => { + const setupParams = ethers.AbiCoder.defaultAbiCoder().encode( + ['address', 'address', 'address', 'uint32', 'uint256', 'uint256', 'address', 'uint256[]'], + [ + gnosisSafeOwner.address, + await votesERC20.getAddress(), + await azorius.getAddress(), + 60, // voting period + 500000, // quorum numerator + 500000, // basis numerator + '0x1234567890123456789012345678901234567890', + [1n], + ], + ); + + await expect(linearERC20VotingWithHats.setUp(setupParams)).to.be.revertedWith( + 'Initializable: contract is already initialized', + ); + }); +}); diff --git a/test/Azorius-LinearERC721VotingWithHatsProposalCreation.test.ts b/test/Azorius-LinearERC721VotingWithHatsProposalCreation.test.ts new file mode 100644 index 00000000..4bb64277 --- /dev/null +++ b/test/Azorius-LinearERC721VotingWithHatsProposalCreation.test.ts @@ -0,0 +1,261 @@ +import { SignerWithAddress } from '@nomicfoundation/hardhat-ethers/signers'; +import { expect } from 'chai'; +// eslint-disable-next-line import/no-extraneous-dependencies +import { ethers } from 'ethers'; +import hre from 'hardhat'; + +import { + GnosisSafe, + GnosisSafeProxyFactory, + LinearERC721VotingWithHatsProposalCreation, + LinearERC721VotingWithHatsProposalCreation__factory, + Azorius, + Azorius__factory, + MockERC721, + MockERC721__factory, + ModuleProxyFactory, + GnosisSafeL2__factory, +} from '../typechain-types'; + +import { + getGnosisSafeL2Singleton, + getGnosisSafeProxyFactory, + getModuleProxyFactory, +} from './GlobalSafeDeployments.test'; +import { + calculateProxyAddress, + predictGnosisSafeAddress, + buildSafeTransaction, + safeSignTypedData, + buildSignatureBytes, +} from './helpers'; + +describe('LinearERC721VotingWithHatsProposalCreation', () => { + // Deployed contracts + let gnosisSafe: GnosisSafe; + let azorius: Azorius; + let azoriusMastercopy: Azorius; + let linearERC721VotingWithHats: LinearERC721VotingWithHatsProposalCreation; + let linearERC721VotingWithHatsMastercopy: LinearERC721VotingWithHatsProposalCreation; + let mockERC721: MockERC721; + let gnosisSafeProxyFactory: GnosisSafeProxyFactory; + let moduleProxyFactory: ModuleProxyFactory; + + // Wallets + let deployer: SignerWithAddress; + let gnosisSafeOwner: SignerWithAddress; + + // Gnosis + let createGnosisSetupCalldata: string; + + const saltNum = BigInt('0x856d90216588f9ffc124d1480a440e1c012c7a816952bc968d737bae5d4e139c'); + + beforeEach(async () => { + gnosisSafeProxyFactory = getGnosisSafeProxyFactory(); + moduleProxyFactory = getModuleProxyFactory(); + const gnosisSafeL2Singleton = getGnosisSafeL2Singleton(); + + const abiCoder = new ethers.AbiCoder(); + + [deployer, gnosisSafeOwner] = await hre.ethers.getSigners(); + + createGnosisSetupCalldata = + // eslint-disable-next-line camelcase + GnosisSafeL2__factory.createInterface().encodeFunctionData('setup', [ + [gnosisSafeOwner.address], + 1, + ethers.ZeroAddress, + ethers.ZeroHash, + ethers.ZeroAddress, + ethers.ZeroAddress, + 0, + ethers.ZeroAddress, + ]); + + const predictedGnosisSafeAddress = await predictGnosisSafeAddress( + createGnosisSetupCalldata, + saltNum, + await gnosisSafeL2Singleton.getAddress(), + gnosisSafeProxyFactory, + ); + + // Deploy Gnosis Safe + await gnosisSafeProxyFactory.createProxyWithNonce( + await gnosisSafeL2Singleton.getAddress(), + createGnosisSetupCalldata, + saltNum, + ); + + gnosisSafe = await hre.ethers.getContractAt('GnosisSafe', predictedGnosisSafeAddress); + + // Deploy MockERC721 contract + mockERC721 = await new MockERC721__factory(deployer).deploy(); + + // Deploy Azorius module + azoriusMastercopy = await new Azorius__factory(deployer).deploy(); + + const azoriusSetupCalldata = + // eslint-disable-next-line camelcase + Azorius__factory.createInterface().encodeFunctionData('setUp', [ + abiCoder.encode( + ['address', 'address', 'address', 'address[]', 'uint32', 'uint32'], + [ + gnosisSafeOwner.address, + await gnosisSafe.getAddress(), + await gnosisSafe.getAddress(), + [], + 60, // timelock period in blocks + 60, // execution period in blocks + ], + ), + ]); + + await moduleProxyFactory.deployModule( + await azoriusMastercopy.getAddress(), + azoriusSetupCalldata, + '10031021', + ); + + const predictedAzoriusAddress = await calculateProxyAddress( + moduleProxyFactory, + await azoriusMastercopy.getAddress(), + azoriusSetupCalldata, + '10031021', + ); + + azorius = await hre.ethers.getContractAt('Azorius', predictedAzoriusAddress); + + // Deploy LinearERC721VotingWithHatsProposalCreation + linearERC721VotingWithHatsMastercopy = + await new LinearERC721VotingWithHatsProposalCreation__factory(deployer).deploy(); + + const mockHatsContractAddress = '0x1234567890123456789012345678901234567890'; + + const linearERC721VotingWithHatsSetupCalldata = + LinearERC721VotingWithHatsProposalCreation__factory.createInterface().encodeFunctionData( + 'setUp', + [ + abiCoder.encode( + [ + 'address', + 'address[]', + 'uint256[]', + 'address', + 'uint32', + 'uint256', + 'uint256', + 'address', + 'uint256[]', + ], + [ + gnosisSafeOwner.address, + [await mockERC721.getAddress()], + [1], // weight for the ERC721 token + await azorius.getAddress(), + 60, // voting period + 500000, // quorum threshold + 500000, // basis numerator + mockHatsContractAddress, + [1n], // Use a mock hat ID + ], + ), + ], + ); + + await moduleProxyFactory.deployModule( + await linearERC721VotingWithHatsMastercopy.getAddress(), + linearERC721VotingWithHatsSetupCalldata, + '10031021', + ); + + const predictedLinearERC721VotingWithHatsAddress = await calculateProxyAddress( + moduleProxyFactory, + await linearERC721VotingWithHatsMastercopy.getAddress(), + linearERC721VotingWithHatsSetupCalldata, + '10031021', + ); + + linearERC721VotingWithHats = await hre.ethers.getContractAt( + 'LinearERC721VotingWithHatsProposalCreation', + predictedLinearERC721VotingWithHatsAddress, + ); + + // Enable the strategy on Azorius + await azorius + .connect(gnosisSafeOwner) + .enableStrategy(await linearERC721VotingWithHats.getAddress()); + + // Create transaction on Gnosis Safe to setup Azorius module + const enableAzoriusModuleData = gnosisSafe.interface.encodeFunctionData('enableModule', [ + await azorius.getAddress(), + ]); + + const enableAzoriusModuleTx = buildSafeTransaction({ + to: await gnosisSafe.getAddress(), + data: enableAzoriusModuleData, + safeTxGas: 1000000, + nonce: await gnosisSafe.nonce(), + }); + + const sigs = [await safeSignTypedData(gnosisSafeOwner, gnosisSafe, enableAzoriusModuleTx)]; + + const signatureBytes = buildSignatureBytes(sigs); + + // Execute transaction that adds the Azorius module to the Safe + await expect( + gnosisSafe.execTransaction( + enableAzoriusModuleTx.to, + enableAzoriusModuleTx.value, + enableAzoriusModuleTx.data, + enableAzoriusModuleTx.operation, + enableAzoriusModuleTx.safeTxGas, + enableAzoriusModuleTx.baseGas, + enableAzoriusModuleTx.gasPrice, + enableAzoriusModuleTx.gasToken, + enableAzoriusModuleTx.refundReceiver, + signatureBytes, + ), + ).to.emit(gnosisSafe, 'ExecutionSuccess'); + }); + + it('Gets correctly initialized', async () => { + expect(await linearERC721VotingWithHats.owner()).to.eq(gnosisSafeOwner.address); + expect(await linearERC721VotingWithHats.tokenAddresses(0)).to.eq(await mockERC721.getAddress()); + expect(await linearERC721VotingWithHats.tokenWeights(await mockERC721.getAddress())).to.eq(1); + expect(await linearERC721VotingWithHats.azoriusModule()).to.eq(await azorius.getAddress()); + expect(await linearERC721VotingWithHats.hatsContract()).to.eq( + '0x1234567890123456789012345678901234567890', + ); + }); + + it('Cannot call setUp function again', async () => { + const setupParams = ethers.AbiCoder.defaultAbiCoder().encode( + [ + 'address', + 'address[]', + 'uint256[]', + 'address', + 'uint32', + 'uint256', + 'uint256', + 'address', + 'uint256[]', + ], + [ + gnosisSafeOwner.address, + [await mockERC721.getAddress()], + [1], + await azorius.getAddress(), + 60, + 500000, + 500000, + '0x1234567890123456789012345678901234567890', + [1n], + ], + ); + + await expect(linearERC721VotingWithHats.setUp(setupParams)).to.be.revertedWith( + 'Initializable: contract is already initialized', + ); + }); +}); diff --git a/test/HatsProposalCreationWhitelist.test.ts b/test/HatsProposalCreationWhitelist.test.ts new file mode 100644 index 00000000..f6562141 --- /dev/null +++ b/test/HatsProposalCreationWhitelist.test.ts @@ -0,0 +1,180 @@ +import { SignerWithAddress } from '@nomicfoundation/hardhat-ethers/signers'; +import { expect } from 'chai'; +/* eslint-disable-next-line import/no-extraneous-dependencies */ +import { ethers } from 'ethers'; +import hre from 'hardhat'; + +import { + MockHatsProposalCreationWhitelist, + MockHatsProposalCreationWhitelist__factory, + MockHats, + MockHats__factory, +} from '../typechain-types'; +import { topHatIdToHatId } from './helpers'; + +describe('HatsProposalCreationWhitelist', () => { + let mockHatsProposalCreationWhitelist: MockHatsProposalCreationWhitelist; + let hatsProtocol: MockHats; + + let deployer: SignerWithAddress; + let owner: SignerWithAddress; + let hatWearer1: SignerWithAddress; + let hatWearer2: SignerWithAddress; + + let proposerHatId: bigint; + let nonProposerHatId: bigint; + + beforeEach(async () => { + [deployer, owner, hatWearer1, hatWearer2] = await hre.ethers.getSigners(); + + // Deploy Hats mock contract + hatsProtocol = await new MockHats__factory(deployer).deploy(); + + // Mint the top hat + const topHatId = topHatIdToHatId((await hatsProtocol.lastTopHatId()) + 1n); + await hatsProtocol.mintTopHat(deployer.address, '', ''); + + // Create and mint hats for testing + proposerHatId = await hatsProtocol.getNextId(topHatId); + await hatsProtocol.createHat( + topHatId, + 'Proposer Hat', + 1, + deployer.address, + deployer.address, + true, + '', + ); + await hatsProtocol.mintHat(proposerHatId, hatWearer1.address); + + nonProposerHatId = await hatsProtocol.getNextId(topHatId); + await hatsProtocol.createHat( + topHatId, + 'Non-Proposer Hat', + 1, + deployer.address, + deployer.address, + true, + '', + ); + await hatsProtocol.mintHat(nonProposerHatId, hatWearer2.address); + + // Deploy MockHatsProposalCreationWhitelist + mockHatsProposalCreationWhitelist = await new MockHatsProposalCreationWhitelist__factory( + deployer, + ).deploy(); + + // Initialize the contract + await mockHatsProposalCreationWhitelist.setUp( + ethers.AbiCoder.defaultAbiCoder().encode( + ['address', 'uint256[]'], + [await hatsProtocol.getAddress(), [proposerHatId]], + ), + ); + + // Transfer ownership to the owner + await mockHatsProposalCreationWhitelist.transferOwnership(owner.address); + }); + + it('Gets correctly initialized', async () => { + expect(await mockHatsProposalCreationWhitelist.owner()).to.eq(owner.address); + expect(await mockHatsProposalCreationWhitelist.hatsContract()).to.eq( + await hatsProtocol.getAddress(), + ); + expect( + (await mockHatsProposalCreationWhitelist.getWhitelistedHatIds()).includes(proposerHatId), + ).to.equal(true); + }); + + it('Cannot call setUp function again', async () => { + const setupParams = ethers.AbiCoder.defaultAbiCoder().encode( + ['address', 'uint256[]'], + [await hatsProtocol.getAddress(), [proposerHatId]], + ); + + await expect(mockHatsProposalCreationWhitelist.setUp(setupParams)).to.be.revertedWith( + 'Initializable: contract is already initialized', + ); + }); + + it('Cannot initialize with no whitelisted hats', async () => { + const mockHatsProposalCreationWhitelistFactory = new MockHatsProposalCreationWhitelist__factory( + deployer, + ); + const newMockContract = await mockHatsProposalCreationWhitelistFactory.deploy(); + + const setupParams = ethers.AbiCoder.defaultAbiCoder().encode( + ['address', 'uint256[]'], + [await hatsProtocol.getAddress(), []], + ); + + await expect(newMockContract.setUp(setupParams)).to.be.revertedWithCustomError( + newMockContract, + 'NoHatsWhitelisted', + ); + }); + + it('Only owner can whitelist a hat', async () => { + await expect(mockHatsProposalCreationWhitelist.connect(owner).whitelistHat(nonProposerHatId)) + .to.emit(mockHatsProposalCreationWhitelist, 'HatWhitelisted') + .withArgs(nonProposerHatId); + + await expect( + mockHatsProposalCreationWhitelist.connect(hatWearer1).whitelistHat(nonProposerHatId), + ).to.be.revertedWith('Ownable: caller is not the owner'); + }); + + it('Only owner can remove a hat from whitelist', async () => { + await expect( + mockHatsProposalCreationWhitelist.connect(owner).removeHatFromWhitelist(proposerHatId), + ) + .to.emit(mockHatsProposalCreationWhitelist, 'HatRemovedFromWhitelist') + .withArgs(proposerHatId); + + await expect( + mockHatsProposalCreationWhitelist.connect(hatWearer1).removeHatFromWhitelist(proposerHatId), + ).to.be.revertedWith('Ownable: caller is not the owner'); + }); + + it('Correctly identifies proposers based on whitelisted hats', async () => { + expect(await mockHatsProposalCreationWhitelist.isProposer(hatWearer1.address)).to.equal(true); + expect(await mockHatsProposalCreationWhitelist.isProposer(hatWearer2.address)).to.equal(false); + + await mockHatsProposalCreationWhitelist.connect(owner).whitelistHat(nonProposerHatId); + + expect(await mockHatsProposalCreationWhitelist.isProposer(hatWearer2.address)).to.equal(true); + }); + + it('Returns correct number of whitelisted hats', async () => { + expect((await mockHatsProposalCreationWhitelist.getWhitelistedHatIds()).length).to.equal(1); + + await mockHatsProposalCreationWhitelist.connect(owner).whitelistHat(nonProposerHatId); + + expect((await mockHatsProposalCreationWhitelist.getWhitelistedHatIds()).length).to.equal(2); + + await mockHatsProposalCreationWhitelist.connect(owner).removeHatFromWhitelist(proposerHatId); + + expect((await mockHatsProposalCreationWhitelist.getWhitelistedHatIds()).length).to.equal(1); + }); + + it('Correctly checks if a hat is whitelisted', async () => { + expect( + (await mockHatsProposalCreationWhitelist.getWhitelistedHatIds()).includes(proposerHatId), + ).to.equal(true); + expect( + (await mockHatsProposalCreationWhitelist.getWhitelistedHatIds()).includes(nonProposerHatId), + ).to.equal(false); + + await mockHatsProposalCreationWhitelist.connect(owner).whitelistHat(nonProposerHatId); + + expect( + (await mockHatsProposalCreationWhitelist.getWhitelistedHatIds()).includes(nonProposerHatId), + ).to.equal(true); + + await mockHatsProposalCreationWhitelist.connect(owner).removeHatFromWhitelist(proposerHatId); + + expect( + (await mockHatsProposalCreationWhitelist.getWhitelistedHatIds()).includes(proposerHatId), + ).to.equal(false); + }); +});