diff --git a/packages/contracts/deploy/20_new_version/23_publish.ts b/packages/contracts/deploy/20_new_version/23_publish.ts index 7c9510f9..28020a74 100644 --- a/packages/contracts/deploy/20_new_version/23_publish.ts +++ b/packages/contracts/deploy/20_new_version/23_publish.ts @@ -142,8 +142,12 @@ const func: DeployFunction = async function (hre: HardhatRuntimeEnvironment) { createVersion: { _release: VERSION.release, _pluginSetup: setup.address, - _buildMetadata: toHex(buildMetadataURI), - _releaseMetadata: toHex(releaseMetadataURI), + _buildMetadata: ethers.utils.hexlify( + ethers.utils.toUtf8Bytes(buildMetadataURI) + ), + _releaseMetadata: ethers.utils.hexlify( + ethers.utils.toUtf8Bytes(releaseMetadataURI) + ), }, }, ], diff --git a/packages/contracts/hardhat.config.ts b/packages/contracts/hardhat.config.ts index 596fb73a..1f7e130f 100644 --- a/packages/contracts/hardhat.config.ts +++ b/packages/contracts/hardhat.config.ts @@ -156,7 +156,7 @@ const config: HardhatUserConfig = { deploy: './deploy', }, mocha: { - timeout: 60_000, + timeout: 6000000, }, solidity: { version: '0.8.17', diff --git a/packages/contracts/src/IMajorityVoting.sol b/packages/contracts/src/IMajorityVoting.sol index e2aa66d1..f3e73834 100644 --- a/packages/contracts/src/IMajorityVoting.sol +++ b/packages/contracts/src/IMajorityVoting.sol @@ -36,6 +36,10 @@ interface IMajorityVoting { /// @return The support threshold parameter. function supportThreshold() external view returns (uint32); + /// @notice Returns the min approval value stored configured. + /// @return The minimal approval value. + function minApproval() external view returns (uint256); + /// @notice Returns the minimum participation parameter stored in the voting settings. /// @return The minimum participation parameter. function minParticipation() external view returns (uint32); @@ -61,6 +65,13 @@ interface IMajorityVoting { /// @return Returns `true` if the participation is greater than the minimum participation and `false` otherwise. function isMinParticipationReached(uint256 _proposalId) external view returns (bool); + /// @notice Checks if the min approval value defined as: + ///$$\texttt{minApproval} = \frac{N_\text{yes}}{N_\text{total}}$$ + /// for a proposal vote is greater or equal than the minimum approval value. + /// @param _proposalId The ID of the proposal. + /// @return Returns `true` if the participation is greater than the minimum participation and `false` otherwise. + function isMinApprovalReached(uint256 _proposalId) external view returns (bool); + /// @notice Checks if an account can participate on a proposal vote. This can be because the vote /// - has not started, /// - has ended, diff --git a/packages/contracts/src/MajorityVotingBase.sol b/packages/contracts/src/MajorityVotingBase.sol index d59b25cb..23037f6c 100644 --- a/packages/contracts/src/MajorityVotingBase.sol +++ b/packages/contracts/src/MajorityVotingBase.sol @@ -12,6 +12,9 @@ import {ProposalUpgradeable} from "@aragon/osx-commons-contracts/src/plugin/exte import {RATIO_BASE, RatioOutOfBounds} from "@aragon/osx-commons-contracts/src/utils/math/Ratio.sol"; import {PluginUUPSUpgradeable} from "@aragon/osx-commons-contracts/src/plugin/PluginUUPSUpgradeable.sol"; import {IDAO} from "@aragon/osx-commons-contracts/src/dao/IDAO.sol"; +import {IProposal} from "@aragon/osx-commons-contracts/src/plugin/extensions/proposal/IProposal.sol"; +import {Action} from "@aragon/osx-commons-contracts/src/executors/IExecutor.sol"; +import {MetadataExtensionUpgradeable} from "@aragon/osx-commons-contracts/src/utils/metadata/MetadataExtensionUpgradeable.sol"; import {IMajorityVoting} from "./IMajorityVoting.sol"; @@ -120,6 +123,7 @@ abstract contract MajorityVotingBase is IMajorityVoting, Initializable, ERC165Upgradeable, + MetadataExtensionUpgradeable, PluginUUPSUpgradeable, ProposalUpgradeable { @@ -165,6 +169,7 @@ abstract contract MajorityVotingBase is /// @param voters The votes casted by the voters. /// @param actions The actions to be executed when the proposal passes. /// @param allowFailureMap A bitmap allowing the proposal to succeed, even if individual actions might revert. + /// @param minApprovalPower The minimum amount of yes votes power needed for the proposal advance. /// If the bit at index `i` is 1, the proposal succeeds even if the `i`th action reverts. /// A failure map value of 0 requires every action to not revert. struct Proposal { @@ -172,8 +177,10 @@ abstract contract MajorityVotingBase is ProposalParameters parameters; Tally tally; mapping(address => IMajorityVoting.VoteOption) voters; - IDAO.Action[] actions; + Action[] actions; uint256 allowFailureMap; + uint256 minApprovalPower; + TargetConfig targetConfig; // added in v1.3 } /// @notice A container for the proposal parameters at the time of proposal creation. @@ -211,12 +218,24 @@ abstract contract MajorityVotingBase is this.totalVotingPower.selector ^ this.getProposal.selector ^ this.updateVotingSettings.selector ^ - this.createProposal.selector; + this.updateMinApprovals.selector ^ + bytes4( + keccak256( + "createProposal(bytes,(address,uint256,bytes)[],uint256,uint64,uint64,uint8,bool)" + ) + ); /// @notice The ID of the permission required to call the `updateVotingSettings` function. bytes32 public constant UPDATE_VOTING_SETTINGS_PERMISSION_ID = keccak256("UPDATE_VOTING_SETTINGS_PERMISSION"); + /// @notice The ID of the permission required to call the `createProposal` functions. + bytes32 public constant CREATE_PROPOSAL_PERMISSION_ID = keccak256("CREATE_PROPOSAL_PERMISSION"); + + /// @notice The ID of the permission required to call the `execute` function. + bytes32 public constant EXECUTE_PROPOSAL_PERMISSION_ID = + keccak256("EXECUTE_PROPOSAL_PERMISSION"); + /// @notice A mapping between proposal IDs and proposal information. // solhint-disable-next-line named-parameters-mapping mapping(uint256 => Proposal) internal proposals; @@ -224,6 +243,10 @@ abstract contract MajorityVotingBase is /// @notice The struct storing the voting settings. VotingSettings private votingSettings; + /// @notice The minimal ratio of yes votes needed for a proposal succeed. + /// @dev is not on the VotingSettings for compatibility reasons. + uint256 private minApprovals; // added in v1.3 + /// @notice Thrown if a date is out of bounds. /// @param limit The limit value. /// @param actual The actual value. @@ -238,6 +261,10 @@ abstract contract MajorityVotingBase is /// @param sender The sender address. error ProposalCreationForbidden(address sender); + /// @notice Thrown when a proposal doesn't exist. + /// @param proposalId The ID of the proposal which doesn't exist. + error NonexistentProposal(uint256 proposalId); + /// @notice Thrown if an account is not allowed to cast a vote. This can be because the vote /// - has not started, /// - has ended, @@ -252,6 +279,10 @@ abstract contract MajorityVotingBase is /// @param proposalId The ID of the proposal. error ProposalExecutionForbidden(uint256 proposalId); + /// @notice Thrown if the proposal with same actions and metadata already exists. + /// @param proposalId The id of the proposal. + error ProposalAlreadyExists(uint256 proposalId); + /// @notice Emitted when the voting settings are updated. /// @param votingMode A parameter to select the vote mode. /// @param supportThreshold The support threshold value. @@ -266,6 +297,10 @@ abstract contract MajorityVotingBase is uint256 minProposerVotingPower ); + /// @notice Emitted when the min approval value is updated. + /// @param minApprovals The minimum amount of yes votes needed for a proposal succeed. + event VotingMinApprovalUpdated(uint256 minApprovals); + /// @notice Initializes the component to be used by inheriting contracts. /// @dev This method is required to support [ERC-1822](https://eips.ethereum.org/EIPS/eip-1822). /// @param _dao The IDAO interface of the associated DAO. @@ -273,10 +308,16 @@ abstract contract MajorityVotingBase is // solhint-disable-next-line func-name-mixedcase function __MajorityVotingBase_init( IDAO _dao, - VotingSettings calldata _votingSettings + VotingSettings calldata _votingSettings, + TargetConfig calldata _targetConfig, + uint256 _minApprovals, + bytes calldata _pluginMetadata ) internal onlyInitializing { __PluginUUPSUpgradeable_init(_dao); _updateVotingSettings(_votingSettings); + _updateMinApprovals(_minApprovals); + _setTargetConfig(_targetConfig); + _setMetadata(_pluginMetadata); } /// @notice Checks if this or the parent contract supports an interface by its ID. @@ -288,12 +329,25 @@ abstract contract MajorityVotingBase is public view virtual - override(ERC165Upgradeable, PluginUUPSUpgradeable, ProposalUpgradeable) + override( + ERC165Upgradeable, + MetadataExtensionUpgradeable, + PluginUUPSUpgradeable, + ProposalUpgradeable + ) returns (bool) { + // In addition to the current IMajorityVoting interface, also support previous version + // that did not include the `isMinApprovalReached` and `minApproval` functions, same + // happens with MAJORITY_VOTING_BASE_INTERFACE which did not include `updateMinApprovals`. return _interfaceId == MAJORITY_VOTING_BASE_INTERFACE_ID || + _interfaceId == MAJORITY_VOTING_BASE_INTERFACE_ID ^ this.updateMinApprovals.selector || _interfaceId == type(IMajorityVoting).interfaceId || + _interfaceId == + type(IMajorityVoting).interfaceId ^ + this.isMinApprovalReached.selector ^ + this.minApproval.selector || super.supportsInterface(_interfaceId); } @@ -316,7 +370,7 @@ abstract contract MajorityVotingBase is } /// @inheritdoc IMajorityVoting - function execute(uint256 _proposalId) public virtual { + function execute(uint256 _proposalId) public virtual auth(EXECUTE_PROPOSAL_PERMISSION_ID) { if (!_canExecute(_proposalId)) { revert ProposalExecutionForbidden(_proposalId); } @@ -337,14 +391,33 @@ abstract contract MajorityVotingBase is address _voter, VoteOption _voteOption ) public view virtual returns (bool) { + if (!_proposalExists(_proposalId)) { + revert NonexistentProposal(_proposalId); + } + return _canVote(_proposalId, _voter, _voteOption); } /// @inheritdoc IMajorityVoting - function canExecute(uint256 _proposalId) public view virtual returns (bool) { + function canExecute( + uint256 _proposalId + ) public view virtual override(IMajorityVoting) returns (bool) { + if (!_proposalExists(_proposalId)) { + revert NonexistentProposal(_proposalId); + } + return _canExecute(_proposalId); } + /// @inheritdoc IProposal + function hasSucceeded(uint256 _proposalId) public view virtual returns (bool) { + if (!_proposalExists(_proposalId)) { + revert NonexistentProposal(_proposalId); + } + + return _hasSucceeded(_proposalId); + } + /// @inheritdoc IMajorityVoting function isSupportThresholdReached(uint256 _proposalId) public view virtual returns (bool) { Proposal storage proposal_ = proposals[_proposalId]; @@ -386,6 +459,16 @@ abstract contract MajorityVotingBase is proposal_.parameters.minVotingPower; } + /// @inheritdoc IMajorityVoting + function isMinApprovalReached(uint256 _proposalId) public view virtual returns (bool) { + return proposals[_proposalId].tally.yes >= proposals[_proposalId].minApprovalPower; + } + + /// @inheritdoc IMajorityVoting + function minApproval() public view virtual returns (uint256) { + return minApprovals; + } + /// @inheritdoc IMajorityVoting function supportThreshold() public view virtual returns (uint32) { return votingSettings.supportThreshold; @@ -438,7 +521,7 @@ abstract contract MajorityVotingBase is bool executed, ProposalParameters memory parameters, Tally memory tally, - IDAO.Action[] memory actions, + Action[] memory actions, uint256 allowFailureMap ) { @@ -460,6 +543,14 @@ abstract contract MajorityVotingBase is _updateVotingSettings(_votingSettings); } + /// @notice Updates the minimal approval value. + /// @param _minApprovals The new minimal approval value. + function updateMinApprovals( + uint256 _minApprovals + ) external virtual auth(UPDATE_VOTING_SETTINGS_PERMISSION_ID) { + _updateMinApprovals(_minApprovals); + } + /// @notice Creates a new majority voting proposal. /// @param _metadata The metadata of the proposal. /// @param _actions The actions that will be executed after the proposal passes. @@ -477,7 +568,7 @@ abstract contract MajorityVotingBase is /// @return proposalId The ID of the proposal. function createProposal( bytes calldata _metadata, - IDAO.Action[] calldata _actions, + Action[] calldata _actions, uint256 _allowFailureMap, uint64 _startDate, uint64 _endDate, @@ -500,14 +591,19 @@ abstract contract MajorityVotingBase is /// @notice Internal function to execute a vote. It assumes the queried proposal exists. /// @param _proposalId The ID of the proposal. function _execute(uint256 _proposalId) internal virtual { - proposals[_proposalId].executed = true; + Proposal storage proposal_ = proposals[_proposalId]; + + proposal_.executed = true; - _executeProposal( - dao(), - _proposalId, - proposals[_proposalId].actions, - proposals[_proposalId].allowFailureMap + _execute( + proposal_.targetConfig.target, + bytes32(_proposalId), + proposal_.actions, + proposal_.allowFailureMap, + proposal_.targetConfig.operation ); + + emit ProposalExecuted(_proposalId); } /// @notice Internal function to check if a voter can vote. It assumes the queried proposal exists. @@ -521,18 +617,12 @@ abstract contract MajorityVotingBase is VoteOption _voteOption ) internal view virtual returns (bool); - /// @notice Internal function to check if a proposal can be executed. It assumes the queried proposal exists. + /// @notice An internal function that checks if the proposal succeeded or not. /// @param _proposalId The ID of the proposal. - /// @return True if the proposal can be executed, false otherwise. - /// @dev Threshold and minimal values are compared with `>` and `>=` comparators, respectively. - function _canExecute(uint256 _proposalId) internal view virtual returns (bool) { + /// @return Returns `true` if the proposal succeeded depending on the thresholds and voting modes. + function _hasSucceeded(uint256 _proposalId) internal view virtual returns (bool) { Proposal storage proposal_ = proposals[_proposalId]; - // Verify that the vote has not been executed already. - if (proposal_.executed) { - return false; - } - if (_isProposalOpen(proposal_)) { // Early execution if (proposal_.parameters.votingMode != VotingMode.EarlyExecution) { @@ -550,10 +640,28 @@ abstract contract MajorityVotingBase is if (!isMinParticipationReached(_proposalId)) { return false; } + if (!isMinApprovalReached(_proposalId)) { + return false; + } return true; } + /// @notice Internal function to check if a proposal can be executed. It assumes the queried proposal exists. + /// @param _proposalId The ID of the proposal. + /// @return True if the proposal can be executed, false otherwise. + /// @dev Threshold and minimal values are compared with `>` and `>=` comparators, respectively. + function _canExecute(uint256 _proposalId) internal view virtual returns (bool) { + Proposal storage proposal_ = proposals[_proposalId]; + + // Verify that the vote has not been executed already. + if (proposal_.executed) { + return false; + } + + return _hasSucceeded(_proposalId); + } + /// @notice Internal function to check if a proposal vote is still open. /// @param proposal_ The proposal struct. /// @return True if the proposal vote is open, false otherwise. @@ -570,7 +678,7 @@ abstract contract MajorityVotingBase is /// @param _votingSettings The voting settings to be validated and updated. function _updateVotingSettings(VotingSettings calldata _votingSettings) internal virtual { // Require the support threshold value to be in the interval [0, 10^6-1], - // because `>` comparision is used in the support criterion and >100% could never be reached. + // because `>` comparison is used in the support criterion and >100% could never be reached. if (_votingSettings.supportThreshold > RATIO_BASE - 1) { revert RatioOutOfBounds({ limit: RATIO_BASE - 1, @@ -579,7 +687,7 @@ abstract contract MajorityVotingBase is } // Require the minimum participation value to be in the interval [0, 10^6], - // because `>=` comparision is used in the participation criterion. + // because `>=` comparison is used in the participation criterion. if (_votingSettings.minParticipation > RATIO_BASE) { revert RatioOutOfBounds({limit: RATIO_BASE, actual: _votingSettings.minParticipation}); } @@ -603,6 +711,26 @@ abstract contract MajorityVotingBase is }); } + /// @notice Checks if proposal exists or not. + /// @param _proposalId The ID of the proposal. + /// @return Returns `true` if proposal exists, otherwise false. + function _proposalExists(uint256 _proposalId) private view returns (bool) { + return proposals[_proposalId].parameters.snapshotBlock != 0; + } + + /// @notice Internal function to update minimal approval value. + /// @param _minApprovals The new minimal approval value. + function _updateMinApprovals(uint256 _minApprovals) internal virtual { + // Require the minimum approval value to be in the interval [0, 10^6], + // because `>=` comparison is used in the participation criterion. + if (_minApprovals > RATIO_BASE) { + revert RatioOutOfBounds({limit: RATIO_BASE, actual: _minApprovals}); + } + + minApprovals = _minApprovals; + emit VotingMinApprovalUpdated(_minApprovals); + } + /// @notice Validates and returns the proposal vote dates. /// @param _start The start date of the proposal vote. /// If 0, the current timestamp is used and the vote starts immediately. @@ -644,5 +772,5 @@ abstract contract MajorityVotingBase is /// new variables without shifting down storage in the inheritance chain /// (see [OpenZeppelin's guide about storage gaps] /// (https://docs.openzeppelin.com/contracts/4.x/upgradeable#storage_gaps)). - uint256[47] private __gap; + uint256[46] private __gap; } diff --git a/packages/contracts/src/TokenVoting.sol b/packages/contracts/src/TokenVoting.sol index 95a061a0..02267ccd 100644 --- a/packages/contracts/src/TokenVoting.sol +++ b/packages/contracts/src/TokenVoting.sol @@ -10,6 +10,9 @@ import {IMembership} from "@aragon/osx-commons-contracts/src/plugin/extensions/m import {_applyRatioCeiled} from "@aragon/osx-commons-contracts/src/utils/math/Ratio.sol"; import {IDAO} from "@aragon/osx-commons-contracts/src/dao/IDAO.sol"; +import {IProposal} from "@aragon/osx-commons-contracts/src/plugin/extensions/proposal/IProposal.sol"; +import {Action} from "@aragon/osx-commons-contracts/src/executors/IExecutor.sol"; + import {MajorityVotingBase} from "./MajorityVotingBase.sol"; /// @title TokenVoting @@ -17,14 +20,13 @@ import {MajorityVotingBase} from "./MajorityVotingBase.sol"; /// @notice The majority voting implementation using an /// [OpenZeppelin `Votes`](https://docs.openzeppelin.com/contracts/4.x/api/governance#Votes) /// compatible governance token. -/// @dev v1.3 (Release 1, Build 3) +/// @dev v1.3 (Release 1, Build 3). For each upgrade, if the reinitialization step is required, increment the version numbers in the modifier for both the initialize and initializeFrom functions. /// @custom:security-contact sirt@aragon.org contract TokenVoting is IMembership, MajorityVotingBase { using SafeCastUpgradeable for uint256; /// @notice The [ERC-165](https://eips.ethereum.org/EIPS/eip-165) interface ID of the contract. - bytes4 internal constant TOKEN_VOTING_INTERFACE_ID = - this.initialize.selector ^ this.getVotingToken.selector; + bytes4 internal constant TOKEN_VOTING_INTERFACE_ID = this.getVotingToken.selector; /// @notice An [OpenZeppelin `Votes`](https://docs.openzeppelin.com/contracts/4.x/api/governance#Votes) /// compatible contract referencing the token being used for voting. @@ -38,18 +40,48 @@ contract TokenVoting is IMembership, MajorityVotingBase { /// @param _dao The IDAO interface of the associated DAO. /// @param _votingSettings The voting settings. /// @param _token The [ERC-20](https://eips.ethereum.org/EIPS/eip-20) token used for voting. + /// @param _minApprovals The minimal amount of approvals the proposal needs to succeed. function initialize( IDAO _dao, VotingSettings calldata _votingSettings, - IVotesUpgradeable _token - ) external initializer { - __MajorityVotingBase_init(_dao, _votingSettings); + IVotesUpgradeable _token, + TargetConfig calldata _targetConfig, + uint256 _minApprovals, + bytes calldata _pluginMetadata + ) external onlyCallAtInitialization reinitializer(2) { + __MajorityVotingBase_init( + _dao, + _votingSettings, + _targetConfig, + _minApprovals, + _pluginMetadata + ); votingToken = _token; emit MembershipContractAnnounced({definingContract: address(_token)}); } + /// @notice Reinitializes the TokenVoting after an upgrade from a previous protocol version.For each reinitialization step, use the `_fromBuild` version to decide which internal functions to call for reinitialization. + /// @dev WARNING: The contract should only be upgradeable through PSP to ensure that _fromBuild is not incorrectly passed, and that the appropriate permissions for the upgrade are properly configured. + /// @param _fromBuild The build version number of the previous implementation contract this upgrade is transitioning from. + /// @param _initData The initialization data to be passed to via `upgradeToAndCall` (see [ERC-1967](https://docs.openzeppelin.com/contracts/4.x/api/proxy#ERC1967Upgrade)). + function initializeFrom(uint16 _fromBuild, bytes calldata _initData) external reinitializer(2) { + if (_fromBuild < 3) { + ( + uint256 minApprovals, + TargetConfig memory targetConfig, + bytes memory pluginMetadata + ) = abi.decode(_initData, (uint256, TargetConfig, bytes)); + + _updateMinApprovals(minApprovals); + + _setTargetConfig(targetConfig); + + _setMetadata(pluginMetadata); + } + } + /// @notice Checks if this or the parent contract supports an interface by its ID. /// @param _interfaceId The ID of the interface. /// @return Returns `true` if the interface is supported. @@ -76,30 +108,13 @@ contract TokenVoting is IMembership, MajorityVotingBase { /// @inheritdoc MajorityVotingBase function createProposal( bytes calldata _metadata, - IDAO.Action[] calldata _actions, + Action[] calldata _actions, uint256 _allowFailureMap, uint64 _startDate, uint64 _endDate, VoteOption _voteOption, bool _tryEarlyExecution - ) external override returns (uint256 proposalId) { - // Check that either `_msgSender` owns enough tokens or has enough voting power from being a delegatee. - { - uint256 minProposerVotingPower_ = minProposerVotingPower(); - - if (minProposerVotingPower_ != 0) { - // Because of the checks in `TokenVotingSetup`, we can assume that `votingToken` - // is an [ERC-20](https://eips.ethereum.org/EIPS/eip-20) token. - if ( - votingToken.getVotes(_msgSender()) < minProposerVotingPower_ && - IERC20Upgradeable(address(votingToken)).balanceOf(_msgSender()) < - minProposerVotingPower_ - ) { - revert ProposalCreationForbidden(_msgSender()); - } - } - } - + ) public override auth(CREATE_PROPOSAL_PERMISSION_ID) returns (uint256 proposalId) { uint256 snapshotBlock; unchecked { // The snapshot block must be mined already to @@ -115,18 +130,15 @@ contract TokenVoting is IMembership, MajorityVotingBase { (_startDate, _endDate) = _validateProposalDates(_startDate, _endDate); - proposalId = _createProposal({ - _creator: _msgSender(), - _metadata: _metadata, - _startDate: _startDate, - _endDate: _endDate, - _actions: _actions, - _allowFailureMap: _allowFailureMap - }); + proposalId = _createProposalId(keccak256(abi.encode(_actions, _metadata))); // Store proposal related information Proposal storage proposal_ = proposals[proposalId]; + if (proposal_.parameters.snapshotBlock != 0) { + revert ProposalAlreadyExists(proposalId); + } + proposal_.parameters.startDate = _startDate; proposal_.parameters.endDate = _endDate; proposal_.parameters.snapshotBlock = snapshotBlock.toUint64(); @@ -137,6 +149,10 @@ contract TokenVoting is IMembership, MajorityVotingBase { minParticipation() ); + proposal_.minApprovalPower = _applyRatioCeiled(totalVotingPower_, minApproval()); + + proposal_.targetConfig = getTargetConfig(); + // Reduce costs if (_allowFailureMap != 0) { proposal_.allowFailureMap = _allowFailureMap; @@ -152,6 +168,51 @@ contract TokenVoting is IMembership, MajorityVotingBase { if (_voteOption != VoteOption.None) { vote(proposalId, _voteOption, _tryEarlyExecution); } + + _emitProposalCreatedEvent( + _metadata, + _actions, + _allowFailureMap, + proposalId, + _startDate, + _endDate + ); + } + + /// @inheritdoc IProposal + function createProposal( + bytes calldata _metadata, + Action[] calldata _actions, + uint64 _startDate, + uint64 _endDate, + bytes memory _data + ) external override returns (uint256 proposalId) { + // Note that this calls public function for permission check. + uint256 allowFailureMap; + VoteOption _voteOption = VoteOption.None; + bool tryEarlyExecution; + + if (_data.length != 0) { + (allowFailureMap, _voteOption, tryEarlyExecution) = abi.decode( + _data, + (uint256, VoteOption, bool) + ); + } + + proposalId = createProposal( + _metadata, + _actions, + allowFailureMap, + _startDate, + _endDate, + _voteOption, + tryEarlyExecution + ); + } + + /// @inheritdoc IProposal + function customProposalParamsABI() external pure override returns (string memory) { + return "(uint256 allowFailureMap, uint8 voteOption, bool tryEarlyExecution)"; } /// @inheritdoc IMembership @@ -202,7 +263,14 @@ contract TokenVoting is IMembership, MajorityVotingBase { votingPower: votingPower }); - if (_tryEarlyExecution && _canExecute(_proposalId)) { + if (!_tryEarlyExecution) { + return; + } + + if ( + _canExecute(_proposalId) && + dao().hasPermission(address(this), _voter, EXECUTE_PROPOSAL_PERMISSION_ID, _msgData()) + ) { _execute(_proposalId); } } @@ -241,6 +309,26 @@ contract TokenVoting is IMembership, MajorityVotingBase { return true; } + /// @dev Helper function to avoid stack too deep in non via-ir compilation mode. + function _emitProposalCreatedEvent( + bytes calldata _metadata, + Action[] calldata _actions, + uint256 _allowFailureMap, + uint256 proposalId, + uint64 _startDate, + uint64 _endDate + ) private { + emit ProposalCreated( + proposalId, + _msgSender(), + _startDate, + _endDate, + _metadata, + _actions, + _allowFailureMap + ); + } + /// @dev This empty reserved space is put in place to allow future versions to add new /// variables without shifting down storage in the inheritance chain. /// https://docs.openzeppelin.com/contracts/4.x/upgradeable#storage_gaps diff --git a/packages/contracts/src/TokenVotingSetup.sol b/packages/contracts/src/TokenVotingSetup.sol index 0459fb90..e3952086 100644 --- a/packages/contracts/src/TokenVotingSetup.sol +++ b/packages/contracts/src/TokenVotingSetup.sol @@ -9,11 +9,11 @@ import {IERC20Upgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC20 import {IVotesUpgradeable} from "@openzeppelin/contracts-upgradeable/governance/utils/IVotesUpgradeable.sol"; import {GovernanceERC20} from "./ERC20/governance/GovernanceERC20.sol"; -import {IGovernanceWrappedERC20} from "./ERC20/governance/IGovernanceWrappedERC20.sol"; import {GovernanceWrappedERC20} from "./ERC20/governance/GovernanceWrappedERC20.sol"; import {IDAO} from "@aragon/osx-commons-contracts/src/dao/IDAO.sol"; import {PermissionLib} from "@aragon/osx-commons-contracts/src/permission/PermissionLib.sol"; +import {IPlugin} from "@aragon/osx-commons-contracts/src/plugin/IPlugin.sol"; import {IPluginSetup} from "@aragon/osx-commons-contracts/src/plugin/setup/IPluginSetup.sol"; import {PluginUpgradeableSetup} from "@aragon/osx-commons-contracts/src/plugin/setup/PluginUpgradeableSetup.sol"; @@ -22,6 +22,8 @@ import {TokenVoting} from "./TokenVoting.sol"; import {ProxyLib} from "@aragon/osx-commons-contracts/src/utils/deployment/ProxyLib.sol"; +import {VotingPowerCondition} from "./VotingPowerCondition.sol"; + /// @title TokenVotingSetup /// @author Aragon X - 2022-2023 /// @notice The setup contract of the `TokenVoting` plugin. @@ -37,6 +39,23 @@ contract TokenVotingSetup is PluginUpgradeableSetup { /// @dev TODO: Migrate this constant to a common library that can be shared across plugins. bytes32 public constant EXECUTE_PERMISSION_ID = keccak256("EXECUTE_PERMISSION"); + /// @notice The ID of the permission required to call the `setTargetConfig` function. + bytes32 public constant SET_TARGET_CONFIG_PERMISSION_ID = + keccak256("SET_TARGET_CONFIG_PERMISSION"); + + /// @notice The ID of the permission required to call the `setMetadata` function. + bytes32 public constant SET_METADATA_PERMISSION_ID = keccak256("SET_METADATA_PERMISSION"); + + /// @notice The ID of the permission required to call the `upgradeToAndCall` function. + bytes32 internal constant UPGRADE_PLUGIN_PERMISSION_ID = keccak256("UPGRADE_PLUGIN_PERMISSION"); + + /// @notice The ID of the permission required to call the `execute` function. + bytes32 internal constant EXECUTE_PROPOSAL_PERMISSION_ID = + keccak256("EXECUTE_PROPOSAL_PERMISSION"); + + /// @notice A special address encoding permissions that are valid for any address `who` or `where`. + address internal constant ANY_ADDR = address(type(uint160).max); + /// @notice The address of the `TokenVoting` base contract. // solhint-disable-next-line immutable-vars-naming TokenVoting private immutable tokenVotingBase; @@ -68,10 +87,6 @@ contract TokenVotingSetup is PluginUpgradeableSetup { /// @param token The token address error TokenNotERC20(address token); - /// @notice Thrown if passed helpers array is of wrong length. - /// @param length The array length of passed helpers. - error WrongHelpersArrayLength(uint256 length); - /// @notice The contract constructor deploying the plugin implementation contract /// and receiving the governance token base contracts to clone from. /// @param _governanceERC20Base The base `GovernanceERC20` contract to create clones from. @@ -96,19 +111,25 @@ contract TokenVotingSetup is PluginUpgradeableSetup { MajorityVotingBase.VotingSettings memory votingSettings, TokenSettings memory tokenSettings, // only used for GovernanceERC20(token is not passed) - GovernanceERC20.MintSettings memory mintSettings + GovernanceERC20.MintSettings memory mintSettings, + IPlugin.TargetConfig memory targetConfig, + uint256 minApprovals, + bytes memory pluginMetadata ) = abi.decode( _data, - (MajorityVotingBase.VotingSettings, TokenSettings, GovernanceERC20.MintSettings) + ( + MajorityVotingBase.VotingSettings, + TokenSettings, + GovernanceERC20.MintSettings, + IPlugin.TargetConfig, + uint256, + bytes + ) ); address token = tokenSettings.addr; - bool tokenAddressNotZero = token != address(0); - - // Prepare helpers. - address[] memory helpers = new address[](1); - if (tokenAddressNotZero) { + if (tokenSettings.addr != address(0)) { if (!token.isContract()) { revert TokenNotContract(token); } @@ -117,18 +138,7 @@ contract TokenVotingSetup is PluginUpgradeableSetup { revert TokenNotERC20(token); } - // [0] = IERC20Upgradeable, [1] = IVotesUpgradeable, [2] = IGovernanceWrappedERC20 - bool[] memory supportedIds = _getTokenInterfaceIds(token); - - if ( - // If token supports none of them - // it's simply ERC20 which gets checked by _isERC20 - // Currently, not a satisfiable check. - (!supportedIds[0] && !supportedIds[1] && !supportedIds[2]) || - // If token supports IERC20, but neither - // IVotes nor IGovernanceWrappedERC20, it needs wrapping. - (supportedIds[0] && !supportedIds[1] && !supportedIds[2]) - ) { + if (!supportsIVotesInterface(token)) { token = governanceWrappedERC20Base.clone(); // User already has a token. We need to wrap it in // GovernanceWrappedERC20 in order to make the token @@ -150,20 +160,29 @@ contract TokenVotingSetup is PluginUpgradeableSetup { ); } - helpers[0] = token; - // Prepare and deploy plugin proxy. plugin = address(tokenVotingBase).deployUUPSProxy( abi.encodeCall( TokenVoting.initialize, - (IDAO(_dao), votingSettings, IVotesUpgradeable(token)) + ( + IDAO(_dao), + votingSettings, + IVotesUpgradeable(token), + targetConfig, + minApprovals, + pluginMetadata + ) ) ); + preparedSetupData.helpers = new address[](2); + preparedSetupData.helpers[0] = address(new VotingPowerCondition(plugin)); + preparedSetupData.helpers[1] = token; + // Prepare permissions PermissionLib.MultiTargetPermission[] memory permissions = new PermissionLib.MultiTargetPermission[]( - tokenAddressNotZero ? 2 : 3 + tokenSettings.addr != address(0) ? 6 : 7 ); // Set plugin permissions to be granted. @@ -185,10 +204,42 @@ contract TokenVotingSetup is PluginUpgradeableSetup { permissionId: EXECUTE_PERMISSION_ID }); - if (!tokenAddressNotZero) { + permissions[2] = PermissionLib.MultiTargetPermission( + PermissionLib.Operation.GrantWithCondition, + plugin, + ANY_ADDR, + preparedSetupData.helpers[0], // VotingPowerCondition + TokenVoting(IMPLEMENTATION).CREATE_PROPOSAL_PERMISSION_ID() + ); + + permissions[3] = PermissionLib.MultiTargetPermission({ + operation: PermissionLib.Operation.Grant, + where: plugin, + who: _dao, + condition: PermissionLib.NO_CONDITION, + permissionId: SET_TARGET_CONFIG_PERMISSION_ID + }); + + permissions[4] = PermissionLib.MultiTargetPermission({ + operation: PermissionLib.Operation.Grant, + where: plugin, + who: _dao, + condition: PermissionLib.NO_CONDITION, + permissionId: SET_METADATA_PERMISSION_ID + }); + + permissions[5] = PermissionLib.MultiTargetPermission({ + operation: PermissionLib.Operation.Grant, + where: plugin, + who: ANY_ADDR, + condition: PermissionLib.NO_CONDITION, + permissionId: EXECUTE_PROPOSAL_PERMISSION_ID + }); + + if (tokenSettings.addr == address(0)) { bytes32 tokenMintPermission = GovernanceERC20(token).MINT_PERMISSION_ID(); - permissions[2] = PermissionLib.MultiTargetPermission({ + permissions[6] = PermissionLib.MultiTargetPermission({ operation: PermissionLib.Operation.Grant, where: token, who: _dao, @@ -197,7 +248,6 @@ contract TokenVotingSetup is PluginUpgradeableSetup { }); } - preparedSetupData.helpers = helpers; preparedSetupData.permissions = permissions; } @@ -209,24 +259,60 @@ contract TokenVotingSetup is PluginUpgradeableSetup { SetupPayload calldata _payload ) external - view override returns (bytes memory initData, PreparedSetupData memory preparedSetupData) { - (initData); if (_fromBuild < 3) { + address votingPowerCondition = address(new VotingPowerCondition(_payload.plugin)); + PermissionLib.MultiTargetPermission[] - memory permissions = new PermissionLib.MultiTargetPermission[](1); + memory permissions = new PermissionLib.MultiTargetPermission[](5); permissions[0] = PermissionLib.MultiTargetPermission({ operation: PermissionLib.Operation.Revoke, where: _payload.plugin, who: _dao, condition: PermissionLib.NO_CONDITION, - permissionId: tokenVotingBase.UPGRADE_PLUGIN_PERMISSION_ID() + permissionId: UPGRADE_PLUGIN_PERMISSION_ID + }); + + permissions[1] = PermissionLib.MultiTargetPermission( + PermissionLib.Operation.GrantWithCondition, + _payload.plugin, + ANY_ADDR, // ANY_ADDR + votingPowerCondition, + TokenVoting(IMPLEMENTATION).CREATE_PROPOSAL_PERMISSION_ID() + ); + + permissions[2] = PermissionLib.MultiTargetPermission({ + operation: PermissionLib.Operation.Grant, + where: _payload.plugin, + who: _dao, + condition: PermissionLib.NO_CONDITION, + permissionId: SET_TARGET_CONFIG_PERMISSION_ID + }); + + permissions[3] = PermissionLib.MultiTargetPermission({ + operation: PermissionLib.Operation.Grant, + where: _payload.plugin, + who: _dao, + condition: PermissionLib.NO_CONDITION, + permissionId: SET_METADATA_PERMISSION_ID + }); + + permissions[4] = PermissionLib.MultiTargetPermission({ + operation: PermissionLib.Operation.Grant, + where: _payload.plugin, + who: ANY_ADDR, + condition: PermissionLib.NO_CONDITION, + permissionId: EXECUTE_PROPOSAL_PERMISSION_ID }); preparedSetupData.permissions = permissions; + preparedSetupData.helpers = new address[](1); + preparedSetupData.helpers[0] = votingPowerCondition; + + initData = abi.encodeCall(TokenVoting.initializeFrom, (_fromBuild, _payload.data)); } } @@ -236,12 +322,7 @@ contract TokenVotingSetup is PluginUpgradeableSetup { SetupPayload calldata _payload ) external view returns (PermissionLib.MultiTargetPermission[] memory permissions) { // Prepare permissions. - uint256 helperLength = _payload.currentHelpers.length; - if (helperLength != 1) { - revert WrongHelpersArrayLength({length: helperLength}); - } - - permissions = new PermissionLib.MultiTargetPermission[](2); + permissions = new PermissionLib.MultiTargetPermission[](6); // Set permissions to be Revoked. permissions[0] = PermissionLib.MultiTargetPermission({ @@ -259,17 +340,59 @@ contract TokenVotingSetup is PluginUpgradeableSetup { condition: PermissionLib.NO_CONDITION, permissionId: EXECUTE_PERMISSION_ID }); + + permissions[2] = PermissionLib.MultiTargetPermission({ + operation: PermissionLib.Operation.Revoke, + where: _payload.plugin, + who: _dao, + condition: PermissionLib.NO_CONDITION, + permissionId: SET_TARGET_CONFIG_PERMISSION_ID + }); + + permissions[3] = PermissionLib.MultiTargetPermission({ + operation: PermissionLib.Operation.Revoke, + where: _payload.plugin, + who: _dao, + condition: PermissionLib.NO_CONDITION, + permissionId: SET_METADATA_PERMISSION_ID + }); + + permissions[4] = PermissionLib.MultiTargetPermission({ + operation: PermissionLib.Operation.Revoke, + where: _payload.plugin, + who: ANY_ADDR, // ANY_ADDR + condition: PermissionLib.NO_CONDITION, + permissionId: TokenVoting(IMPLEMENTATION).CREATE_PROPOSAL_PERMISSION_ID() + }); + + permissions[5] = PermissionLib.MultiTargetPermission({ + operation: PermissionLib.Operation.Revoke, + where: _payload.plugin, + who: ANY_ADDR, + condition: PermissionLib.NO_CONDITION, + permissionId: EXECUTE_PROPOSAL_PERMISSION_ID + }); } - /// @notice Retrieves the interface identifiers supported by the token contract. - /// @dev It is crucial to verify if the provided token address represents a valid contract before using the below. - /// @param token The token address - function _getTokenInterfaceIds(address token) private view returns (bool[] memory) { - bytes4[] memory interfaceIds = new bytes4[](3); - interfaceIds[0] = type(IERC20Upgradeable).interfaceId; - interfaceIds[1] = type(IVotesUpgradeable).interfaceId; - interfaceIds[2] = type(IGovernanceWrappedERC20).interfaceId; - return token.getSupportedInterfaces(interfaceIds); + /// @notice Unsatisfiably determines if the token is an IVotes interface. + /// @dev Many tokens don't use ERC165 even though they still support IVotes. + function supportsIVotesInterface(address token) public view returns (bool) { + (bool success1, bytes memory data1) = token.staticcall( + abi.encodeWithSelector(IVotesUpgradeable.getPastTotalSupply.selector, 0) + ); + (bool success2, bytes memory data2) = token.staticcall( + abi.encodeWithSelector(IVotesUpgradeable.getVotes.selector, address(this)) + ); + (bool success3, bytes memory data3) = token.staticcall( + abi.encodeWithSelector(IVotesUpgradeable.getPastVotes.selector, address(this), 0) + ); + + return (success1 && + data1.length == 0x20 && + success2 && + data2.length == 0x20 && + success3 && + data3.length == 0x20); } /// @notice Unsatisfiably determines if the contract is an ERC20 token. diff --git a/packages/contracts/src/VotingPowerCondition.sol b/packages/contracts/src/VotingPowerCondition.sol new file mode 100644 index 00000000..7f23c387 --- /dev/null +++ b/packages/contracts/src/VotingPowerCondition.sol @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +pragma solidity ^0.8.8; + +import {TokenVoting} from "./TokenVoting.sol"; + +import {IERC20Upgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC20/IERC20Upgradeable.sol"; +import {IVotesUpgradeable} from "@openzeppelin/contracts-upgradeable/governance/utils/IVotesUpgradeable.sol"; + +import {PermissionCondition} from "@aragon/osx-commons-contracts/src/permission/condition/PermissionCondition.sol"; + +contract VotingPowerCondition is PermissionCondition { + TokenVoting private immutable TOKEN_VOTING; + IVotesUpgradeable private immutable VOTING_TOKEN; + + constructor(address _tokenVoting) { + TOKEN_VOTING = TokenVoting(_tokenVoting); + VOTING_TOKEN = TOKEN_VOTING.getVotingToken(); + } + + function isGranted( + address _where, + address _who, + bytes32 _permissionId, + bytes calldata _data + ) public view override returns (bool) { + (_where, _data, _permissionId); + + uint256 minProposerVotingPower_ = TOKEN_VOTING.minProposerVotingPower(); + + if (minProposerVotingPower_ != 0) { + if ( + VOTING_TOKEN.getVotes(_who) < minProposerVotingPower_ && + IERC20Upgradeable(address(VOTING_TOKEN)).balanceOf(_who) < minProposerVotingPower_ + ) { + return false; + } + } + + return true; + } +} diff --git a/packages/contracts/src/build-metadata.json b/packages/contracts/src/build-metadata.json index 989f7144..afa47b68 100644 --- a/packages/contracts/src/build-metadata.json +++ b/packages/contracts/src/build-metadata.json @@ -88,6 +88,38 @@ "name": "mintSettings", "type": "tuple", "description": "The token mint settings struct containing the `receivers` and `amounts`." + }, + { + "components": [ + { + "internalType": "address", + "name": "target", + "type": "address", + "description": "The target contract to which actions will be forwarded to for execution." + }, + { + "internalType": "uint8", + "name": "operation", + "type": "uint8", + "description": "The operation type(either `call` or `delegatecall`) that will be used for execution forwarding." + } + ], + "internalType": "struct TokenVoting.TargetConfig", + "name": "TargetConfig", + "type": "tuple", + "description": "The initial target config" + }, + { + "internalType": "uint256", + "name": "minApproval", + "type": "uint256", + "description": "The minimum amount of yes votes needed for the proposal advance." + }, + { + "internalType": "bytes", + "name": "metadata", + "type": "bytes", + "description": "The metadata that contains the information about the multisig." } ] }, @@ -99,6 +131,43 @@ "2": { "description": "No input is required for the update.", "inputs": [] + }, + "3": { + "description": "The information required for the update.", + "inputs": [ + { + "internalType": "uint256", + "name": "minApprovals", + "type": "uint256", + "description": "The minimum amount of yes votes needed for the proposal advance." + }, + { + "components": [ + { + "internalType": "address", + "name": "target", + "type": "address", + "description": "The target contract to which actions will be forwarded to for execution." + }, + { + "internalType": "uint8", + "name": "operation", + "type": "uint8", + "description": "The operation type(either `call` or `delegatecall`) that will be used for execution forwarding." + } + ], + "internalType": "struct TokenVoting.TargetConfig", + "name": "TargetConfig", + "type": "tuple", + "description": "The initial target config" + }, + { + "internalType": "bytes", + "name": "metadata", + "type": "bytes", + "description": "The metadata that contains the information about the multisig." + } + ] } }, "prepareUninstallation": { diff --git a/packages/contracts/src/mocks/CustomExecutorMock.sol b/packages/contracts/src/mocks/CustomExecutorMock.sol new file mode 100644 index 00000000..e59fa539 --- /dev/null +++ b/packages/contracts/src/mocks/CustomExecutorMock.sol @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +pragma solidity ^0.8.8; + +import {IExecutor, Action} from "@aragon/osx-commons-contracts/src/executors/IExecutor.sol"; + +/// @dev DO NOT USE IN PRODUCTION! +contract CustomExecutorMock { + error FailedCustom(); + + event ExecutedCustom(); + + function execute( + bytes32 callId, + Action[] memory, + uint256 + ) external returns (bytes[] memory execResults, uint256 failureMap) { + (execResults, failureMap); + + if (callId == bytes32(0)) { + revert FailedCustom(); + } else { + emit ExecutedCustom(); + } + } +} diff --git a/packages/contracts/src/mocks/DAOMock.sol b/packages/contracts/src/mocks/DAOMock.sol index 008fcecf..8a0eedb7 100644 --- a/packages/contracts/src/mocks/DAOMock.sol +++ b/packages/contracts/src/mocks/DAOMock.sol @@ -5,8 +5,9 @@ pragma solidity ^0.8.8; import {IDAO} from "@aragon/osx-commons-contracts/src/dao/IDAO.sol"; import {IPermissionCondition} from "@aragon/osx-commons-contracts/src/permission/condition/IPermissionCondition.sol"; import {PermissionLib} from "@aragon/osx-commons-contracts/src/permission/PermissionLib.sol"; +import {IExecutor, Action} from "@aragon/osx-commons-contracts/src/executors/IExecutor.sol"; -contract DAOMock is IDAO { +contract DAOMock is IDAO, IExecutor { address internal constant NO_CONDITION = address(0); event Granted( diff --git a/packages/contracts/src/mocks/MajorityVotingMock.sol b/packages/contracts/src/mocks/MajorityVotingMock.sol index 55bc8c58..56d88418 100644 --- a/packages/contracts/src/mocks/MajorityVotingMock.sol +++ b/packages/contracts/src/mocks/MajorityVotingMock.sol @@ -2,25 +2,56 @@ pragma solidity ^0.8.8; +import {Action} from "@aragon/osx-commons-contracts/src/executors/IExecutor.sol"; + import {MajorityVotingBase, IDAO} from "../MajorityVotingBase.sol"; contract MajorityVotingMock is MajorityVotingBase { - function initializeMock(IDAO _dao, VotingSettings calldata _votingSettings) public initializer { - __MajorityVotingBase_init(_dao, _votingSettings); + function initializeMock( + IDAO _dao, + VotingSettings calldata _votingSettings, + TargetConfig calldata _targetConfig, + uint256 _minApprovals, + bytes calldata _metadata + ) public initializer { + __MajorityVotingBase_init(_dao, _votingSettings, _targetConfig, _minApprovals, _metadata); } function createProposal( bytes calldata /* _metadata */, - IDAO.Action[] calldata /* _actions */, + Action[] calldata /* _actions */, uint256 /* _allowFailureMap */, uint64 /* _startDate */, uint64 /* _endDate */, VoteOption /* _voteOption */, bool /* _tryEarlyExecution */ - ) external pure override returns (uint256 proposalId) { + ) public pure override returns (uint256 proposalId) { return 0; } + function createProposal( + bytes calldata _metadata, + Action[] calldata _actions, + uint64 _startDate, + uint64 _endDate, + bytes memory + ) external pure override returns (uint256 proposalId) { + // Calls public function for permission check. + proposalId = createProposal( + _metadata, + _actions, + 0, + _startDate, + _endDate, + VoteOption.None, + false + ); + } + + function customProposalParamsABI() external pure override returns (string memory) { + return "[uint256 allowFailureMap, uint8 voteOption, bool tryEarlyExecution]"; + } + function totalVotingPower(uint256 /* _blockNumber */) public pure override returns (uint256) { return 0; } diff --git a/packages/contracts/test/10_unit-testing/11_plugin.ts b/packages/contracts/test/10_unit-testing/11_plugin.ts index 401b487c..74effc8e 100644 --- a/packages/contracts/test/10_unit-testing/11_plugin.ts +++ b/packages/contracts/test/10_unit-testing/11_plugin.ts @@ -9,6 +9,8 @@ import { IProposal__factory, IProtocolVersion__factory, ProxyFactory__factory, + VotingPowerCondition__factory, + CustomExecutorMock__factory, } from '../../typechain'; import {ProxyCreatedEvent} from '../../typechain/@aragon/osx-commons-contracts/src/utils/deployment/ProxyFactory'; import {MajorityVotingBase} from '../../typechain/src/MajorityVotingBase'; @@ -19,15 +21,27 @@ import { import {ExecutedEvent} from '../../typechain/src/mocks/DAOMock'; import { MAJORITY_VOTING_BASE_INTERFACE, + MAJORITY_VOTING_BASE_OLD_INTERFACE, VOTING_EVENTS, } from '../test-utils/majority-voting-constants'; import { TOKEN_VOTING_INTERFACE, UPDATE_VOTING_SETTINGS_PERMISSION_ID, + EXECUTE_PROPOSAL_PERMISSION_ID, + INITIALIZE_SIGNATURE, + INITIALIZE_SIGNATURE_OLD, + Operation, + TargetConfig, + CREATE_PROPOSAL_SIGNATURE, + CREATE_PROPOSAL_PERMISSION_ID, + ANY_ADDR, + CREATE_PROPOSAL_SIGNATURE_IProposal, + SET_TARGET_CONFIG_PERMISSION_ID, } from '../test-utils/token-voting-constants'; import { TokenVoting__factory, TokenVoting, + IMajorityVoting_V1_3_0__factory, } from '../test-utils/typechain-versions'; import { VoteOption, @@ -46,11 +60,17 @@ import { RATIO_BASE, DAO_PERMISSIONS, } from '@aragon/osx-commons-sdk'; -import {DAO, DAOStructs, DAO__factory} from '@aragon/osx-ethers'; +import { + DAO, + DAOStructs, + DAO__factory, + MajorityVotingBase__factory, +} from '@aragon/osx-ethers'; import {loadFixture, time} from '@nomicfoundation/hardhat-network-helpers'; import {SignerWithAddress} from '@nomiclabs/hardhat-ethers/signers'; import {expect} from 'chai'; import {BigNumber} from 'ethers'; +import {defaultAbiCoder, keccak256} from 'ethers/lib/utils'; import {ethers} from 'hardhat'; type GlobalFixtureResult = { @@ -69,12 +89,39 @@ type GlobalFixtureResult = { initializedPlugin: TokenVoting; uninitializedPlugin: TokenVoting; defaultVotingSettings: MajorityVotingBase.VotingSettingsStruct; + defaultMinApproval: BigNumber; + defaultMetadata: string; token: TestGovernanceERC20; dao: DAO; + defaultTargetConfig: TargetConfig; dummyActions: DAOStructs.ActionStruct[]; dummyMetadata: string; }; +let chainId: number; + +async function createProposalId( + pluginAddress: string, + actions: DAOStructs.ActionStruct[], + metadata: string +): Promise { + const blockNumber = (await ethers.provider.getBlock('latest')).number; + const salt = keccak256( + defaultAbiCoder.encode( + ['tuple(address to,uint256 value,bytes data)[]', 'bytes'], + [actions, metadata] + ) + ); + return BigNumber.from( + keccak256( + defaultAbiCoder.encode( + ['uint256', 'uint256', 'address', 'bytes32'], + [chainId, blockNumber + 1, pluginAddress, salt] + ) + ) + ); +} + async function globalFixture(): Promise { const [ deployer, @@ -122,11 +169,27 @@ async function globalFixture(): Promise { minProposerVotingPower: 0, }; - const pluginInitdata = pluginImplementation.interface.encodeFunctionData( - 'initialize', - [dao.address, defaultVotingSettings, token.address] + const defaultMinApproval = pctToRatio(10); + const defaultMetadata = '0x11'; + + // Deploy an initialized plugin proxy. + const defaultTargetConfig: TargetConfig = { + target: dao.address, + operation: Operation.call, + }; + + const pluginInitData = pluginImplementation.interface.encodeFunctionData( + INITIALIZE_SIGNATURE, + [ + dao.address, + defaultVotingSettings, + token.address, + defaultTargetConfig, + defaultMinApproval, + defaultMetadata, + ] ); - const deploymentTx1 = await proxyFactory.deployUUPSProxy(pluginInitdata); + const deploymentTx1 = await proxyFactory.deployUUPSProxy(pluginInitData); const proxyCreatedEvent1 = findEvent( await deploymentTx1.wait(), proxyFactory.interface.getEvent('ProxyCreated').name @@ -136,6 +199,11 @@ async function globalFixture(): Promise { deployer ); + // Grant ANY_ADDR the permission to execute proposals + await dao + .connect(deployer) + .grant(initializedPlugin.address, ANY_ADDR, EXECUTE_PROPOSAL_PERMISSION_ID); + // Grant deployer the permission to update the voting settings await dao .connect(deployer) @@ -174,6 +242,13 @@ async function globalFixture(): Promise { }, ]; + await grantCreateProposalPermissions( + deployer, + dao, + initializedPlugin, + uninitializedPlugin + ); + return { deployer, alice, @@ -190,6 +265,9 @@ async function globalFixture(): Promise { initializedPlugin, uninitializedPlugin, defaultVotingSettings, + defaultMinApproval, + defaultMetadata, + defaultTargetConfig, token, dao, dummyActions, @@ -197,42 +275,92 @@ async function globalFixture(): Promise { }; } +async function grantCreateProposalPermissions( + deployer: SignerWithAddress, + dao: DAO, + initializedPlugin: TokenVoting, + uninitializedPlugin: TokenVoting +) { + const condition = await new VotingPowerCondition__factory(deployer).deploy( + initializedPlugin.address + ); + + await dao.grantWithCondition( + initializedPlugin.address, + ANY_ADDR, + CREATE_PROPOSAL_PERMISSION_ID, + condition.address + ); + + await dao.grantWithCondition( + uninitializedPlugin.address, + ANY_ADDR, + CREATE_PROPOSAL_PERMISSION_ID, + condition.address + ); +} + describe('TokenVoting', function () { + before(async () => { + chainId = (await ethers.provider.getNetwork()).chainId; + }); describe('initialize', async () => { it('reverts if trying to re-initialize', async () => { - const {dao, initializedPlugin, defaultVotingSettings, token} = - await loadFixture(globalFixture); + const { + dao, + initializedPlugin, + defaultVotingSettings, + defaultMinApproval, + defaultMetadata, + defaultTargetConfig, + token, + } = await loadFixture(globalFixture); // Try to reinitialize the initialized plugin. await expect( - initializedPlugin.initialize( + initializedPlugin[INITIALIZE_SIGNATURE]( dao.address, defaultVotingSettings, - token.address + token.address, + defaultTargetConfig, + defaultMinApproval, + defaultMetadata ) - ).to.be.revertedWith('Initializable: contract is already initialized'); + ).to.be.revertedWithCustomError(initializedPlugin, 'AlreadyInitialized'); }); it('emits the `MembershipContractAnnounced` event', async () => { - const {dao, uninitializedPlugin, defaultVotingSettings, token} = - await loadFixture(globalFixture); + const { + dao, + uninitializedPlugin, + defaultVotingSettings, + defaultMinApproval, + defaultMetadata, + defaultTargetConfig, + token, + } = await loadFixture(globalFixture); // Initialize the uninitialized plugin. await expect( - await uninitializedPlugin.initialize( + await uninitializedPlugin[INITIALIZE_SIGNATURE]( dao.address, defaultVotingSettings, - token.address + token.address, + defaultTargetConfig, + defaultMinApproval, + defaultMetadata ) ) .to.emit(uninitializedPlugin, 'MembershipContractAnnounced') .withArgs(token.address); }); - it('sets the voting settings and token', async () => { + it('sets the voting settings, token, minimal approval and metadata', async () => { const { dao, uninitializedPlugin: plugin, + defaultTargetConfig, + defaultMetadata, token, } = await loadFixture(globalFixture); @@ -254,9 +382,17 @@ describe('TokenVoting', function () { minDuration: TIME.HOUR, minProposerVotingPower: 123, }; + const minApproval = pctToRatio(30); // Initialize the plugin. - await plugin.initialize(dao.address, votingSettings, token.address); + await plugin[INITIALIZE_SIGNATURE]( + dao.address, + votingSettings, + token.address, + defaultTargetConfig, + minApproval, + defaultMetadata + ); // Check that the voting settings have been set. expect(await plugin.minDuration()).to.equal(votingSettings.minDuration); @@ -273,6 +409,12 @@ describe('TokenVoting', function () { // Check that the token has been set. expect(await plugin.getVotingToken()).to.equal(token.address); + + // Check the minimal approval has been set. + expect(await plugin.minApproval()).to.equal(minApproval); + + // Check the metadata has been set. + expect(await plugin.getMetadata()).to.equal(defaultMetadata); }); }); @@ -318,6 +460,13 @@ describe('TokenVoting', function () { expect(await plugin.supportsInterface(getInterfaceId(iface))).to.be.true; }); + it('supports the `IMajorityVoting` OLD interface', async () => { + const {initializedPlugin: plugin} = await loadFixture(globalFixture); + const oldIface = IMajorityVoting_V1_3_0__factory.createInterface(); + expect(await plugin.supportsInterface(getInterfaceId(oldIface))).to.be + .true; + }); + it('supports the `MajorityVotingBase` interface', async () => { const {initializedPlugin: plugin} = await loadFixture(globalFixture); expect( @@ -327,6 +476,15 @@ describe('TokenVoting', function () { ).to.be.true; }); + it('supports the `MajorityVotingBase` OLD interface', async () => { + const {initializedPlugin: plugin} = await loadFixture(globalFixture); + expect( + await plugin.supportsInterface( + getInterfaceId(MAJORITY_VOTING_BASE_OLD_INTERFACE) + ) + ).to.be.true; + }); + it('supports the `TokenVoting` interface', async () => { const {initializedPlugin: plugin} = await loadFixture(globalFixture); const interfaceId = getInterfaceId(TOKEN_VOTING_INTERFACE); @@ -376,6 +534,111 @@ describe('TokenVoting', function () { }); }); + // These tests ensure that overriden `createProposal` function from `IProposal` + // successfully creates a proposal with default values(when `data` is not passed) + // and with custom values when it's passed. + describe('Proposal creation: IProposal Interface Function', async () => { + let voteSettingsWithMinProposerVotingPower: MajorityVotingBase.VotingSettingsStruct; + + before(async () => { + voteSettingsWithMinProposerVotingPower = { + votingMode: VotingMode.EarlyExecution, + supportThreshold: pctToRatio(0), + minParticipation: pctToRatio(0), + minDuration: TIME.HOUR, + minProposerVotingPower: 0, + }; + }); + + it('creates proposal with default values if `data` param is encoded with custom values', async () => { + const { + deployer, + initializedPlugin: plugin, + token, + dummyActions, + dummyMetadata, + } = await loadFixture(globalFixture); + + await plugin.updateVotingSettings(voteSettingsWithMinProposerVotingPower); + + // Make sure the supply is not zero. + await setBalances(token, [ + { + receiver: deployer.address, + amount: 1, + }, + ]); + + await setTotalSupply(token, 5); + + const data = ethers.utils.defaultAbiCoder.encode( + ['uint256', 'uint256', 'bool'], + [1, 2, true] + ); + const proposalId = await createProposalId( + plugin.address, + dummyActions, + dummyMetadata + ); + + await plugin[CREATE_PROPOSAL_SIGNATURE_IProposal]( + dummyMetadata, + dummyActions, + 0, + 0, + data + ); + + const proposal = await plugin.getProposal(proposalId); + expect(proposal.allowFailureMap).to.equal(1); + expect(await plugin.getVoteOption(proposalId, deployer.address)).to.equal( + 2 + ); + expect(proposal.executed).to.be.true; + }); + + it('creates proposal with default values if `data` param is passed as empty', async () => { + const { + deployer, + initializedPlugin: plugin, + token, + dummyActions, + dummyMetadata, + } = await loadFixture(globalFixture); + + await plugin.updateVotingSettings(voteSettingsWithMinProposerVotingPower); + + // Make sure the supply is not zero. + await setBalances(token, [ + { + receiver: deployer.address, + amount: 1, + }, + ]); + + await setTotalSupply(token, 5); + const proposalId = await createProposalId( + plugin.address, + dummyActions, + dummyMetadata + ); + await plugin[CREATE_PROPOSAL_SIGNATURE_IProposal]( + dummyMetadata, + dummyActions, + 0, + 0, + '0x' + ); + + const proposal = await plugin.getProposal(proposalId); + expect(proposal.allowFailureMap).to.equal(0); + expect(await plugin.getVoteOption(proposalId, deployer.address)).to.equal( + 0 + ); + expect(proposal.executed).to.be.false; + }); + }); + describe('Proposal creation', async () => { let voteSettingsWithMinProposerVotingPower: MajorityVotingBase.VotingSettingsStruct; @@ -403,9 +666,14 @@ describe('TokenVoting', function () { // Create a proposal with Alice despite her having no voting power. const endDate = (await time.latest()) + TIME.DAY; + const expectedProposalId = await createProposalId( + plugin.address, + dummyActions, + dummyMetadata + ); const tx = await plugin .connect(alice) - .createProposal( + [CREATE_PROPOSAL_SIGNATURE]( dummyMetadata, dummyActions, 0, @@ -415,12 +683,11 @@ describe('TokenVoting', function () { false ); - const id = 0; const event = findEvent( await tx.wait(), 'ProposalCreated' ); - expect(event.args.proposalId).to.equal(id); + expect(event.args.proposalId).to.equal(expectedProposalId); }); }); @@ -434,6 +701,7 @@ describe('TokenVoting', function () { token, dummyActions, dummyMetadata, + dao, } = await loadFixture(globalFixture); await plugin @@ -452,7 +720,7 @@ describe('TokenVoting', function () { await expect( plugin .connect(alice) - .createProposal( + [CREATE_PROPOSAL_SIGNATURE]( dummyMetadata, dummyActions, 0, @@ -462,14 +730,19 @@ describe('TokenVoting', function () { false ) ) - .to.be.revertedWithCustomError(plugin, 'ProposalCreationForbidden') - .withArgs(alice.address); + .to.be.revertedWithCustomError(plugin, 'DaoUnauthorized') + .withArgs( + dao.address, + plugin.address, + alice.address, + CREATE_PROPOSAL_PERMISSION_ID + ); // Create a proposal as Bob. await expect( plugin .connect(bob) - .createProposal( + [CREATE_PROPOSAL_SIGNATURE]( dummyMetadata, dummyActions, 0, @@ -484,6 +757,7 @@ describe('TokenVoting', function () { it('reverts if `_msgSender` owns no tokens and has no tokens delegated to her/him in the current block although having them in the last block', async () => { const { deployer, + dao, alice, bob, initializedPlugin: plugin, @@ -523,7 +797,7 @@ describe('TokenVoting', function () { await expect( plugin .connect(alice) - .createProposal( + [CREATE_PROPOSAL_SIGNATURE]( dummyMetadata, dummyActions, 0, @@ -533,13 +807,23 @@ describe('TokenVoting', function () { false ) ) - .to.be.revertedWithCustomError(plugin, 'ProposalCreationForbidden') - .withArgs(alice.address); + .to.be.revertedWithCustomError(plugin, 'DaoUnauthorized') + .withArgs( + dao.address, + plugin.address, + alice.address, + CREATE_PROPOSAL_PERMISSION_ID + ); // Transaction 3: Create the proposal as Bob. + const id = await createProposalId( + plugin.address, + dummyActions, + dummyMetadata + ); const tx3 = await plugin .connect(bob) - .createProposal( + [CREATE_PROPOSAL_SIGNATURE]( dummyMetadata, dummyActions, 0, @@ -548,7 +832,6 @@ describe('TokenVoting', function () { VoteOption.None, false ); - const id = 0; // Check the balances before the block is mined. Note that `balanceOf` is a view function, // whose result will be immediately available and does not rely on the block to be mined. @@ -596,6 +879,7 @@ describe('TokenVoting', function () { const { deployer, alice, + dao, bob, initializedPlugin: plugin, token, @@ -620,7 +904,7 @@ describe('TokenVoting', function () { await expect( plugin .connect(bob) - .createProposal( + [CREATE_PROPOSAL_SIGNATURE]( dummyMetadata, dummyActions, 0, @@ -630,14 +914,19 @@ describe('TokenVoting', function () { false ) ) - .to.be.revertedWithCustomError(plugin, 'ProposalCreationForbidden') - .withArgs(bob.address); + .to.be.revertedWithCustomError(plugin, 'DaoUnauthorized') + .withArgs( + dao.address, + plugin.address, + bob.address, + CREATE_PROPOSAL_PERMISSION_ID + ); // Check that Alice who has enough balance can create a proposal. await expect( plugin .connect(alice) - .createProposal( + [CREATE_PROPOSAL_SIGNATURE]( dummyMetadata, dummyActions, 0, @@ -676,9 +965,15 @@ describe('TokenVoting', function () { // Check that Alice can create a proposal although she delegated to Bob. const endDate = (await time.latest()) + TIME.DAY; + const id = await createProposalId( + plugin.address, + dummyActions, + dummyMetadata + ); + const tx = await plugin .connect(alice) - .createProposal( + [CREATE_PROPOSAL_SIGNATURE]( dummyMetadata, dummyActions, 0, @@ -691,7 +986,7 @@ describe('TokenVoting', function () { await tx.wait(), 'ProposalCreated' ); - expect(event.args.proposalId).to.equal(0); + expect(event.args.proposalId).to.equal(id); }); it('creates a proposal if `_msgSender` owns no tokens but has enough tokens delegated to her/him in the current block', async () => { @@ -725,7 +1020,7 @@ describe('TokenVoting', function () { await expect( plugin .connect(bob) - .createProposal( + [CREATE_PROPOSAL_SIGNATURE]( dummyMetadata, dummyActions, 0, @@ -737,11 +1032,12 @@ describe('TokenVoting', function () { ).not.to.be.reverted; }); - it('reverts if `_msgSender` doesn not own enough tokens herself/himself and has not tokens delegated to her/him in the current block', async () => { + it('reverts if `_msgSender` does not own enough tokens herself/himself and has not tokens delegated to her/him in the current block', async () => { const { deployer, alice, bob, + dao, initializedPlugin: plugin, token, dummyActions, @@ -772,7 +1068,7 @@ describe('TokenVoting', function () { await expect( plugin .connect(alice) - .createProposal( + [CREATE_PROPOSAL_SIGNATURE]( dummyMetadata, dummyActions, 0, @@ -782,8 +1078,13 @@ describe('TokenVoting', function () { false ) ) - .to.be.revertedWithCustomError(plugin, 'ProposalCreationForbidden') - .withArgs(alice.address); + .to.be.revertedWithCustomError(plugin, 'DaoUnauthorized') + .withArgs( + dao.address, + plugin.address, + alice.address, + CREATE_PROPOSAL_PERMISSION_ID + ); // As Alice delegate all votes to Bob. await token.connect(alice).delegate(bob.address); @@ -792,7 +1093,7 @@ describe('TokenVoting', function () { await expect( plugin .connect(alice) - .createProposal( + [CREATE_PROPOSAL_SIGNATURE]( dummyMetadata, dummyActions, 0, @@ -802,8 +1103,13 @@ describe('TokenVoting', function () { false ) ) - .to.be.revertedWithCustomError(plugin, 'ProposalCreationForbidden') - .withArgs(alice.address); + .to.be.revertedWithCustomError(plugin, 'DaoUnauthorized') + .withArgs( + dao.address, + plugin.address, + alice.address, + CREATE_PROPOSAL_PERMISSION_ID + ); }); }); @@ -822,7 +1128,7 @@ describe('TokenVoting', function () { await expect( plugin .connect(alice) - .createProposal( + [CREATE_PROPOSAL_SIGNATURE]( dummyMetadata, dummyActions, 0, @@ -855,7 +1161,7 @@ describe('TokenVoting', function () { await expect( plugin .connect(alice) - .createProposal( + [CREATE_PROPOSAL_SIGNATURE]( dummyMetadata, dummyActions, 0, @@ -894,7 +1200,7 @@ describe('TokenVoting', function () { await expect( plugin .connect(alice) - .createProposal( + [CREATE_PROPOSAL_SIGNATURE]( dummyMetadata, dummyActions, 0, @@ -929,7 +1235,7 @@ describe('TokenVoting', function () { await expect( plugin .connect(alice) - .createProposal( + [CREATE_PROPOSAL_SIGNATURE]( dummyMetadata, dummyActions, 0, @@ -958,10 +1264,11 @@ describe('TokenVoting', function () { // Create a proposal with zero as an input for `startDate` and `endDate` const startDate = 0; // now const endDate = 0; // startDate + minDuration + const id = await createProposalId(plugin.address, [], dummyMetadata); const creationTx = await plugin .connect(alice) - .createProposal( + [CREATE_PROPOSAL_SIGNATURE]( dummyMetadata, [], 0, @@ -970,7 +1277,6 @@ describe('TokenVoting', function () { VoteOption.None, false ); - const id = 0; const expectedStartDate = BigNumber.from(await time.latest()); const expectedEndDate = expectedStartDate.add( @@ -1020,7 +1326,12 @@ describe('TokenVoting', function () { // Create a proposal. const endDate = (await time.latest()) + TIME.DAY; - const tx = await plugin.createProposal( + const id = await createProposalId( + plugin.address, + dummyActions, + dummyMetadata + ); + const tx = await plugin[CREATE_PROPOSAL_SIGNATURE]( dummyMetadata, dummyActions, 0, @@ -1029,7 +1340,7 @@ describe('TokenVoting', function () { VoteOption.None, false ); - const id = 0; + const event = findEvent( await tx.wait(), 'ProposalCreated' @@ -1063,7 +1374,13 @@ describe('TokenVoting', function () { // Create a proposal const endDate = (await time.latest()) + TIME.DAY; - const tx = await plugin.createProposal( + const id = await createProposalId( + plugin.address, + dummyActions, + dummyMetadata + ); + + const tx = await plugin[CREATE_PROPOSAL_SIGNATURE]( dummyMetadata, dummyActions, 0, @@ -1072,7 +1389,7 @@ describe('TokenVoting', function () { VoteOption.None, false ); - const id = 0; + const event = findEvent( await tx.wait(), 'ProposalCreated' @@ -1098,9 +1415,15 @@ describe('TokenVoting', function () { await token.setBalance(alice.address, 10); // Create a proposal as Alice. + const id = await createProposalId( + plugin.address, + dummyActions, + dummyMetadata + ); + const tx = await plugin .connect(alice) - .createProposal( + [CREATE_PROPOSAL_SIGNATURE]( dummyMetadata, dummyActions, allowFailureMap, @@ -1109,7 +1432,6 @@ describe('TokenVoting', function () { VoteOption.None, false ); - const id = 0; // Check that the `ProposalCreated` event is emitted and `VoteCast` is not. await expect(tx) @@ -1161,8 +1483,10 @@ describe('TokenVoting', function () { expect(proposal.tally.no).to.equal(0); expect(proposal.tally.abstain).to.equal(0); - expect(await plugin.canVote(1, alice.address, VoteOption.Yes)).to.equal( - false + expect(await plugin.canVote(id, alice.address, VoteOption.Yes)).to.be + .true; + expect(await plugin.getVoteOption(id, alice.address)).to.equal( + VoteOption.None ); expect(proposal.actions.length).to.equal(1); @@ -1185,9 +1509,15 @@ describe('TokenVoting', function () { await token.setBalance(alice.address, 10); // Create a proposal as Alice. + const id = await createProposalId( + plugin.address, + dummyActions, + dummyMetadata + ); + const tx = await plugin .connect(alice) - .createProposal( + [CREATE_PROPOSAL_SIGNATURE]( dummyMetadata, dummyActions, 0, @@ -1196,7 +1526,6 @@ describe('TokenVoting', function () { VoteOption.Yes, false ); - const id = 0; // Check that the `ProposalCreated` and `VoteCast` events are emitted with the expected data. await expect(tx) @@ -1258,11 +1587,17 @@ describe('TokenVoting', function () { const startDate = (await time.latest()) + TIME.HOUR; const endDate = startDate + TIME.DAY; expect(await time.latest()).to.be.lessThan(startDate); - const id = 0; + + const id = await createProposalId( + plugin.address, + dummyActions, + dummyMetadata + ); + await expect( plugin .connect(alice) - .createProposal( + [CREATE_PROPOSAL_SIGNATURE]( dummyMetadata, dummyActions, 0, @@ -1276,7 +1611,7 @@ describe('TokenVoting', function () { .withArgs(id, alice.address, VoteOption.Yes); // Check that the proposal can be created without voting (by setting `_voteOption` to `VoteOption.None`). - const tx = await plugin.createProposal( + const tx = await plugin[CREATE_PROPOSAL_SIGNATURE]( dummyMetadata, dummyActions, 0, @@ -1316,6 +1651,24 @@ describe('TokenVoting', function () { dummyMetadata: string; }> ) { + it('reverts if proposal does not exist', async () => { + const {initializedPlugin: plugin} = await loadFixture(localFixture); + + const id = 10; + + await expect(plugin.canExecute(id)) + .to.be.revertedWithCustomError(plugin, 'NonexistentProposal') + .withArgs(id); + + await expect(plugin.canVote(id, plugin.address, VoteOption.Yes)) + .to.be.revertedWithCustomError(plugin, 'NonexistentProposal') + .withArgs(id); + + await expect(plugin.hasSucceeded(id)) + .to.be.revertedWithCustomError(plugin, 'NonexistentProposal') + .withArgs(id); + }); + it('does not allow voting, when the vote has not started yet', async () => { const { alice, @@ -1326,8 +1679,13 @@ describe('TokenVoting', function () { const startDate = (await time.latest()) + TIME.HOUR; const endDate = startDate + TIME.DAY; + const id = await createProposalId( + plugin.address, + dummyActions, + dummyMetadata + ); - await plugin.createProposal( + await plugin[CREATE_PROPOSAL_SIGNATURE]( dummyMetadata, dummyActions, 0, @@ -1336,7 +1694,6 @@ describe('TokenVoting', function () { VoteOption.None, false ); - const id = 0; await expect(plugin.connect(alice).vote(id, VoteOption.Yes, false)) .to.be.revertedWithCustomError(plugin, 'VoteCastForbidden') @@ -1353,8 +1710,13 @@ describe('TokenVoting', function () { } = await loadFixture(localFixture); const endDate = (await time.latest()) + TIME.DAY; + const id = await createProposalId( + plugin.address, + dummyActions, + dummyMetadata + ); - await plugin.createProposal( + await plugin[CREATE_PROPOSAL_SIGNATURE]( dummyMetadata, dummyActions, 0, @@ -1363,7 +1725,6 @@ describe('TokenVoting', function () { VoteOption.None, false ); - const id = 0; // check the mallory has 0 token expect(await token.balanceOf(mallory.address)).to.equal(0); @@ -1387,8 +1748,13 @@ describe('TokenVoting', function () { } = await loadFixture(localFixture); const endDate = (await time.latest()) + TIME.DAY; + const id = await createProposalId( + plugin.address, + dummyActions, + dummyMetadata + ); - await plugin.createProposal( + await plugin[CREATE_PROPOSAL_SIGNATURE]( dummyMetadata, dummyActions, 0, @@ -1397,7 +1763,6 @@ describe('TokenVoting', function () { VoteOption.None, false ); - const id = 0; // Vote with Alice. await expect(plugin.connect(alice).vote(id, VoteOption.Yes, false)) @@ -1449,8 +1814,13 @@ describe('TokenVoting', function () { } = await loadFixture(localFixture); const endDate = (await time.latest()) + TIME.DAY; + const id = await createProposalId( + plugin.address, + dummyActions, + dummyMetadata + ); - await plugin.createProposal( + await plugin[CREATE_PROPOSAL_SIGNATURE]( dummyMetadata, dummyActions, 0, @@ -1459,7 +1829,6 @@ describe('TokenVoting', function () { VoteOption.None, false ); - const id = 0; // Check that voting is possible but don't vote using `callStatic` await expect( @@ -1509,6 +1878,7 @@ describe('TokenVoting', function () { judy, mallory, initializedPlugin, + uninitializedPlugin, token, dao, dummyActions, @@ -1577,11 +1947,16 @@ describe('TokenVoting', function () { } = await loadFixture(localFixture); const endDate = (await time.latest()) + TIME.DAY; + const id = await createProposalId( + plugin.address, + dummyActions, + dummyMetadata + ); // Create a proposal. await plugin .connect(alice) - .createProposal( + [CREATE_PROPOSAL_SIGNATURE]( dummyMetadata, dummyActions, 0, @@ -1590,7 +1965,6 @@ describe('TokenVoting', function () { VoteOption.None, false ); - const id = 0; // Vote as Alice. await plugin.connect(alice).vote(id, VoteOption.Yes, false); @@ -1624,9 +1998,14 @@ describe('TokenVoting', function () { } = await loadFixture(localFixture); const endDate = (await time.latest()) + TIME.DAY; + const id = await createProposalId( + plugin.address, + dummyActions, + dummyMetadata + ); // Create a proposal. - await plugin.createProposal( + await plugin[CREATE_PROPOSAL_SIGNATURE]( dummyMetadata, dummyActions, 0, @@ -1635,7 +2014,6 @@ describe('TokenVoting', function () { VoteOption.None, false ); - const id = 0; // Vote with enough voters so that the execution criteria are met. // Vote with enough votes so that the execution criteria and the vote outcome cannot change anymore, @@ -1652,6 +2030,8 @@ describe('TokenVoting', function () { expect(await plugin.isSupportThresholdReachedEarly(id)).to.be.true; expect(await plugin.isMinParticipationReached(id)).to.be.true; expect(await plugin.canExecute(id)).to.equal(false); + // Still return false as voting mode is Standard and proposal is still open. + expect(await plugin.hasSucceeded(id)).to.be.false; }); it('can execute normally if participation and support are met', async () => { @@ -1669,9 +2049,14 @@ describe('TokenVoting', function () { } = await loadFixture(localFixture); const endDate = (await time.latest()) + TIME.DAY; + const id = await createProposalId( + plugin.address, + dummyActions, + dummyMetadata + ); // Create a proposal. - await plugin.createProposal( + await plugin[CREATE_PROPOSAL_SIGNATURE]( dummyMetadata, dummyActions, 0, @@ -1680,7 +2065,6 @@ describe('TokenVoting', function () { VoteOption.None, false ); - const id = 0; // Vote with enough voters so that the execution criteria are met. await voteWithSigners(plugin, id, { @@ -1700,6 +2084,8 @@ describe('TokenVoting', function () { expect(await plugin.isSupportThresholdReached(id)).to.be.true; expect(await plugin.isMinParticipationReached(id)).to.be.true; expect(await plugin.canExecute(id)).to.equal(true); + + expect(await plugin.hasSucceeded(id)).to.be.true; }); it('does not execute early when voting with the `tryEarlyExecution` option', async () => { @@ -1717,9 +2103,14 @@ describe('TokenVoting', function () { } = await loadFixture(localFixture); const endDate = (await time.latest()) + TIME.DAY; + const id = await createProposalId( + plugin.address, + dummyActions, + dummyMetadata + ); // Create a proposal. - await plugin.createProposal( + await plugin[CREATE_PROPOSAL_SIGNATURE]( dummyMetadata, dummyActions, 0, @@ -1728,7 +2119,6 @@ describe('TokenVoting', function () { VoteOption.None, false ); - const id = 0; // Vote with enough voters so that the execution criteria are met. await voteWithSigners(plugin, id, { @@ -1766,9 +2156,14 @@ describe('TokenVoting', function () { } = await loadFixture(localFixture); const endDate = (await time.latest()) + TIME.DAY; + const id = await createProposalId( + plugin.address, + dummyActions, + dummyMetadata + ); // Create a proposal. - await plugin.createProposal( + await plugin[CREATE_PROPOSAL_SIGNATURE]( dummyMetadata, dummyActions, 0, @@ -1777,13 +2172,77 @@ describe('TokenVoting', function () { VoteOption.None, false ); - const id = 0; // Try to execute it while the vote is not decided yet. await expect(plugin.execute(id)) .to.be.revertedWithCustomError(plugin, 'ProposalExecutionForbidden') .withArgs(id); }); + + it('can not execute even if participation and support are met when caller does not have permission', async () => { + const { + alice, + bob, + carol, + dave, + eve, + frank, + grace, + initializedPlugin: plugin, + dummyMetadata, + dummyActions, + dao, + } = await loadFixture(localFixture); + + const endDate = (await time.latest()) + TIME.DAY; + const id = await createProposalId( + plugin.address, + dummyActions, + dummyMetadata + ); + + // Create a proposal. + await plugin[CREATE_PROPOSAL_SIGNATURE]( + dummyMetadata, + dummyActions, + 0, + 0, + endDate, + VoteOption.None, + false + ); + + // Vote with enough voters so that the execution criteria are met. + await voteWithSigners(plugin, id, { + yes: [alice, bob, carol], // 30 votes + no: [dave, eve], // 20 votes + abstain: [frank, grace], // 20 votes + }); + + // Wait until the vote is over. + await time.increaseTo(endDate); + + // Check that the proposal can be executed. + expect(await plugin.isSupportThresholdReached(id)).to.be.true; + expect(await plugin.isMinParticipationReached(id)).to.be.true; + expect(await plugin.canExecute(id)).to.equal(true); + + // Revoke execute permission from ANY_ADDR + await dao.revoke( + plugin.address, + ANY_ADDR, + EXECUTE_PROPOSAL_PERMISSION_ID + ); + + await expect(plugin.connect(alice).execute(id)) + .to.be.revertedWithCustomError(plugin, 'DaoUnauthorized') + .withArgs( + dao.address, + plugin.address, + alice.address, + EXECUTE_PROPOSAL_PERMISSION_ID + ); + }); }); describe('Early Execution', async () => { @@ -1823,6 +2282,7 @@ describe('TokenVoting', function () { judy, mallory, initializedPlugin, + uninitializedPlugin, token, dao, dummyActions, @@ -1845,7 +2305,7 @@ describe('TokenVoting', function () { ].map(signer => token.setBalance(signer.address, amount)); await Promise.all(promises); - // Update Voting settings + // // Update Voting settings const newVotingSettings: MajorityVotingBase.VotingSettingsStruct = { votingMode: VotingMode.EarlyExecution, supportThreshold: pctToRatio(50), @@ -1892,7 +2352,13 @@ describe('TokenVoting', function () { // Create a proposal const endDate = (await time.latest()) + TIME.DAY; - await plugin.createProposal( + const id = await createProposalId( + plugin.address, + dummyActions, + dummyMetadata + ); + + await plugin[CREATE_PROPOSAL_SIGNATURE]( dummyMetadata, dummyActions, 0, @@ -1901,7 +2367,6 @@ describe('TokenVoting', function () { VoteOption.None, false ); - const id = 0; // Vote with Alice. await plugin.connect(alice).vote(id, VoteOption.Yes, false); @@ -1936,7 +2401,13 @@ describe('TokenVoting', function () { // Create a Proposal const endDate = (await time.latest()) + TIME.DAY; - await plugin.createProposal( + const id = await createProposalId( + plugin.address, + dummyActions, + dummyMetadata + ); + + await plugin[CREATE_PROPOSAL_SIGNATURE]( dummyMetadata, dummyActions, 0, @@ -1945,7 +2416,6 @@ describe('TokenVoting', function () { VoteOption.None, false ); - const id = 0; // Vote with enough votes so that the vote is almost already decided. // If the remaining 50 votes become `No`s, the proposal would be defeated because the support threshold wouldn't be exceeded. @@ -1967,6 +2437,7 @@ describe('TokenVoting', function () { expect(await plugin.isMinParticipationReached(id)).to.be.true; expect(await plugin.isSupportThresholdReachedEarly(id)).to.be.true; expect(await plugin.canExecute(id)).to.equal(true); + expect(await plugin.hasSucceeded(id)).to.be.true; // Advance time after the end date. await time.increaseTo(endDate); @@ -1975,6 +2446,7 @@ describe('TokenVoting', function () { expect(await plugin.isMinParticipationReached(id)).to.be.true; expect(await plugin.isSupportThresholdReached(id)).to.be.true; expect(await plugin.canExecute(id)).to.equal(true); + expect(await plugin.hasSucceeded(id)).to.be.true; }); it('can execute normally if participation is large enough', async () => { @@ -1994,9 +2466,14 @@ describe('TokenVoting', function () { } = await loadFixture(localFixture); const endDate = (await time.latest()) + TIME.DAY; + const id = await createProposalId( + plugin.address, + dummyActions, + dummyMetadata + ); // Create a proposal. - await plugin.createProposal( + await plugin[CREATE_PROPOSAL_SIGNATURE]( dummyMetadata, dummyActions, 0, @@ -2005,7 +2482,6 @@ describe('TokenVoting', function () { VoteOption.None, false ); - const id = 0; // Vote with enough people so that execution criteria are met. await voteWithSigners(plugin, id, { @@ -2014,11 +2490,15 @@ describe('TokenVoting', function () { abstain: [ivan], // 10 votes }); + expect(await plugin.hasSucceeded(id)).to.be.true; + // Advance after the end date. await time.increaseTo(endDate); // Check that the vote is executable because support > 50%, participation > 20%, and the voting period is over. expect(await plugin.canExecute(id)).to.equal(true); + + expect(await plugin.hasSucceeded(id)).to.be.true; }); it('cannot execute normally if participation is too low', async () => { @@ -2034,7 +2514,13 @@ describe('TokenVoting', function () { // Create a proposal. const endDate = (await time.latest()) + TIME.DAY; - await plugin.createProposal( + const id = await createProposalId( + plugin.address, + dummyActions, + dummyMetadata + ); + + await plugin[CREATE_PROPOSAL_SIGNATURE]( dummyMetadata, dummyActions, 0, @@ -2043,7 +2529,6 @@ describe('TokenVoting', function () { VoteOption.None, false ); - const id = 0; // Set Bob's and Carol's balances. await token.setBalance(bob.address, 5); @@ -2062,6 +2547,89 @@ describe('TokenVoting', function () { // Check that the vote is not executable because the participation with 19% is still too low, despite a support of 67% and the voting period being over. expect(await plugin.canExecute(id)).to.equal(false); + expect(await plugin.hasSucceeded(id)).to.be.false; + }); + + it('executes target with delegate call', async () => { + let { + alice, + bob, + carol, + dave, + eve, + frank, + grace, + harold, + ivan, + dummyMetadata, + dummyActions, + deployer, + dao, + initializedPlugin: plugin, + } = await loadFixture(localFixture); + + const executorFactory = new CustomExecutorMock__factory(deployer); + const executor = await executorFactory.deploy(); + + const abiA = CustomExecutorMock__factory.abi; + const abiB = TokenVoting__factory.abi; + + // @ts-ignore + const mergedABI = abiA.concat(abiB); + + await dao.grant( + plugin.address, + deployer.address, + SET_TARGET_CONFIG_PERMISSION_ID + ); + + await plugin.connect(deployer).setTargetConfig({ + target: executor.address, + operation: Operation.delegatecall, + }); + + // @ts-ignore + const pluginMerged = (await ethers.getContractAt( + mergedABI, + plugin.address + )) as TokenVoting; + + const endDate = (await time.latest()) + TIME.DAY; + const id = await createProposalId( + plugin.address, + dummyActions, + dummyMetadata + ); + + await plugin[CREATE_PROPOSAL_SIGNATURE]( + dummyMetadata, + dummyActions, + 0, + 0, + endDate, + VoteOption.None, + false + ); + + // Vote with enough people so that execution criteria are met. + await voteWithSigners(plugin, id, { + yes: [alice, bob, carol, dave, eve], // 50 yes + no: [frank, grace, harold], // 30 votes + abstain: [ivan], // 10 votes + }); + + // Advance after the end date. + await time.increaseTo(endDate); + + // Check that the vote is executable because support > 50%, participation > 20%, and the voting period is over. + expect(await plugin.canExecute(id)).to.equal(true); + + await expect(plugin.execute(id)) + .to.emit(pluginMerged, 'ExecutedCustom') + .to.emit(pluginMerged, 'ProposalExecuted'); + + // It still should return `true` even if proposal has executed. + expect(await plugin.hasSucceeded(id)).to.be.true; }); it('executes the vote immediately when the vote is decided early and the tryEarlyExecution options is selected', async () => { @@ -2080,7 +2648,13 @@ describe('TokenVoting', function () { // Create a Proposal. const endDate = (await time.latest()) + TIME.DAY; - await plugin.createProposal( + const id = await createProposalId( + plugin.address, + dummyActions, + dummyMetadata + ); + + await plugin[CREATE_PROPOSAL_SIGNATURE]( dummyMetadata, dummyActions, 0, @@ -2089,7 +2663,6 @@ describe('TokenVoting', function () { VoteOption.None, false ); - const id = 0; // Vote 40 votes for `Yes`. The proposal can still get defeated if the remaining 60 votes vote for `No`. await voteWithSigners(plugin, id, { @@ -2106,7 +2679,7 @@ describe('TokenVoting', function () { // Vote `Yes` with Frank with `tryEarlyExecution` being turned off. The vote is decided now. await plugin.connect(frank).vote(id, VoteOption.Yes, false); - // Check that the proposal can be excuted but didn't execute yet. + // Check that the proposal can be executed but didn't execute yet. expect((await plugin.getProposal(id)).executed).to.equal(false); expect(await plugin.canExecute(id)).to.equal(true); @@ -2121,7 +2694,9 @@ describe('TokenVoting', function () { ); expect(event.args.actor).to.equal(plugin.address); - expect(event.args.callId).to.equal(proposalIdToBytes32(id)); + expect(event.args.callId).to.equal( + ethers.utils.hexZeroPad(id.toHexString(), 32) + ); expect(event.args.actions.length).to.equal(1); expect(event.args.actions[0].to).to.equal(dummyActions[0].to); expect(event.args.actions[0].value).to.equal(dummyActions[0].value); @@ -2155,7 +2730,13 @@ describe('TokenVoting', function () { // Create a proposal. const endDate = (await time.latest()) + TIME.DAY; - await plugin.createProposal( + const id = await createProposalId( + plugin.address, + dummyActions, + dummyMetadata + ); + + await plugin[CREATE_PROPOSAL_SIGNATURE]( dummyMetadata, dummyActions, 0, @@ -2164,13 +2745,71 @@ describe('TokenVoting', function () { VoteOption.None, false ); - const id = 0; // Check that it cannot be executed because it is not decided yet. await expect(plugin.execute(id)) .to.be.revertedWithCustomError(plugin, 'ProposalExecutionForbidden') .withArgs(id); }); + + it('record vote correctly without executing even when tryEarlyExecution options is selected', async () => { + const { + alice, + bob, + carol, + dave, + eve, + frank, + grace, + dao, + initializedPlugin: plugin, + dummyMetadata, + dummyActions, + } = await loadFixture(localFixture); + + // Create a Proposal. + const endDate = (await time.latest()) + TIME.DAY; + const id = await createProposalId( + plugin.address, + dummyActions, + dummyMetadata + ); + + await plugin[CREATE_PROPOSAL_SIGNATURE]( + dummyMetadata, + dummyActions, + 0, + 0, + endDate, + VoteOption.None, + false + ); + + // Vote 40 votes for `Yes`. The proposal can still get defeated if the remaining 60 votes vote for `No`. + await voteWithSigners(plugin, id, { + yes: [alice, bob, carol, dave, eve], // 50 votes + no: [], // 0 votes + abstain: [], // 0 votes + }); + + // Check that the proposal cannot be early executed and didn't execute yet. + expect((await plugin.getProposal(id)).executed).to.equal(false); + expect(await plugin.canExecute(id)).to.equal(false); + + // Revoke execute permission from ANY_ADDR + await dao.revoke( + plugin.address, + ANY_ADDR, + EXECUTE_PROPOSAL_PERMISSION_ID + ); + + // Vote `Yes` with Frank with `tryEarlyExecution` being turned on. + // The vote is decided now, but proposal should not be executed yet. + await plugin.connect(frank).vote(id, VoteOption.Yes, true); + // Check that the proposal can be executed but didn't execute yet. + expect((await plugin.getProposal(id)).executed).to.equal(false); + expect(await plugin.canExecute(id)).to.equal(true); + }); }); describe('Vote Replacement', async () => { @@ -2280,7 +2919,13 @@ describe('TokenVoting', function () { // Create a proposal. const endDate = (await time.latest()) + TIME.DAY; - await plugin.createProposal( + const id = await createProposalId( + plugin.address, + dummyActions, + dummyMetadata + ); + + await plugin[CREATE_PROPOSAL_SIGNATURE]( dummyMetadata, dummyActions, 0, @@ -2289,7 +2934,6 @@ describe('TokenVoting', function () { VoteOption.None, false ); - const id = 0; // Vote two times for `Yes` as Alice. await plugin.connect(alice).vote(id, VoteOption.Yes, false); @@ -2337,7 +2981,13 @@ describe('TokenVoting', function () { // Create a proposal. const endDate = (await time.latest()) + TIME.DAY; - await plugin.createProposal( + const id = await createProposalId( + plugin.address, + dummyActions, + dummyMetadata + ); + + await plugin[CREATE_PROPOSAL_SIGNATURE]( dummyMetadata, dummyActions, 0, @@ -2346,7 +2996,6 @@ describe('TokenVoting', function () { VoteOption.None, false ); - const id = 0; // Vote with enough votes so that the vote is already decided. await voteWithSigners(plugin, id, { @@ -2359,6 +3008,7 @@ describe('TokenVoting', function () { expect(await plugin.isSupportThresholdReachedEarly(id)).to.be.true; expect(await plugin.isMinParticipationReached(id)).to.be.true; expect(await plugin.canExecute(id)).to.equal(false); + expect(await plugin.hasSucceeded(id)).to.be.false; }); it('can execute normally if participation and support are met', async () => { @@ -2376,8 +3026,13 @@ describe('TokenVoting', function () { } = await loadFixture(localFixture); const endDate = (await time.latest()) + TIME.DAY; + const id = await createProposalId( + plugin.address, + dummyActions, + dummyMetadata + ); - await plugin.createProposal( + await plugin[CREATE_PROPOSAL_SIGNATURE]( dummyMetadata, dummyActions, 0, @@ -2386,7 +3041,6 @@ describe('TokenVoting', function () { VoteOption.None, false ); - const id = 0; // Vote with enough votes so that the support threshold and minimal participation are met. await voteWithSigners(plugin, id, { @@ -2400,6 +3054,7 @@ describe('TokenVoting', function () { expect(await plugin.isSupportThresholdReached(id)).to.be.true; expect(await plugin.isMinParticipationReached(id)).to.be.true; expect(await plugin.canExecute(id)).to.equal(false); + expect(await plugin.hasSucceeded(id)).to.be.false; // Advance time to the end date. await time.increaseTo(endDate); @@ -2408,6 +3063,7 @@ describe('TokenVoting', function () { expect(await plugin.isSupportThresholdReached(id)).to.be.true; expect(await plugin.isMinParticipationReached(id)).to.be.true; expect(await plugin.canExecute(id)).to.equal(true); + expect(await plugin.hasSucceeded(id)).to.be.true; }); it('does not execute early when voting with the `tryEarlyExecution` option', async () => { @@ -2426,7 +3082,13 @@ describe('TokenVoting', function () { // Create a proposal. const endDate = (await time.latest()) + TIME.DAY; - await plugin.createProposal( + const id = await createProposalId( + plugin.address, + dummyActions, + dummyMetadata + ); + + await plugin[CREATE_PROPOSAL_SIGNATURE]( dummyMetadata, dummyActions, 0, @@ -2435,7 +3097,6 @@ describe('TokenVoting', function () { VoteOption.None, false ); - const id = 0; // Vote 40 votes for `Yes`. The proposal can still get defeated if the remaining 60 votes vote for `No`. await voteWithSigners(plugin, id, { @@ -2449,19 +3110,19 @@ describe('TokenVoting', function () { // Vote `Yes` with Eve with `tryEarlyExecution` being turned on. The vote is not decided yet. await plugin.connect(eve).vote(id, VoteOption.Yes, true); - // Check that the proposal cannot be excuted. + // Check that the proposal cannot be executed. expect((await plugin.getProposal(id)).executed).to.equal(false); expect(await plugin.canExecute(id)).to.equal(false); // Vote `Yes` with Frank with `tryEarlyExecution` being turned off. The vote is decided now. await plugin.connect(frank).vote(id, VoteOption.Yes, false); - // Check that the proposal cannot be excuted. + // Check that the proposal cannot be executed. expect((await plugin.getProposal(id)).executed).to.equal(false); expect(await plugin.canExecute(id)).to.equal(false); // Vote `Yes` with Eve with `tryEarlyExecution` being turned on. The vote is not decided yet. await plugin.connect(grace).vote(id, VoteOption.Yes, true); - // Check that the proposal cannot be excuted. + // Check that the proposal cannot be executed. expect((await plugin.getProposal(id)).executed).to.equal(false); expect(await plugin.canExecute(id)).to.equal(false); }); @@ -2475,7 +3136,13 @@ describe('TokenVoting', function () { // Create a proposal. const endDate = (await time.latest()) + TIME.DAY; - await plugin.createProposal( + const id = await createProposalId( + plugin.address, + dummyActions, + dummyMetadata + ); + + await plugin[CREATE_PROPOSAL_SIGNATURE]( dummyMetadata, dummyActions, 0, @@ -2484,7 +3151,6 @@ describe('TokenVoting', function () { VoteOption.None, false ); - const id = 0; // Check that it cannot be executed because the vote is not decided yet. await expect(plugin.execute(id)) @@ -2495,7 +3161,7 @@ describe('TokenVoting', function () { }); describe('Different configurations:', async () => { - describe('A simple majority vote with >50% support and >=25% participation required', async () => { + describe('A simple majority vote with >50% support, >=25% participation required and minimal approval >= 21%', async () => { type LocalFixtureResult = { deployer: SignerWithAddress; alice: SignerWithAddress; @@ -2562,10 +3228,16 @@ describe('TokenVoting', function () { minProposerVotingPower: 0, }; + const newMinApproval = pctToRatio(21); + await initializedPlugin .connect(deployer) .updateVotingSettings(newVotingSettings); + await initializedPlugin + .connect(deployer) + .updateMinApprovals(newMinApproval); + return { deployer, alice, @@ -2597,8 +3269,13 @@ describe('TokenVoting', function () { } = await loadFixture(localFixture); const endDate = (await time.latest()) + TIME.DAY; + const id = await createProposalId( + plugin.address, + dummyActions, + dummyMetadata + ); - await plugin.createProposal( + await plugin[CREATE_PROPOSAL_SIGNATURE]( dummyMetadata, dummyActions, 0, @@ -2607,7 +3284,6 @@ describe('TokenVoting', function () { VoteOption.None, false ); - const id = 0; await plugin.connect(alice).vote(id, VoteOption.Yes, false); @@ -2623,6 +3299,54 @@ describe('TokenVoting', function () { expect(await plugin.canExecute(id)).to.equal(false); }); + it('does not execute if support and participation are high enough but minimal approval is too low', async () => { + const { + alice, + bob, + carol, + initializedPlugin: plugin, + dummyMetadata, + dummyActions, + } = await loadFixture(localFixture); + + const endDate = (await time.latest()) + TIME.DAY; + const id = await createProposalId( + plugin.address, + dummyActions, + dummyMetadata + ); + + await plugin[CREATE_PROPOSAL_SIGNATURE]( + dummyMetadata, + dummyActions, + 0, + 0, + endDate, + VoteOption.None, + false + ); + + await voteWithSigners(plugin, id, { + yes: [alice, carol], // 20 votes + no: [bob], // 10 votes + abstain: [], // 0 votes + }); + + expect(await plugin.isMinParticipationReached(id)).to.be.true; + expect(await plugin.isSupportThresholdReachedEarly(id)).to.be.false; + expect(await plugin.isMinApprovalReached(id)).to.be.false; + + expect(await plugin.canExecute(id)).to.be.false; + + await time.increaseTo(endDate); + + expect(await plugin.isMinParticipationReached(id)).to.be.true; + expect(await plugin.isSupportThresholdReached(id)).to.be.true; + expect(await plugin.isMinApprovalReached(id)).to.be.false; + + expect(await plugin.canExecute(id)).to.equal(false); + }); + it('does not execute if participation is high enough but support is too low', async () => { const { alice, @@ -2633,8 +3357,13 @@ describe('TokenVoting', function () { dummyActions, } = await loadFixture(localFixture); const endDate = (await time.latest()) + TIME.DAY; + const id = await createProposalId( + plugin.address, + dummyActions, + dummyMetadata + ); - await plugin.createProposal( + await plugin[CREATE_PROPOSAL_SIGNATURE]( dummyMetadata, dummyActions, 0, @@ -2643,7 +3372,6 @@ describe('TokenVoting', function () { VoteOption.None, false ); - const id = 0; await voteWithSigners(plugin, id, { yes: [alice], // 10 votes @@ -2662,7 +3390,56 @@ describe('TokenVoting', function () { expect(await plugin.canExecute(id)).to.equal(false); }); - it('executes after the duration if participation and support are met', async () => { + it('does not execute if participation and minimal approval are high enough but support is too low', async () => { + const { + alice, + bob, + carol, + dave, + eve, + frank, + grace, + initializedPlugin: plugin, + dummyMetadata, + dummyActions, + } = await loadFixture(localFixture); + const endDate = (await time.latest()) + TIME.DAY; + const id = await createProposalId( + plugin.address, + dummyActions, + dummyMetadata + ); + + await plugin[CREATE_PROPOSAL_SIGNATURE]( + dummyMetadata, + dummyActions, + 0, + 0, + endDate, + VoteOption.None, + false + ); + + await voteWithSigners(plugin, id, { + yes: [alice, dave, eve], // 30 votes + no: [bob, carol, frank, grace], // 40 votes + abstain: [], // 0 votes + }); + + expect(await plugin.isMinParticipationReached(id)).to.be.true; + expect(await plugin.isMinApprovalReached(id)).to.be.true; + expect(await plugin.isSupportThresholdReachedEarly(id)).to.be.false; + expect(await plugin.canExecute(id)).to.equal(false); + + await time.increaseTo(endDate); + + expect(await plugin.isMinParticipationReached(id)).to.be.true; + expect(await plugin.isMinApprovalReached(id)).to.be.true; + expect(await plugin.isSupportThresholdReached(id)).to.be.false; + expect(await plugin.canExecute(id)).to.equal(false); + }); + + it('executes after the duration if participation, support and minimal approval are met', async () => { const { alice, bob, @@ -2672,8 +3449,13 @@ describe('TokenVoting', function () { dummyActions, } = await loadFixture(localFixture); const endDate = (await time.latest()) + TIME.DAY; + const id = await createProposalId( + plugin.address, + dummyActions, + dummyMetadata + ); - await plugin.createProposal( + await plugin[CREATE_PROPOSAL_SIGNATURE]( dummyMetadata, dummyActions, 0, @@ -2682,7 +3464,6 @@ describe('TokenVoting', function () { VoteOption.None, false ); - const id = 0; await voteWithSigners(plugin, id, { yes: [alice, bob, carol], // 30 votes @@ -2691,6 +3472,8 @@ describe('TokenVoting', function () { }); expect(await plugin.isMinParticipationReached(id)).to.be.true; + expect(await plugin.isMinApprovalReached(id)).to.be.true; + expect(await plugin.isSupportThresholdReached(id)).to.be.true; expect(await plugin.isSupportThresholdReachedEarly(id)).to.be.false; expect(await plugin.canExecute(id)).to.equal(false); @@ -2698,10 +3481,11 @@ describe('TokenVoting', function () { expect(await plugin.isMinParticipationReached(id)).to.be.true; expect(await plugin.isSupportThresholdReached(id)).to.be.true; + expect(await plugin.isMinApprovalReached(id)).to.be.true; expect(await plugin.canExecute(id)).to.equal(true); }); - it('executes early if participation and support are met and the vote outcome cannot change anymore', async () => { + it('executes early if participation, support and minimal approval are met and the vote outcome cannot change anymore', async () => { const { alice, bob, @@ -2714,8 +3498,13 @@ describe('TokenVoting', function () { dummyActions, } = await loadFixture(localFixture); const endDate = (await time.latest()) + TIME.DAY; + const id = await createProposalId( + plugin.address, + dummyActions, + dummyMetadata + ); - await plugin.createProposal( + await plugin[CREATE_PROPOSAL_SIGNATURE]( dummyMetadata, dummyActions, 0, @@ -2724,7 +3513,6 @@ describe('TokenVoting', function () { VoteOption.None, false ); - const id = 0; await voteWithSigners(plugin, id, { yes: [alice, bob, carol, dave, eve], // 50 votes @@ -2749,7 +3537,7 @@ describe('TokenVoting', function () { }); }); - describe('An edge case with `supportThreshold = 0%`, `minParticipation = 0%`, in early execution mode', async () => { + describe('An edge case with `supportThreshold = 0%`, `minParticipation = 0%`, `minApproval = 0%` in early execution mode', async () => { type LocalFixtureResult = { deployer: SignerWithAddress; alice: SignerWithAddress; @@ -2784,10 +3572,16 @@ describe('TokenVoting', function () { minProposerVotingPower: 0, }; + const minApproval = pctToRatio(0); + await initializedPlugin .connect(deployer) .updateVotingSettings(newVotingSettings); + await initializedPlugin + .connect(deployer) + .updateMinApprovals(minApproval); + return { deployer, alice, @@ -2807,8 +3601,13 @@ describe('TokenVoting', function () { dummyActions, } = await loadFixture(localFixture); const endDate = (await time.latest()) + TIME.DAY; + const id = await createProposalId( + plugin.address, + dummyActions, + dummyMetadata + ); - await plugin.createProposal( + await plugin[CREATE_PROPOSAL_SIGNATURE]( dummyMetadata, dummyActions, 0, @@ -2817,7 +3616,6 @@ describe('TokenVoting', function () { VoteOption.None, false ); - const id = 0; // does not execute early expect(await plugin.isMinParticipationReached(id)).to.be.true; @@ -2832,7 +3630,7 @@ describe('TokenVoting', function () { expect(await plugin.canExecute(id)).to.equal(false); }); - it('executes if participation and support are met', async () => { + it('executes if participation, support and min approval are met', async () => { const { alice, initializedPlugin: plugin, @@ -2840,8 +3638,13 @@ describe('TokenVoting', function () { dummyActions, } = await loadFixture(localFixture); const endDate = (await time.latest()) + TIME.DAY; + const id = await createProposalId( + plugin.address, + dummyActions, + dummyMetadata + ); - await plugin.createProposal( + await plugin[CREATE_PROPOSAL_SIGNATURE]( dummyMetadata, dummyActions, 0, @@ -2850,7 +3653,6 @@ describe('TokenVoting', function () { VoteOption.None, false ); - const id = 0; await plugin.connect(alice).vote(id, VoteOption.Yes, false); @@ -2868,7 +3670,7 @@ describe('TokenVoting', function () { }); }); - describe('An edge case with `supportThreshold = 99.9999%` and `minParticipation = 100%` in early execution mode', async () => { + describe('An edge case with `supportThreshold = 99.9999%`, `minParticipation = 100%` and `minApproval = 100%` in early execution mode', async () => { describe('token balances are in the magnitude of 10^18', async () => { type LocalFixtureResult = { deployer: SignerWithAddress; @@ -2917,10 +3719,16 @@ describe('TokenVoting', function () { minProposerVotingPower: 0, }; + const minApproval = pctToRatio(100); // the largest possible value + await initializedPlugin .connect(deployer) .updateVotingSettings(newVotingSettings); + await initializedPlugin + .connect(deployer) + .updateMinApprovals(minApproval); + return { deployer, alice, @@ -2945,9 +3753,14 @@ describe('TokenVoting', function () { dummyActions, } = await loadFixture(localFixture); const endDate = (await time.latest()) + TIME.DAY; + const id = await createProposalId( + plugin.address, + dummyActions, + dummyMetadata + ); // Create a proposal. - await plugin.createProposal( + await plugin[CREATE_PROPOSAL_SIGNATURE]( dummyMetadata, dummyActions, 0, @@ -2956,7 +3769,6 @@ describe('TokenVoting', function () { VoteOption.None, false ); - const id = 0; // Vote `Yes` with Alice who has 99.9999% of the voting power. await plugin.connect(alice).vote(id, VoteOption.Yes, false); @@ -3000,7 +3812,13 @@ describe('TokenVoting', function () { // Create a proposal. const endDate = (await time.latest()) + TIME.DAY; - await plugin.createProposal( + const id = await createProposalId( + plugin.address, + dummyActions, + dummyMetadata + ); + + await plugin[CREATE_PROPOSAL_SIGNATURE]( dummyMetadata, dummyActions, 0, @@ -3009,14 +3827,13 @@ describe('TokenVoting', function () { VoteOption.None, false ); - const id = 0; //Vote `Yes` with Alice who has 99.9999% of the total supply. await plugin.connect(alice).vote(id, VoteOption.Yes, false); // Vote `yes` with Carol who has close to 0.0001% of the total supply (only 1 vote is missing that Bob has). await plugin.connect(carol).vote(id, VoteOption.Yes, false); - // Check that only 1 vote is missing to meet 100% particpiation. + // Check that only 1 vote is missing to meet 100% participation. const proposal = await plugin.getProposal(id); const tally = proposal.tally; const totalVotingPower = await plugin.totalVotingPower( @@ -3103,8 +3920,13 @@ describe('TokenVoting', function () { dummyActions, } = await loadFixture(localFixture); const endDate = (await time.latest()) + TIME.DAY; + const id = await createProposalId( + plugin.address, + dummyActions, + dummyMetadata + ); - await plugin.createProposal( + await plugin[CREATE_PROPOSAL_SIGNATURE]( dummyMetadata, dummyActions, 0, @@ -3113,7 +3935,6 @@ describe('TokenVoting', function () { VoteOption.None, false ); - const id = 0; await plugin.connect(alice).vote(id, VoteOption.Yes, false); @@ -3145,8 +3966,13 @@ describe('TokenVoting', function () { dummyActions, } = await loadFixture(localFixture); const endDate = (await time.latest()) + TIME.DAY; + const id = await createProposalId( + plugin.address, + dummyActions, + dummyMetadata + ); - await plugin.createProposal( + await plugin[CREATE_PROPOSAL_SIGNATURE]( dummyMetadata, dummyActions, 0, @@ -3155,12 +3981,11 @@ describe('TokenVoting', function () { VoteOption.None, false ); - const id = 0; await plugin.connect(alice).vote(id, VoteOption.Yes, false); expect(await plugin.isMinParticipationReached(id)).to.be.false; - // 1 vote is still missing to meet particpiation = 100% + // 1 vote is still missing to meet participation = 100% const proposal = await plugin.getProposal(id); const tally = proposal.tally; const totalVotingPower = await plugin.totalVotingPower( @@ -3212,8 +4037,14 @@ describe('TokenVoting', function () { // Check that Alice has one more vote than Bob. expect(balanceAlice.sub(balanceBob)).to.eq(1); + const id = await createProposalId( + plugin.address, + dummyActions, + dummyMetadata + ); + // Create a proposal. - await plugin.createProposal( + await plugin[CREATE_PROPOSAL_SIGNATURE]( dummyMetadata, dummyActions, 0, @@ -3222,7 +4053,6 @@ describe('TokenVoting', function () { VoteOption.None, false ); - const id = 0; // Check that Alice and Bob's balances add up to the total voting power. const snapshotBlock = (await plugin.getProposal(id)).parameters diff --git a/packages/contracts/test/10_unit-testing/12_plugin-setup.ts b/packages/contracts/test/10_unit-testing/12_plugin-setup.ts index b73b5d94..2fb8c3a0 100644 --- a/packages/contracts/test/10_unit-testing/12_plugin-setup.ts +++ b/packages/contracts/test/10_unit-testing/12_plugin-setup.ts @@ -7,12 +7,23 @@ import { GovernanceERC20__factory, GovernanceWrappedERC20, GovernanceWrappedERC20__factory, + IERC20Upgradeable__factory, + IVotesUpgradeable__factory, } from '../../typechain'; +import {plugins} from '../../typechain/@aragon/osx-v1.0.0'; +import {IGovernanceWrappedERC20__factory} from '../../typechain/factories/src/ERC20/governance'; import {MajorityVotingBase} from '../../typechain/src/MajorityVotingBase'; import { + ANY_ADDR, + CREATE_PROPOSAL_PERMISSION_ID, MINT_PERMISSION_ID, + SET_TARGET_CONFIG_PERMISSION_ID, + TargetConfig, + SET_METADATA_PERMISSION_ID, UPDATE_VOTING_SETTINGS_PERMISSION_ID, + EXECUTE_PROPOSAL_PERMISSION_ID, } from '../test-utils/token-voting-constants'; +import {Operation as Op} from '../test-utils/token-voting-constants'; import { TokenVoting__factory, TokenVotingSetup, @@ -21,6 +32,7 @@ import { import {VotingMode} from '../test-utils/voting-helpers'; import { DAO_PERMISSIONS, + getInterfaceId, Operation, PLUGIN_UUPS_UPGRADEABLE_PERMISSIONS, } from '@aragon/osx-commons-sdk'; @@ -28,6 +40,7 @@ import {getNamedTypesFromMetadata} from '@aragon/osx-commons-sdk'; import {TIME} from '@aragon/osx-commons-sdk'; import {pctToRatio} from '@aragon/osx-commons-sdk'; import {DAO} from '@aragon/osx-ethers'; +import {BigNumber} from '@ethersproject/bignumber'; import {loadFixture} from '@nomicfoundation/hardhat-network-helpers'; import {SignerWithAddress} from '@nomiclabs/hardhat-ethers/signers'; import {expect} from 'chai'; @@ -48,9 +61,16 @@ type FixtureResult = { name: string; symbol: string; }; + defaultTargetConfig: TargetConfig; defaultMintSettings: GovernanceERC20.MintSettingsStruct; + defaultMinApproval: BigNumber; + defaultMetadata: string; + updateMinApproval: BigNumber; + updateMetadata: string; + updateTargetConfig: TargetConfig; prepareInstallationInputs: string; prepareUninstallationInputs: string; + prepareUpdateBuild3Inputs: string; dao: DAO; governanceERC20Base: GovernanceERC20; governanceWrappedERC20Base: GovernanceWrappedERC20; @@ -89,6 +109,13 @@ async function fixture(): Promise { governanceWrappedERC20Base.address ); + const defaultTargetConfig: TargetConfig = { + target: dao.address, + operation: Op.call, + }; + + const defaultMetadata: string = '0x11'; + const defaultVotingSettings: MajorityVotingBase.VotingSettingsStruct = { votingMode: VotingMode.EarlyExecution, supportThreshold: pctToRatio(50), @@ -97,6 +124,8 @@ async function fixture(): Promise { minProposerVotingPower: 0, }; + const defaultMinApproval = pctToRatio(30); + // Provide installation inputs const prepareInstallationInputs = ethers.utils.defaultAbiCoder.encode( getNamedTypesFromMetadata( @@ -106,6 +135,9 @@ async function fixture(): Promise { Object.values(defaultVotingSettings), Object.values(defaultTokenSettings), Object.values(defaultMintSettings), + Object.values(defaultTargetConfig), + defaultMinApproval, + defaultMetadata, ] ); @@ -117,6 +149,21 @@ async function fixture(): Promise { [] ); + const updateMinApproval = pctToRatio(35); + const updateTargetConfig: TargetConfig = { + target: pluginSetup.address, + operation: Op.call, + }; + const updateMetadata: string = '0x11'; + + // Provide update inputs + const prepareUpdateBuild3Inputs = ethers.utils.defaultAbiCoder.encode( + getNamedTypesFromMetadata( + METADATA.build.pluginSetup.prepareUpdate[3].inputs + ), + [updateMinApproval, updateTargetConfig, updateMetadata] + ); + return { deployer, alice, @@ -126,8 +173,15 @@ async function fixture(): Promise { defaultVotingSettings, defaultTokenSettings, defaultMintSettings, + defaultMinApproval, + defaultMetadata, + defaultTargetConfig, + updateMinApproval, + updateTargetConfig, + updateMetadata, prepareInstallationInputs, prepareUninstallationInputs, + prepareUpdateBuild3Inputs, dao, governanceERC20Base, governanceWrappedERC20Base, @@ -155,18 +209,17 @@ describe('TokenVotingSetup', function () { describe('prepareInstallation', async () => { it('fails if data is empty, or not of minimum length', async () => { - const {pluginSetup, dao, prepareInstallationInputs} = await loadFixture( - fixture - ); + const {pluginSetup, dao, prepareInstallationInputs, defaultMetadata} = + await loadFixture(fixture); // Try calling `prepareInstallation` without input data. await expect(pluginSetup.prepareInstallation(dao.address, [])).to.be .reverted; - // Try calling `prepareInstallation` without input data of wrong length. + // Try calling `prepareInstallation` with input data of wrong length. const trimmedData = prepareInstallationInputs.substring( 0, - prepareInstallationInputs.length - 2 + prepareInstallationInputs.length - 100 ); await expect(pluginSetup.prepareInstallation(dao.address, trimmedData)).to .be.reverted; @@ -184,6 +237,9 @@ describe('TokenVotingSetup', function () { dao, defaultVotingSettings, defaultTokenSettings, + defaultMinApproval, + defaultTargetConfig, + defaultMetadata, } = await loadFixture(fixture); const receivers: string[] = [AddressZero]; @@ -196,6 +252,9 @@ describe('TokenVotingSetup', function () { Object.values(defaultVotingSettings), Object.values(defaultTokenSettings), {receivers, amounts}, + defaultTargetConfig, + defaultMinApproval, + defaultMetadata, ] ); @@ -226,6 +285,9 @@ describe('TokenVotingSetup', function () { dao, defaultVotingSettings, defaultMintSettings, + defaultMinApproval, + defaultTargetConfig, + defaultMetadata, } = await loadFixture(fixture); const data = abiCoder.encode( @@ -236,6 +298,9 @@ describe('TokenVotingSetup', function () { Object.values(defaultVotingSettings), [alice.address, '', ''], // Instead of a token address, we pass Alice's address here. Object.values(defaultMintSettings), + defaultTargetConfig, + defaultMinApproval, + defaultMetadata, ] ); @@ -245,8 +310,15 @@ describe('TokenVotingSetup', function () { }); it('fails if passed token address is not ERC20', async () => { - const {pluginSetup, dao, defaultVotingSettings, defaultMintSettings} = - await loadFixture(fixture); + const { + pluginSetup, + dao, + defaultVotingSettings, + defaultMintSettings, + defaultMinApproval, + defaultTargetConfig, + defaultMetadata, + } = await loadFixture(fixture); const data = abiCoder.encode( getNamedTypesFromMetadata( @@ -256,6 +328,9 @@ describe('TokenVotingSetup', function () { Object.values(defaultVotingSettings), [dao.address, '', ''], Object.values(defaultMintSettings), + defaultTargetConfig, + defaultMinApproval, + defaultMetadata, ] ); @@ -272,6 +347,9 @@ describe('TokenVotingSetup', function () { defaultTokenSettings, defaultMintSettings, erc20, + defaultMinApproval, + defaultTargetConfig, + defaultMetadata, } = await loadFixture(fixture); const nonce = await ethers.provider.getTransactionCount( @@ -285,6 +363,10 @@ describe('TokenVotingSetup', function () { from: pluginSetup.address, nonce: nonce + 1, }); + const anticipatedCondition = ethers.utils.getContractAddress({ + from: pluginSetup.address, + nonce: nonce + 2, + }); const data = abiCoder.encode( getNamedTypesFromMetadata( @@ -299,6 +381,9 @@ describe('TokenVotingSetup', function () { defaultTokenSettings.symbol, ], Object.values(defaultMintSettings), + defaultTargetConfig, + defaultMinApproval, + defaultMetadata, ] ); @@ -307,10 +392,16 @@ describe('TokenVotingSetup', function () { preparedSetupData: {helpers, permissions}, } = await pluginSetup.callStatic.prepareInstallation(dao.address, data); + expect(await pluginSetup.supportsIVotesInterface(erc20.address)).to.be + .false; + expect(plugin).to.be.equal(anticipatedPluginAddress); - expect(helpers.length).to.be.equal(1); - expect(helpers).to.be.deep.equal([anticipatedWrappedTokenAddress]); - expect(permissions.length).to.be.equal(2); + expect(helpers.length).to.be.equal(2); + expect(helpers).to.be.deep.equal([ + anticipatedCondition, + anticipatedWrappedTokenAddress, + ]); + expect(permissions.length).to.be.equal(6); expect(permissions).to.deep.equal([ [ Operation.Grant, @@ -326,6 +417,34 @@ describe('TokenVotingSetup', function () { AddressZero, DAO_PERMISSIONS.EXECUTE_PERMISSION_ID, ], + [ + Operation.GrantWithCondition, + plugin, + ANY_ADDR, + anticipatedCondition, + CREATE_PROPOSAL_PERMISSION_ID, + ], + [ + Operation.Grant, + plugin, + dao.address, + AddressZero, + SET_TARGET_CONFIG_PERMISSION_ID, + ], + [ + Operation.Grant, + plugin, + dao.address, + AddressZero, + SET_METADATA_PERMISSION_ID, + ], + [ + Operation.Grant, + plugin, + ANY_ADDR, + AddressZero, + EXECUTE_PROPOSAL_PERMISSION_ID, + ], ]); }); @@ -337,6 +456,9 @@ describe('TokenVotingSetup', function () { defaultVotingSettings, defaultMintSettings, erc20, + defaultTargetConfig, + defaultMinApproval, + defaultMetadata, } = await loadFixture(fixture); const nonce = await ethers.provider.getTransactionCount( @@ -355,6 +477,9 @@ describe('TokenVotingSetup', function () { Object.values(defaultVotingSettings), [erc20.address, 'myName', 'mySymb'], Object.values(defaultMintSettings), + defaultTargetConfig, + defaultMinApproval, + defaultMetadata, ] ); @@ -373,6 +498,36 @@ describe('TokenVotingSetup', function () { expect(await governanceWrappedERC20Contract.underlying()).to.be.equal( erc20.address ); + + expect(await pluginSetup.supportsIVotesInterface(erc20.address)).to.be + .false; + + // If a token address is not passed, it must have deployed GovernanceERC20. + const ivotesInterfaceId = getInterfaceId( + IVotesUpgradeable__factory.createInterface() + ); + const iERC20InterfaceId = getInterfaceId( + IERC20Upgradeable__factory.createInterface() + ); + const iGovernanceWrappedERC20 = getInterfaceId( + IGovernanceWrappedERC20__factory.createInterface() + ); + + expect( + await governanceWrappedERC20Contract.supportsInterface( + ivotesInterfaceId + ) + ).to.be.true; + expect( + await governanceWrappedERC20Contract.supportsInterface( + iERC20InterfaceId + ) + ).to.be.true; + expect( + await governanceWrappedERC20Contract.supportsInterface( + iGovernanceWrappedERC20 + ) + ).to.be.true; }); it('correctly returns plugin, helpers and permissions, when a governance token address is supplied', async () => { @@ -382,6 +537,9 @@ describe('TokenVotingSetup', function () { dao, defaultVotingSettings, defaultMintSettings, + defaultMinApproval, + defaultTargetConfig, + defaultMetadata, } = await loadFixture(fixture); const governanceERC20 = await new GovernanceERC20__factory( @@ -397,6 +555,11 @@ describe('TokenVotingSetup', function () { nonce: nonce, }); + const anticipatedCondition = ethers.utils.getContractAddress({ + from: pluginSetup.address, + nonce: nonce + 1, + }); + const data = abiCoder.encode( getNamedTypesFromMetadata( METADATA.build.pluginSetup.prepareInstallation.inputs @@ -405,6 +568,9 @@ describe('TokenVotingSetup', function () { Object.values(defaultVotingSettings), [governanceERC20.address, '', ''], Object.values(defaultMintSettings), + defaultTargetConfig, + defaultMinApproval, + defaultMetadata, ] ); @@ -413,10 +579,16 @@ describe('TokenVotingSetup', function () { preparedSetupData: {helpers, permissions}, } = await pluginSetup.callStatic.prepareInstallation(dao.address, data); + expect(await pluginSetup.supportsIVotesInterface(governanceERC20.address)) + .to.be.true; + expect(plugin).to.be.equal(anticipatedPluginAddress); - expect(helpers.length).to.be.equal(1); - expect(helpers).to.be.deep.equal([governanceERC20.address]); - expect(permissions.length).to.be.equal(2); + expect(helpers.length).to.be.equal(2); + expect(helpers).to.be.deep.equal([ + anticipatedCondition, + governanceERC20.address, + ]); + expect(permissions.length).to.be.equal(6); expect(permissions).to.deep.equal([ [ Operation.Grant, @@ -432,6 +604,34 @@ describe('TokenVotingSetup', function () { AddressZero, DAO_PERMISSIONS.EXECUTE_PERMISSION_ID, ], + [ + Operation.GrantWithCondition, + plugin, + ANY_ADDR, + anticipatedCondition, + CREATE_PROPOSAL_PERMISSION_ID, + ], + [ + Operation.Grant, + plugin, + dao.address, + AddressZero, + SET_TARGET_CONFIG_PERMISSION_ID, + ], + [ + Operation.Grant, + plugin, + dao.address, + AddressZero, + SET_METADATA_PERMISSION_ID, + ], + [ + Operation.Grant, + plugin, + ANY_ADDR, + AddressZero, + EXECUTE_PROPOSAL_PERMISSION_ID, + ], ]); }); @@ -439,9 +639,8 @@ describe('TokenVotingSetup', function () { const { pluginSetup, dao, - defaultVotingSettings, defaultTokenSettings, - defaultMintSettings, + prepareInstallationInputs, } = await loadFixture(fixture); const nonce = await ethers.provider.getTransactionCount( @@ -457,26 +656,30 @@ describe('TokenVotingSetup', function () { nonce: nonce + 1, }); - const data = abiCoder.encode( - getNamedTypesFromMetadata( - METADATA.build.pluginSetup.prepareInstallation.inputs - ), - [ - Object.values(defaultVotingSettings), - Object.values(defaultTokenSettings), - Object.values(defaultMintSettings), - ] - ); + const anticipatedCondition = ethers.utils.getContractAddress({ + from: pluginSetup.address, + nonce: nonce + 2, + }); const { plugin, preparedSetupData: {helpers, permissions}, - } = await pluginSetup.callStatic.prepareInstallation(dao.address, data); + } = await pluginSetup.callStatic.prepareInstallation( + dao.address, + prepareInstallationInputs + ); + + expect( + await pluginSetup.supportsIVotesInterface(defaultTokenSettings.addr) + ).to.be.false; expect(plugin).to.be.equal(anticipatedPluginAddress); - expect(helpers.length).to.be.equal(1); - expect(helpers).to.be.deep.equal([anticipatedTokenAddress]); - expect(permissions.length).to.be.equal(3); + expect(helpers.length).to.be.equal(2); + expect(helpers).to.be.deep.equal([ + anticipatedCondition, + anticipatedTokenAddress, + ]); + expect(permissions.length).to.be.equal(7); expect(permissions).to.deep.equal([ [ Operation.Grant, @@ -492,6 +695,34 @@ describe('TokenVotingSetup', function () { AddressZero, DAO_PERMISSIONS.EXECUTE_PERMISSION_ID, ], + [ + Operation.GrantWithCondition, + plugin, + ANY_ADDR, + anticipatedCondition, + CREATE_PROPOSAL_PERMISSION_ID, + ], + [ + Operation.Grant, + plugin, + dao.address, + AddressZero, + SET_TARGET_CONFIG_PERMISSION_ID, + ], + [ + Operation.Grant, + plugin, + dao.address, + AddressZero, + SET_METADATA_PERMISSION_ID, + ], + [ + Operation.Grant, + plugin, + ANY_ADDR, + AddressZero, + EXECUTE_PROPOSAL_PERMISSION_ID, + ], [ Operation.Grant, anticipatedTokenAddress, @@ -510,6 +741,9 @@ describe('TokenVotingSetup', function () { defaultVotingSettings, defaultTokenSettings, defaultMintSettings, + defaultMinApproval, + defaultTargetConfig, + defaultMetadata, } = await loadFixture(fixture); const daoAddress = dao.address; @@ -522,6 +756,9 @@ describe('TokenVotingSetup', function () { Object.values(defaultVotingSettings), [AddressZero, defaultTokenSettings.name, defaultTokenSettings.symbol], Object.values(defaultMintSettings), + defaultTargetConfig, + defaultMinApproval, + defaultMetadata, ] ); @@ -562,6 +799,11 @@ describe('TokenVotingSetup', function () { anticipatedTokenAddress ); + expect(await tokenVoting.getTargetConfig()).to.deep.equal([ + defaultTargetConfig.target, + defaultTargetConfig.operation, + ]); + // check helpers const token = new GovernanceERC20__factory(deployer).attach( anticipatedTokenAddress @@ -570,13 +812,42 @@ describe('TokenVotingSetup', function () { expect(await token.dao()).to.be.equal(daoAddress); expect(await token.name()).to.be.equal(defaultTokenSettings.name); expect(await token.symbol()).to.be.equal(defaultTokenSettings.symbol); + + // If a token address is not passed, it must have deployed GovernanceERC20. + const ivotesInterfaceId = getInterfaceId( + IVotesUpgradeable__factory.createInterface() + ); + const iERC20InterfaceId = getInterfaceId( + IERC20Upgradeable__factory.createInterface() + ); + + expect(await token.supportsInterface(ivotesInterfaceId)).to.be.true; + expect(await token.supportsInterface(iERC20InterfaceId)).to.be.true; }); }); describe('prepareUpdate', async () => { it('returns the permissions expected for the update from build 1', async () => { - const {pluginSetup, dao} = await loadFixture(fixture); - const plugin = ethers.Wallet.createRandom().address; + const { + pluginSetup, + dao, + prepareInstallationInputs, + prepareUpdateBuild3Inputs, + } = await loadFixture(fixture); + + const nonce = await ethers.provider.getTransactionCount( + pluginSetup.address + ); + + const plugin = ethers.utils.getContractAddress({ + from: pluginSetup.address, + nonce: nonce + 1, + }); + + await pluginSetup.prepareInstallation( + dao.address, + prepareInstallationInputs + ); // Make a static call to check that the plugin update data being returned is correct. const { @@ -587,14 +858,29 @@ describe('TokenVotingSetup', function () { ethers.Wallet.createRandom().address, ethers.Wallet.createRandom().address, ], - data: [], + data: prepareUpdateBuild3Inputs, plugin, }); // Check the return data. - expect(initData).to.be.eq('0x'); - expect(helpers).to.be.eql([]); - expect(permissions.length).to.be.eql(1); + expect(initData).to.be.eq( + TokenVoting__factory.createInterface().encodeFunctionData( + 'initializeFrom', + [1, prepareUpdateBuild3Inputs] + ) + ); + + const currentNonce = await ethers.provider.getTransactionCount( + pluginSetup.address + ); + + const anticipatedCondition = ethers.utils.getContractAddress({ + from: pluginSetup.address, + nonce: currentNonce, + }); + + expect(helpers).to.deep.equal([anticipatedCondition]); + expect(permissions.length).to.be.eql(5); expect(permissions).to.deep.equal([ [ Operation.Revoke, @@ -603,12 +889,58 @@ describe('TokenVotingSetup', function () { AddressZero, PLUGIN_UUPS_UPGRADEABLE_PERMISSIONS.UPGRADE_PLUGIN_PERMISSION_ID, ], + [ + Operation.GrantWithCondition, + plugin, + ANY_ADDR, + anticipatedCondition, + CREATE_PROPOSAL_PERMISSION_ID, + ], + [ + Operation.Grant, + plugin, + dao.address, + AddressZero, + SET_TARGET_CONFIG_PERMISSION_ID, + ], + [ + Operation.Grant, + plugin, + dao.address, + AddressZero, + SET_METADATA_PERMISSION_ID, + ], + [ + Operation.Grant, + plugin, + ANY_ADDR, + AddressZero, + EXECUTE_PROPOSAL_PERMISSION_ID, + ], ]); }); it('returns the permissions expected for the update from build 2', async () => { - const {pluginSetup, dao} = await loadFixture(fixture); - const plugin = ethers.Wallet.createRandom().address; + const { + pluginSetup, + dao, + prepareInstallationInputs, + prepareUpdateBuild3Inputs, + } = await loadFixture(fixture); + + const nonce = await ethers.provider.getTransactionCount( + pluginSetup.address + ); + + const plugin = ethers.utils.getContractAddress({ + from: pluginSetup.address, + nonce: nonce + 1, + }); + + await pluginSetup.prepareInstallation( + dao.address, + prepareInstallationInputs + ); // Make a static call to check that the plugin update data being returned is correct. const { @@ -619,14 +951,28 @@ describe('TokenVotingSetup', function () { ethers.Wallet.createRandom().address, ethers.Wallet.createRandom().address, ], - data: [], + data: prepareUpdateBuild3Inputs, plugin, }); + const currentNonce = await ethers.provider.getTransactionCount( + pluginSetup.address + ); + + const anticipatedCondition = ethers.utils.getContractAddress({ + from: pluginSetup.address, + nonce: currentNonce, + }); + // Check the return data. - expect(initData).to.be.eq('0x'); - expect(helpers).to.be.eql([]); - expect(permissions.length).to.be.eql(1); + expect(initData).to.be.eq( + TokenVoting__factory.createInterface().encodeFunctionData( + 'initializeFrom', + [2, prepareUpdateBuild3Inputs] + ) + ); + expect(helpers).to.be.eql([anticipatedCondition]); + expect(permissions.length).to.be.eql(5); expect(permissions).to.deep.equal([ [ Operation.Revoke, @@ -635,39 +981,64 @@ describe('TokenVotingSetup', function () { AddressZero, PLUGIN_UUPS_UPGRADEABLE_PERMISSIONS.UPGRADE_PLUGIN_PERMISSION_ID, ], + [ + Operation.GrantWithCondition, + plugin, + ANY_ADDR, + anticipatedCondition, + CREATE_PROPOSAL_PERMISSION_ID, + ], + [ + Operation.Grant, + plugin, + dao.address, + AddressZero, + SET_TARGET_CONFIG_PERMISSION_ID, + ], + [ + Operation.Grant, + plugin, + dao.address, + AddressZero, + SET_METADATA_PERMISSION_ID, + ], + [ + Operation.Grant, + plugin, + ANY_ADDR, + AddressZero, + EXECUTE_PROPOSAL_PERMISSION_ID, + ], ]); }); - }); - describe('prepareUninstallation', async () => { - it('fails when the wrong number of helpers is supplied', async () => { - const {pluginSetup, dao, prepareUninstallationInputs} = await loadFixture( + it('returns the permissions expected for the update from build 3 (empty list)', async () => { + const {pluginSetup, dao, prepareUpdateBuild3Inputs} = await loadFixture( fixture ); - const plugin = ethers.Wallet.createRandom().address; - await expect( - pluginSetup.prepareUninstallation(dao.address, { - plugin, - currentHelpers: [], - data: prepareUninstallationInputs, - }) - ) - .to.be.revertedWithCustomError(pluginSetup, 'WrongHelpersArrayLength') - .withArgs(0); + // Make a static call to check that the plugin update data being returned is correct. + const { + initData: initData, + preparedSetupData: {helpers, permissions}, + } = await pluginSetup.callStatic.prepareUpdate(dao.address, 3, { + currentHelpers: [ + ethers.Wallet.createRandom().address, + ethers.Wallet.createRandom().address, + ], + data: prepareUpdateBuild3Inputs, + plugin, + }); - await expect( - pluginSetup.prepareUninstallation(dao.address, { - plugin, - currentHelpers: [AddressZero, AddressZero, AddressZero], - data: prepareUninstallationInputs, - }) - ) - .to.be.revertedWithCustomError(pluginSetup, 'WrongHelpersArrayLength') - .withArgs(3); + // Check the return data. There should be no permission needed for build 3. + expect(initData).to.be.eq('0x'); + expect(permissions.length).to.be.equal(0); + expect(helpers.length).to.be.equal(0); }); + }); + describe('prepareUninstallation', async () => { it('correctly returns permissions, when the required number of helpers is supplied', async () => { const { deployer, @@ -723,9 +1094,37 @@ describe('TokenVotingSetup', function () { AddressZero, DAO_PERMISSIONS.EXECUTE_PERMISSION_ID, ], + [ + Operation.Revoke, + plugin, + dao.address, + AddressZero, + SET_TARGET_CONFIG_PERMISSION_ID, + ], + [ + Operation.Revoke, + plugin, + dao.address, + AddressZero, + SET_METADATA_PERMISSION_ID, + ], + [ + Operation.Revoke, + plugin, + ANY_ADDR, + AddressZero, + CREATE_PROPOSAL_PERMISSION_ID, + ], + [ + Operation.Revoke, + plugin, + ANY_ADDR, + AddressZero, + EXECUTE_PROPOSAL_PERMISSION_ID, + ], ]; - expect(permissions1.length).to.be.equal(2); + expect(permissions1.length).to.be.equal(6); expect(permissions1).to.deep.equal(essentialPermissions); const permissions2 = await pluginSetup.callStatic.prepareUninstallation( @@ -737,7 +1136,7 @@ describe('TokenVotingSetup', function () { } ); - expect(permissions2.length).to.be.equal(2); + expect(permissions2.length).to.be.equal(6); expect(permissions2).to.deep.equal(essentialPermissions); }); }); diff --git a/packages/contracts/test/10_unit-testing/base/11_majority-voting.ts b/packages/contracts/test/10_unit-testing/base/11_majority-voting.ts index 1f8cb4fa..b110112b 100644 --- a/packages/contracts/test/10_unit-testing/base/11_majority-voting.ts +++ b/packages/contracts/test/10_unit-testing/base/11_majority-voting.ts @@ -11,7 +11,17 @@ import { } from '../../../typechain'; import {ProxyCreatedEvent} from '../../../typechain/@aragon/osx-commons-contracts/src/utils/deployment/ProxyFactory'; import {MajorityVotingBase} from '../../../typechain/src/MajorityVotingBase'; -import {MAJORITY_VOTING_BASE_INTERFACE} from '../../test-utils/majority-voting-constants'; +import { + MAJORITY_VOTING_BASE_INTERFACE, + MAJORITY_VOTING_BASE_OLD_INTERFACE, +} from '../../test-utils/majority-voting-constants'; +import { + Operation, + SET_TARGET_CONFIG_PERMISSION_ID, + TargetConfig, + UPDATE_VOTING_SETTINGS_PERMISSION_ID, +} from '../../test-utils/token-voting-constants'; +import {IMajorityVoting_V1_3_0__factory} from '../../test-utils/typechain-versions'; import {VotingMode} from '../../test-utils/voting-helpers'; import {TIME, findEvent} from '@aragon/osx-commons-sdk'; import {getInterfaceId} from '@aragon/osx-commons-sdk'; @@ -19,6 +29,7 @@ import {pctToRatio} from '@aragon/osx-commons-sdk'; import {DAO} from '@aragon/osx-ethers'; import {SignerWithAddress} from '@nomiclabs/hardhat-ethers/signers'; import {expect} from 'chai'; +import {BigNumber} from 'ethers'; import {ethers} from 'hardhat'; describe('MajorityVotingMock', function () { @@ -28,6 +39,9 @@ describe('MajorityVotingMock', function () { let dao: DAO; let votingSettings: MajorityVotingBase.VotingSettingsStruct; + let minApproval: BigNumber; + let targetConfig: TargetConfig; + let metadata: string; before(async () => { signers = await ethers.getSigners(); @@ -44,6 +58,14 @@ describe('MajorityVotingMock', function () { minDuration: TIME.HOUR, minProposerVotingPower: 0, }; + minApproval = pctToRatio(10); + + targetConfig = { + target: dao.address, + operation: Operation.call, + }; + + metadata = '0x11'; const pluginImplementation = await new MajorityVotingMock__factory( signers[0] @@ -70,10 +92,22 @@ describe('MajorityVotingMock', function () { describe('initialize', async () => { it('reverts if trying to re-initialize', async () => { - await votingBase.initializeMock(dao.address, votingSettings); + await votingBase.initializeMock( + dao.address, + votingSettings, + targetConfig, + minApproval, + metadata + ); await expect( - votingBase.initializeMock(dao.address, votingSettings) + votingBase.initializeMock( + dao.address, + votingSettings, + targetConfig, + minApproval, + metadata + ) ).to.be.revertedWith('Initializable: contract is already initialized'); }); }); @@ -113,6 +147,12 @@ describe('MajorityVotingMock', function () { .true; }); + it('supports the `IMajorityVoting` OLD interface', async () => { + const oldIface = IMajorityVoting_V1_3_0__factory.createInterface(); + expect(await votingBase.supportsInterface(getInterfaceId(oldIface))).to.be + .true; + }); + it('supports the `MajorityVotingBase` interface', async () => { expect( await votingBase.supportsInterface( @@ -120,11 +160,41 @@ describe('MajorityVotingMock', function () { ) ).to.be.true; }); + + it('supports the `MajorityVotingBase` OLD interface', async () => { + expect( + await votingBase.supportsInterface( + getInterfaceId(MAJORITY_VOTING_BASE_OLD_INTERFACE) + ) + ).to.be.true; + }); }); describe('updateVotingSettings', async () => { beforeEach(async () => { - await votingBase.initializeMock(dao.address, votingSettings); + await votingBase.initializeMock( + dao.address, + votingSettings, + targetConfig, + minApproval, + metadata + ); + }); + + it('reverts if caller is unauthorized', async () => { + const unauthorizedAddr = signers[5]; + await expect( + votingBase + .connect(unauthorizedAddr) + .updateVotingSettings(votingSettings) + ) + .to.be.revertedWithCustomError(votingBase, 'DaoUnauthorized') + .withArgs( + dao.address, + votingBase.address, + unauthorizedAddr.address, + UPDATE_VOTING_SETTINGS_PERMISSION_ID + ); }); it('reverts if the support threshold specified equals 100%', async () => { @@ -182,4 +252,76 @@ describe('MajorityVotingMock', function () { ); }); }); + + describe('updateMinApprovals', async () => { + beforeEach(async () => { + await votingBase.initializeMock( + dao.address, + votingSettings, + targetConfig, + minApproval, + metadata + ); + }); + + it('reverts if caller is unauthorized', async () => { + const unauthorizedAddr = signers[5]; + await expect( + votingBase.connect(unauthorizedAddr).updateMinApprovals(pctToRatio(10)) + ) + .to.be.revertedWithCustomError(votingBase, 'DaoUnauthorized') + .withArgs( + dao.address, + votingBase.address, + unauthorizedAddr.address, + UPDATE_VOTING_SETTINGS_PERMISSION_ID + ); + }); + + it('reverts if the minimum approval specified exceeds 100%', async () => { + minApproval = pctToRatio(1000); + + await expect(votingBase.updateMinApprovals(minApproval)) + .to.be.revertedWithCustomError(votingBase, 'RatioOutOfBounds') + .withArgs(pctToRatio(100), minApproval); + }); + + it('should change the minimum approval successfully', async () => { + await expect(votingBase.updateMinApprovals(minApproval)) + .to.emit(votingBase, 'VotingMinApprovalUpdated') + .withArgs(minApproval); + }); + }); + + describe('updateTargetConfig', async () => { + beforeEach(async () => { + await votingBase.initializeMock( + dao.address, + votingSettings, + targetConfig, + minApproval, + metadata + ); + + await dao.grant( + votingBase.address, + deployer.address, + SET_TARGET_CONFIG_PERMISSION_ID + ); + }); + + it('should change the minimum approval successfully', async () => { + const newTargetConfig = { + target: votingBase.address, + operation: Operation.delegatecall, + }; + + await votingBase.setTargetConfig(newTargetConfig); + + expect(await votingBase.getTargetConfig()).to.deep.equal([ + newTargetConfig.target, + Operation.delegatecall, + ]); + }); + }); }); diff --git a/packages/contracts/test/20_integration-testing/22_setup-processing.ts b/packages/contracts/test/20_integration-testing/22_setup-processing.ts index 3ad28a5a..a87295c0 100644 --- a/packages/contracts/test/20_integration-testing/22_setup-processing.ts +++ b/packages/contracts/test/20_integration-testing/22_setup-processing.ts @@ -2,6 +2,11 @@ import {METADATA, VERSION} from '../../plugin-settings'; import {GovernanceERC20} from '../../typechain'; import {MajorityVotingBase} from '../../typechain/src/MajorityVotingBase'; import {getProductionNetworkName, findPluginRepo} from '../../utils/helpers'; +import { + Operation, + TargetConfig, + latestInitializerVersion, +} from '../test-utils/token-voting-constants'; import { GovernanceERC20__factory, TokenVotingSetup, @@ -34,6 +39,7 @@ import { DAO, TokenVoting__factory, } from '@aragon/osx-ethers'; +import {BigNumber} from '@ethersproject/bignumber'; import {loadFixture} from '@nomicfoundation/hardhat-network-helpers'; import {SignerWithAddress} from '@nomiclabs/hardhat-ethers/signers'; import {expect} from 'chai'; @@ -57,6 +63,12 @@ type FixtureResult = { symbol: string; }; defaultMintSettings: GovernanceERC20.MintSettingsStruct; + defaultMinApproval: BigNumber; + defaultMetadata: string; + defaultTargetConfig: TargetConfig; + prepareInstallationInputs: string; + prepareInstallData: any; + prepareUpdateData: any; }; async function fixture(): Promise { @@ -123,6 +135,13 @@ async function fixture(): Promise { minProposerVotingPower: 0, }; + const defaultMinApproval = pctToRatio(30); + + const defaultTargetConfig = { + target: dao.address, + operation: Operation.call, + }; + const defaultTokenSettings = { addr: token.address, name: '', // only relevant if `address(0)` is provided as the token address @@ -134,6 +153,39 @@ async function fixture(): Promise { amounts: [], }; + const defaultMetadata: string = '0x11'; + + // Provide uninstallation inputs + const prepareInstallationInputs = ethers.utils.defaultAbiCoder.encode( + getNamedTypesFromMetadata( + METADATA.build.pluginSetup.prepareInstallation.inputs + ), + [ + Object.values(defaultVotingSettings), + Object.values(defaultTokenSettings), + Object.values(defaultMintSettings), + Object.values(defaultTargetConfig), + defaultMinApproval, + defaultMetadata, + ] + ); + + const prepareInstallData = { + votingSettings: Object.values(defaultVotingSettings), + tokenSettings: Object.values(defaultTokenSettings), + mintSettings: Object.values(defaultMintSettings), + targetConfig: Object.values(defaultTargetConfig), + defaultMinApproval, + defaultMetadata, + }; + + const prepareUpdateData = [ + defaultMinApproval, + defaultTargetConfig, + defaultMetadata, + ]; + // Provide update inputs + // const prepareUpdateBuild3Data = [defaultMinApproval]; return { deployer, alice, @@ -146,6 +198,12 @@ async function fixture(): Promise { defaultVotingSettings, defaultTokenSettings, defaultMintSettings, + defaultMinApproval, + defaultMetadata, + defaultTargetConfig, + prepareInstallationInputs, + prepareInstallData, + prepareUpdateData, }; } @@ -157,9 +215,7 @@ describe(`PluginSetup processing on network '${productionNetworkName}'`, functio psp, dao, pluginSetupRefLatestBuild, - defaultVotingSettings, - defaultTokenSettings, - defaultMintSettings, + prepareInstallationInputs, } = await loadFixture(fixture); // Grant deployer all required permissions @@ -181,25 +237,12 @@ describe(`PluginSetup processing on network '${productionNetworkName}'`, functio .connect(deployer) .grant(dao.address, psp.address, DAO_PERMISSIONS.ROOT_PERMISSION_ID); - const prepareInstallData = { - votingSettings: Object.values(defaultVotingSettings), - tokenSettings: Object.values(defaultTokenSettings), - mintSettings: Object.values(defaultMintSettings), - }; - - const prepareInstallInputType = getNamedTypesFromMetadata( - METADATA.build.pluginSetup.prepareInstallation.inputs - ); - const results = await installPLugin( deployer, psp, dao, pluginSetupRefLatestBuild, - ethers.utils.defaultAbiCoder.encode( - prepareInstallInputType, - Object.values(prepareInstallData) - ) + prepareInstallationInputs ); const plugin = TokenVoting__factory.connect( @@ -214,6 +257,8 @@ describe(`PluginSetup processing on network '${productionNetworkName}'`, functio expect(await plugin.isMember(alice.address)).to.be.false; expect(await plugin.isMember(deployer.address)).to.be.true; + const condition = results.preparedEvent.args.preparedSetupData.helpers[0]; + // Uninstall the current build. await uninstallPLugin( deployer, @@ -227,7 +272,7 @@ describe(`PluginSetup processing on network '${productionNetworkName}'`, functio ), [] ), - [pluginToken] + [condition, pluginToken] ); }); @@ -239,6 +284,9 @@ describe(`PluginSetup processing on network '${productionNetworkName}'`, functio dao, defaultVotingSettings, pluginSetupRefLatestBuild, + defaultMinApproval, + defaultMetadata, + defaultTargetConfig, } = await loadFixture(fixture); // Grant deployer all required permissions @@ -264,6 +312,9 @@ describe(`PluginSetup processing on network '${productionNetworkName}'`, functio votingSettings: Object.values(defaultVotingSettings), tokenSettings: [ethers.constants.AddressZero, 'testToken', 'TEST'], mintSettings: [[alice.address], ['1000']], + defaultTargetConfig, + defaultMinApproval, + defaultMetadata, }; const prepareInstallInputType = getNamedTypesFromMetadata( @@ -293,6 +344,8 @@ describe(`PluginSetup processing on network '${productionNetworkName}'`, functio expect(await plugin.isMember(alice.address)).to.be.true; expect(await plugin.isMember(deployer.address)).to.be.false; + const condition = results.preparedEvent.args.preparedSetupData.helpers[0]; + // Uninstall the current build. await uninstallPLugin( deployer, @@ -306,7 +359,7 @@ describe(`PluginSetup processing on network '${productionNetworkName}'`, functio ), [] ), - [pluginToken] + [condition, pluginToken] ); }); @@ -315,19 +368,12 @@ describe(`PluginSetup processing on network '${productionNetworkName}'`, functio deployer, psp, dao, - defaultVotingSettings, - defaultTokenSettings, - defaultMintSettings, pluginRepo, pluginSetupRefLatestBuild, + prepareInstallData, + prepareUpdateData, } = await loadFixture(fixture); - const prepareInstallData = { - votingSettings: Object.values(defaultVotingSettings), - tokenSettings: Object.values(defaultTokenSettings), - mintSettings: Object.values(defaultMintSettings), - }; - await updateFromBuildTest( dao, deployer, @@ -336,7 +382,8 @@ describe(`PluginSetup processing on network '${productionNetworkName}'`, functio pluginSetupRefLatestBuild, 1, Object.values(prepareInstallData), - [] + prepareUpdateData, + latestInitializerVersion ); }); @@ -347,16 +394,10 @@ describe(`PluginSetup processing on network '${productionNetworkName}'`, functio dao, pluginRepo, pluginSetupRefLatestBuild, - defaultVotingSettings, - defaultTokenSettings, - defaultMintSettings, - } = await loadFixture(fixture); - const prepareInstallData = { - votingSettings: Object.values(defaultVotingSettings), - tokenSettings: Object.values(defaultTokenSettings), - mintSettings: Object.values(defaultMintSettings), - }; + prepareInstallData, + prepareUpdateData, + } = await loadFixture(fixture); await updateFromBuildTest( dao, @@ -366,7 +407,8 @@ describe(`PluginSetup processing on network '${productionNetworkName}'`, functio pluginSetupRefLatestBuild, 2, Object.values(prepareInstallData), - [] + prepareUpdateData, + latestInitializerVersion ); }); }); diff --git a/packages/contracts/test/20_integration-testing/test-helpers.ts b/packages/contracts/test/20_integration-testing/test-helpers.ts index 06aad9d8..9e5cfe5e 100644 --- a/packages/contracts/test/20_integration-testing/test-helpers.ts +++ b/packages/contracts/test/20_integration-testing/test-helpers.ts @@ -6,6 +6,7 @@ import { } from '../../typechain'; import {ProxyCreatedEvent} from '../../typechain/@aragon/osx-commons-contracts/src/utils/deployment/ProxyFactory'; import {PluginUUPSUpgradeable__factory} from '../../typechain/factories/@aragon/osx-v1.0.0/core/plugin'; +import {latestPluginBuild} from '../test-utils/token-voting-constants'; import { DAO_PERMISSIONS, PLUGIN_SETUP_PROCESSOR_PERMISSIONS, @@ -27,6 +28,8 @@ import {expect} from 'chai'; import {ContractTransaction} from 'ethers'; import {ethers} from 'hardhat'; +const OZ_INITIALIZED_SLOT_POSITION = 0; + export async function installPLugin( signer: SignerWithAddress, psp: PluginSetupProcessor, @@ -242,7 +245,8 @@ export async function updateFromBuildTest( pluginSetupRefLatestBuild: PluginSetupProcessorStructs.PluginSetupRefStruct, build: number, installationInputs: any[], - updateInputs: any[] + updateInputs: any[], + reinitializedVersion: number ) { // Grant deployer all required permissions await dao @@ -324,7 +328,7 @@ export async function updateFromBuildTest( pluginSetupRefLatestBuild, ethers.utils.defaultAbiCoder.encode( getNamedTypesFromMetadata( - METADATA.build.pluginSetup.prepareUpdate[1].inputs + METADATA.build.pluginSetup.prepareUpdate[latestPluginBuild].inputs ), updateInputs ) @@ -342,6 +346,16 @@ export async function updateFromBuildTest( deployer ).implementation(); expect(await plugin.implementation()).to.equal(implementationLatestBuild); + + // check the plugin was reinitialized, OZs `_initialized` at storage slot [0] is correct + expect( + ethers.BigNumber.from( + await ethers.provider.getStorageAt( + plugin.address, + OZ_INITIALIZED_SLOT_POSITION + ) + ).toNumber() + ).to.equal(reinitializedVersion); } // TODO Move into OSX commons as part of Task OS-928. diff --git a/packages/contracts/test/30_regression-testing/31_upgradeability.ts b/packages/contracts/test/30_regression-testing/31_upgradeability.ts index 2786e0d5..df3a2b99 100644 --- a/packages/contracts/test/30_regression-testing/31_upgradeability.ts +++ b/packages/contracts/test/30_regression-testing/31_upgradeability.ts @@ -1,6 +1,12 @@ import {createDaoProxy} from '../20_integration-testing/test-helpers'; -import {TestGovernanceERC20} from '../../typechain'; +import {TestGovernanceERC20, TokenVoting} from '../../typechain'; import {MajorityVotingBase} from '../../typechain/src'; +import { + INITIALIZE_SIGNATURE, + latestInitializerVersion, + Operation, + TargetConfig, +} from '../test-utils/token-voting-constants'; import { TokenVoting_V1_0_0__factory, TokenVoting_V1_3_0__factory, @@ -21,8 +27,14 @@ import {DAO, TestGovernanceERC20__factory} from '@aragon/osx-ethers'; import {loadFixture} from '@nomicfoundation/hardhat-network-helpers'; import {SignerWithAddress} from '@nomiclabs/hardhat-ethers/signers'; import {expect} from 'chai'; +import {BigNumber} from 'ethers'; import {ethers} from 'hardhat'; +const AlreadyInitializedSignature = + TokenVoting__factory.createInterface().encodeErrorResult( + 'AlreadyInitialized' + ); + describe('Upgrades', () => { it('upgrades to a new implementation', async () => { const {deployer, alice, dao, defaultInitData} = await loadFixture(fixture); @@ -35,33 +47,66 @@ describe('Upgrades', () => { dao.address, defaultInitData.votingSettings, defaultInitData.token.address, + defaultInitData.targetConfig, + defaultInitData.minApproval, + defaultInitData.metadata, ], - 'initialize', + INITIALIZE_SIGNATURE, currentContractFactory, PLUGIN_UUPS_UPGRADEABLE_PERMISSIONS.UPGRADE_PLUGIN_PERMISSION_ID, dao ); }); - it('upgrades from v1.0.0', async () => { - const {deployer, alice, dao, defaultInitData} = await loadFixture(fixture); + it('upgrades from v1.0.0 with `initializeFrom`', async () => { + const {deployer, alice, dao, defaultInitData, encodedParamsForUpgrade} = + await loadFixture(fixture); const currentContractFactory = new TokenVoting__factory(deployer); const legacyContractFactory = new TokenVoting_V1_0_0__factory(deployer); - const {fromImplementation, toImplementation} = + const data = [ + deployer, + alice, + [ + dao.address, + defaultInitData.votingSettings, + defaultInitData.token.address, + ], + 'initialize', + legacyContractFactory, + currentContractFactory, + PLUGIN_UUPS_UPGRADEABLE_PERMISSIONS.UPGRADE_PLUGIN_PERMISSION_ID, + dao, + 'initialize', + [ + dao.address, + defaultInitData.votingSettings, + defaultInitData.token.address, + defaultInitData.targetConfig, + defaultInitData.minApproval, + defaultInitData.metadata, + ], + ]; + + // Ensure that on the `upgrade`, `initialize` can not be called. + try { + await deployAndUpgradeFromToCheck( + // @ts-ignore + ...data + ); + throw new Error(''); + } catch (err: any) { + expect(err.data).to.equal(AlreadyInitializedSignature); + } + + data[8] = 'initializeFrom'; + // @ts-ignore + data[9] = [latestInitializerVersion, encodedParamsForUpgrade]; + + const {proxy, fromImplementation, toImplementation} = await deployAndUpgradeFromToCheck( - deployer, - alice, - [ - dao.address, - defaultInitData.votingSettings, - defaultInitData.token.address, - ], - 'initialize', - legacyContractFactory, - currentContractFactory, - PLUGIN_UUPS_UPGRADEABLE_PERMISSIONS.UPGRADE_PLUGIN_PERMISSION_ID, - dao + // @ts-ignore + ...data ); expect(toImplementation).to.not.equal(fromImplementation); // The build did change @@ -75,31 +120,90 @@ describe('Upgrades', () => { expect(fromProtocolVersion).to.not.deep.equal(toProtocolVersion); expect(fromProtocolVersion).to.deep.equal([1, 0, 0]); - expect(toProtocolVersion).to.deep.equal([1, 4, 0]); // TODO Check this automatically + expect(toProtocolVersion).to.deep.equal([1, 4, 0]); + + // expects the plugin was reinitialized + const newTokenVoting = TokenVoting__factory.connect( + proxy.address, + deployer + ); + + expect(await newTokenVoting.minApproval()).to.equal( + defaultInitData.minApproval + ); + expect(await newTokenVoting.getMetadata()).to.equal( + defaultInitData.metadata + ); + expect(await newTokenVoting.getTargetConfig()).to.deep.equal([ + defaultInitData.targetConfig.target, + defaultInitData.targetConfig.operation, + ]); + + // `initializeFrom` was called on the upgrade, make sure + // `initialize` can not be called. + await expect( + proxy.initialize( + dao.address, + defaultInitData.votingSettings, + defaultInitData.token.address, + defaultInitData.targetConfig, + defaultInitData.minApproval, + defaultInitData.metadata + ) + ).to.be.revertedWithCustomError(proxy, 'AlreadyInitialized'); }); - it('from v1.3.0', async () => { - const {deployer, alice, dao, defaultInitData} = await loadFixture(fixture); + it('upgrades from v1.3.0 with `initializeFrom`', async () => { + const {deployer, alice, dao, defaultInitData, encodedParamsForUpgrade} = + await loadFixture(fixture); const currentContractFactory = new TokenVoting__factory(deployer); const legacyContractFactory = new TokenVoting_V1_3_0__factory(deployer); - const {fromImplementation, toImplementation} = + const data = [ + deployer, + alice, + [ + dao.address, + defaultInitData.votingSettings, + defaultInitData.token.address, + ], + 'initialize', + legacyContractFactory, + currentContractFactory, + PLUGIN_UUPS_UPGRADEABLE_PERMISSIONS.UPGRADE_PLUGIN_PERMISSION_ID, + dao, + 'initialize', + [ + dao.address, + defaultInitData.votingSettings, + defaultInitData.token.address, + defaultInitData.targetConfig, + defaultInitData.minApproval, + defaultInitData.metadata, + ], + ]; + + // Ensure that on the `upgrade`, `initialize` can not be called. + try { await deployAndUpgradeFromToCheck( - deployer, - alice, - [ - dao.address, - defaultInitData.votingSettings, - defaultInitData.token.address, - ], - 'initialize', - legacyContractFactory, - currentContractFactory, - PLUGIN_UUPS_UPGRADEABLE_PERMISSIONS.UPGRADE_PLUGIN_PERMISSION_ID, - dao + // @ts-ignore + ...data ); + throw new Error(''); + } catch (err: any) { + expect(err.data).to.equal(AlreadyInitializedSignature); + } + data[8] = 'initializeFrom'; + // @ts-ignore + data[9] = [latestInitializerVersion, encodedParamsForUpgrade]; - expect(toImplementation).to.not.equal(fromImplementation); + const {proxy, fromImplementation, toImplementation} = + await deployAndUpgradeFromToCheck( + // @ts-ignore + ...data + ); + + expect(toImplementation).to.not.equal(fromImplementation); // The build did change const fromProtocolVersion = await getProtocolVersion( legacyContractFactory.attach(fromImplementation) @@ -110,7 +214,37 @@ describe('Upgrades', () => { expect(fromProtocolVersion).to.not.deep.equal(toProtocolVersion); expect(fromProtocolVersion).to.deep.equal([1, 0, 0]); - expect(toProtocolVersion).to.deep.equal([1, 4, 0]); // TODO Check this automatically + expect(toProtocolVersion).to.deep.equal([1, 4, 0]); + + // expects the plugin was reinitialized + const newTokenVoting = TokenVoting__factory.connect( + proxy.address, + deployer + ); + + expect(await newTokenVoting.minApproval()).to.equal( + defaultInitData.minApproval + ); + expect(await newTokenVoting.getMetadata()).to.equal( + defaultInitData.metadata + ); + expect(await newTokenVoting.getTargetConfig()).to.deep.equal([ + defaultInitData.targetConfig.target, + defaultInitData.targetConfig.operation, + ]); + + // `initializeFrom` was called on the upgrade, make sure + // `initialize` can not be called. + await expect( + proxy.initialize( + dao.address, + defaultInitData.votingSettings, + defaultInitData.token.address, + defaultInitData.targetConfig, + defaultInitData.minApproval, + defaultInitData.metadata + ) + ).to.be.revertedWithCustomError(proxy, 'AlreadyInitialized'); }); }); @@ -119,12 +253,15 @@ type FixtureResult = { alice: SignerWithAddress; bob: SignerWithAddress; carol: SignerWithAddress; - dao: DAO; defaultInitData: { votingSettings: MajorityVotingBase.VotingSettingsStruct; token: TestGovernanceERC20; + minApproval: BigNumber; + targetConfig: TargetConfig; + metadata: string; }; + encodedParamsForUpgrade: string; }; async function fixture(): Promise { @@ -156,8 +293,25 @@ async function fixture(): Promise { const defaultInitData = { votingSettings, token: token, + minApproval: pctToRatio(10), + targetConfig: { + target: dao.address, + operation: Operation.call, + }, + metadata: '0x11', }; + // initial data is minApproval and targetConfig + const encodedParamsForUpgrade = ethers.utils.defaultAbiCoder.encode( + ['uint256', 'address', 'uint8', 'bytes'], + [ + defaultInitData.minApproval, + defaultInitData.targetConfig.target, + defaultInitData.targetConfig.operation, + defaultInitData.metadata, + ] + ); + return { deployer, alice, @@ -165,5 +319,6 @@ async function fixture(): Promise { carol, dao, defaultInitData, + encodedParamsForUpgrade, }; } diff --git a/packages/contracts/test/test-utils/majority-voting-constants.ts b/packages/contracts/test/test-utils/majority-voting-constants.ts index f58704f0..edc447c3 100644 --- a/packages/contracts/test/test-utils/majority-voting-constants.ts +++ b/packages/contracts/test/test-utils/majority-voting-constants.ts @@ -1,6 +1,17 @@ import {ethers} from 'hardhat'; export const MAJORITY_VOTING_BASE_INTERFACE = new ethers.utils.Interface([ + 'function minDuration()', + 'function minProposerVotingPower()', + 'function votingMode()', + 'function totalVotingPower(uint256)', + 'function getProposal(uint256)', + 'function updateVotingSettings(tuple(uint8,uint32,uint32,uint64,uint256))', + 'function updateMinApprovals(uint256)', + 'function createProposal(bytes,tuple(address,uint256,bytes)[],uint256,uint64,uint64,uint8,bool)', +]); + +export const MAJORITY_VOTING_BASE_OLD_INTERFACE = new ethers.utils.Interface([ 'function minDuration()', 'function minProposerVotingPower()', 'function votingMode()', diff --git a/packages/contracts/test/test-utils/token-voting-constants.ts b/packages/contracts/test/test-utils/token-voting-constants.ts index 47ad4930..20301bbe 100644 --- a/packages/contracts/test/test-utils/token-voting-constants.ts +++ b/packages/contracts/test/test-utils/token-voting-constants.ts @@ -1,7 +1,7 @@ +import {VERSION} from '../../plugin-settings'; import {ethers} from 'hardhat'; export const TOKEN_VOTING_INTERFACE = new ethers.utils.Interface([ - 'function initialize(address,tuple(uint8,uint32,uint32,uint64,uint256),address)', 'function getVotingToken()', ]); @@ -15,13 +15,53 @@ export const MAJORITY_VOTING_BASE_INTERFACE = new ethers.utils.Interface([ 'function createProposal(bytes,tuple(address,uint256,bytes)[],uint256,uint64,uint64,uint8,bool)', ]); +export const EXECUTE_PROPOSAL_PERMISSION_ID = ethers.utils.id( + 'EXECUTE_PROPOSAL_PERMISSION' +); + export const UPDATE_VOTING_SETTINGS_PERMISSION_ID = ethers.utils.id( 'UPDATE_VOTING_SETTINGS_PERMISSION' ); +export const CREATE_PROPOSAL_PERMISSION_ID = ethers.utils.id( + 'CREATE_PROPOSAL_PERMISSION' +); + export const MINT_PERMISSION_ID = ethers.utils.id('MINT_PERMISSION'); +export const SET_TARGET_CONFIG_PERMISSION_ID = ethers.utils.id( + 'SET_TARGET_CONFIG_PERMISSION' +); + +export const SET_METADATA_PERMISSION_ID = ethers.utils.id( + 'SET_METADATA_PERMISSION' +); + export const VOTING_EVENTS = { VOTING_SETTINGS_UPDATED: 'VotingSettingsUpdated', VOTE_CAST: 'VoteCast', }; + +export const ANY_ADDR = '0xFFfFfFffFFfffFFfFFfFFFFFffFFFffffFfFFFfF'; + +export const INITIALIZE_SIGNATURE = + 'initialize(address,(uint8,uint32,uint32,uint64,uint256),address,(address,uint8),uint256,bytes)'; + +export const CREATE_PROPOSAL_SIGNATURE = + 'createProposal(bytes,(address,uint256,bytes)[],uint256,uint64,uint64,uint8,bool)'; + +export const CREATE_PROPOSAL_SIGNATURE_IProposal = + 'createProposal(bytes,(address,uint256,bytes)[],uint64,uint64,bytes)'; + +export enum Operation { + call, + delegatecall, +} + +export type TargetConfig = { + target: string; + operation: number; +}; + +export const latestInitializerVersion = 2; +export const latestPluginBuild = VERSION.build; diff --git a/packages/contracts/test/test-utils/typechain-versions.ts b/packages/contracts/test/test-utils/typechain-versions.ts index 2e52aee3..419fdf48 100644 --- a/packages/contracts/test/test-utils/typechain-versions.ts +++ b/packages/contracts/test/test-utils/typechain-versions.ts @@ -23,3 +23,7 @@ export {GovernanceWrappedERC20__factory as GovernanceWrappedERC20_V1_0_0__factor export {GovernanceWrappedERC20__factory as GovernanceWrappedERC20_V1_3_0__factory} from '../../typechain/factories/@aragon/osx-v1.3.0/token/ERC20/governance/GovernanceWrappedERC20__factory'; export {GovernanceWrappedERC20__factory} from '../../typechain/factories/src/ERC20/governance/GovernanceWrappedERC20__factory'; export {GovernanceWrappedERC20} from '../../typechain/src/ERC20/governance/GovernanceWrappedERC20'; + +/* Majority Voting Base */ +export {IMajorityVoting__factory as IMajorityVoting_V1_3_0__factory} from '../../typechain/factories/@aragon/osx-v1.0.0/plugins/governance/majority-voting/IMajorityVoting__factory'; +export {MajorityVotingBase__factory} from '../../typechain/factories/src/MajorityVotingBase__factory'; diff --git a/packages/contracts/test/test-utils/uups-upgradeable.ts b/packages/contracts/test/test-utils/uups-upgradeable.ts index 80183815..e2cbe2e0 100644 --- a/packages/contracts/test/test-utils/uups-upgradeable.ts +++ b/packages/contracts/test/test-utils/uups-upgradeable.ts @@ -33,7 +33,7 @@ export async function deployAndUpgradeSelfCheck( { kind: 'uups', initializer: initializerName, - unsafeAllow: ['constructor'], + unsafeAllow: ['constructor', 'delegatecall'], constructorArgs: [], } ); @@ -49,7 +49,7 @@ export async function deployAndUpgradeSelfCheck( if (managingContract === undefined) { await expect( upgrades.upgradeProxy(proxy.address, factory.connect(upgrader), { - unsafeAllow: ['constructor'], + unsafeAllow: ['constructor', 'delegatecall'], constructorArgs: [], }) ) @@ -62,7 +62,7 @@ export async function deployAndUpgradeSelfCheck( else { await expect( upgrades.upgradeProxy(proxy.address, factory.connect(upgrader), { - unsafeAllow: ['constructor'], + unsafeAllow: ['constructor', 'delegatecall'], constructorArgs: [], }) ) @@ -102,20 +102,22 @@ export async function deployAndUpgradeFromToCheck( from: ContractFactory, to: ContractFactory, upgradePermissionId: string, - managingDao?: DAO | PluginRepo + managingDao?: DAO | PluginRepo, + reinitializerName?: string, + reinitArgs?: any ): Promise<{ proxy: Contract; fromImplementation: string; toImplementation: string; }> { // Deploy proxy and implementation - const proxy = await upgrades.deployProxy( + let proxy = await upgrades.deployProxy( from.connect(deployer), Object.values(initArgs), { kind: 'uups', initializer: initializerName, - unsafeAllow: ['constructor'], + unsafeAllow: ['constructor', 'delegatecall'], constructorArgs: [], } ); @@ -134,7 +136,7 @@ export async function deployAndUpgradeFromToCheck( if (managingDao === undefined) { await expect( upgrades.upgradeProxy(proxy.address, to.connect(upgrader), { - unsafeAllow: ['constructor'], + unsafeAllow: ['constructor', 'delegatecall'], constructorArgs: [], }) ) @@ -145,7 +147,7 @@ export async function deployAndUpgradeFromToCheck( } else { await expect( upgrades.upgradeProxy(proxy.address, to.connect(upgrader), { - unsafeAllow: ['constructor'], + unsafeAllow: ['constructor', 'delegatecall'], constructorArgs: [], }) ) @@ -155,10 +157,19 @@ export async function deployAndUpgradeFromToCheck( await managingDao.connect(deployer).grant(...grantArgs); } + let call; + if (reinitializerName && reinitArgs) { + call = { + fn: reinitializerName, + args: reinitArgs, + }; + } + // Upgrade the proxy to a new implementation from a different factory - await upgrades.upgradeProxy(proxy.address, to.connect(upgrader), { - unsafeAllow: ['constructor'], + proxy = await upgrades.upgradeProxy(proxy.address, to.connect(upgrader), { + unsafeAllow: ['constructor', 'delegatecall'], constructorArgs: [], + call, }); const toImplementation = await ethers.provider diff --git a/packages/contracts/test/test-utils/voting-helpers.ts b/packages/contracts/test/test-utils/voting-helpers.ts index d6bd724a..bc7e7790 100644 --- a/packages/contracts/test/test-utils/voting-helpers.ts +++ b/packages/contracts/test/test-utils/voting-helpers.ts @@ -18,7 +18,7 @@ export enum VotingMode { export async function voteWithSigners( votingContract: Contract, - proposalId: number, + proposalId: BigNumber, ballot: { yes: SignerWithAddress[]; no: SignerWithAddress[];