diff --git a/packages/contracts/src/governance/GovernancePluginsSetup.sol b/packages/contracts/src/governance/GovernancePluginsSetup.sol index 7d414c1..48630bb 100644 --- a/packages/contracts/src/governance/GovernancePluginsSetup.sol +++ b/packages/contracts/src/governance/GovernancePluginsSetup.sol @@ -11,7 +11,7 @@ import {MemberAccessPlugin} from "./MemberAccessPlugin.sol"; import {MemberAccessExecuteCondition} from "../conditions/MemberAccessExecuteCondition.sol"; import {OnlyPluginUpgraderCondition} from "../conditions/OnlyPluginUpgraderCondition.sol"; import {MainVotingPlugin} from "./MainVotingPlugin.sol"; -import {MajorityVotingBase} from "@aragon/osx/plugins/governance/majority-voting/MajorityVotingBase.sol"; +import {MajorityVotingBase} from "./base/MajorityVotingBase.sol"; /// @title GovernancePluginsSetup /// @dev Release 1, Build 1 diff --git a/packages/contracts/src/governance/MainVotingPlugin.sol b/packages/contracts/src/governance/MainVotingPlugin.sol index c3e979c..f8449e2 100644 --- a/packages/contracts/src/governance/MainVotingPlugin.sol +++ b/packages/contracts/src/governance/MainVotingPlugin.sol @@ -8,8 +8,7 @@ import {IMembership} from "@aragon/osx/core/plugin/membership/IMembership.sol"; import {Addresslist} from "@aragon/osx/plugins/utils/Addresslist.sol"; import {RATIO_BASE, _applyRatioCeiled} from "@aragon/osx/plugins/utils/Ratio.sol"; import {IMajorityVoting} from "@aragon/osx/plugins/governance/majority-voting/IMajorityVoting.sol"; -import {MajorityVotingBase} from "@aragon/osx/plugins/governance/majority-voting/MajorityVotingBase.sol"; -import {MEMBER_PERMISSION_ID} from "../constants.sol"; +import {MajorityVotingBase} from "./base/MajorityVotingBase.sol"; // The [ERC-165](https://eips.ethereum.org/EIPS/eip-165) interface ID of the contract. bytes4 constant MAIN_SPACE_VOTING_INTERFACE_ID = MainVotingPlugin.initialize.selector ^ @@ -29,8 +28,12 @@ contract MainVotingPlugin is IMembership, Addresslist, MajorityVotingBase { bytes32 public constant UPDATE_ADDRESSES_PERMISSION_ID = keccak256("UPDATE_ADDRESSES_PERMISSION"); + /// @notice Who created each proposal mapping(uint256 => address) internal proposalCreators; + /// @notice Whether an address is considered as a space member (not editor) + mapping(address => bool) internal members; + /// @notice Emitted when the creator cancels a proposal event ProposalCanceled(uint256 proposalId); @@ -43,6 +46,12 @@ contract MainVotingPlugin is IMembership, Addresslist, MajorityVotingBase { /// @notice Raised when a wallet who is not an editor or a member attempts to do something error NotAMember(address caller); + /// @notice Raised when an address is defined as a space member + event NewSpaceMember(address account); + + /// @notice Raised when an address is removed as a space member + event RemovedSpaceMember(address account); + /// @notice Raised when someone who didn't create a proposal attempts to cancel it error OnlyCreatorCanCancel(); @@ -82,28 +91,47 @@ contract MainVotingPlugin is IMembership, Addresslist, MajorityVotingBase { super.supportsInterface(_interfaceId); } - /// @notice Adds new members to the address list. - /// @param _members The addresses of members to be added. NOTE: Only one member can be added at a time. + /// @notice Adds new editors to the address list. + /// @param _editors The addresses of the editors to be added. NOTE: Only one member can be added at a time. /// @dev This function is used during the plugin initialization. function addAddresses( - address[] calldata _members + address[] calldata _editors ) external auth(UPDATE_ADDRESSES_PERMISSION_ID) { - if (_members.length > 1) revert OnlyOneEditorPerCall(_members.length); + if (_editors.length > 1) revert OnlyOneEditorPerCall(_editors.length); - _addAddresses(_members); - emit MembersAdded({members: _members}); + _addAddresses(_editors); + emit MembersAdded({members: _editors}); } - /// @notice Removes existing members from the address list. - /// @param _members The addresses of the members to be removed. NOTE: Only one member can be removed at a time. + /// @notice Removes existing editors from the address list. + /// @param _editors The addresses of the editors to be removed. NOTE: Only one member can be removed at a time. function removeAddresses( - address[] calldata _members + address[] calldata _editors ) external auth(UPDATE_ADDRESSES_PERMISSION_ID) { - if (_members.length > 1) revert OnlyOneEditorPerCall(_members.length); + if (_editors.length > 1) revert OnlyOneEditorPerCall(_editors.length); else if (addresslistLength() <= 1) revert NoEditorsLeft(); - _removeAddresses(_members); - emit MembersRemoved({members: _members}); + _removeAddresses(_editors); + emit MembersRemoved({members: _editors}); + } + + /// @notice Defines the given address as a new space member that can create proposals. + /// @param _account The address of the space member to be added. + /// @dev This function is used during the plugin initialization. + function addSpaceMember(address _account) external auth(UPDATE_ADDRESSES_PERMISSION_ID) { + if (members[_account]) return; + + members[_account] = true; + emit NewSpaceMember({account: _account}); + } + + /// @notice Removes the given address as a proposal creator. + /// @param _account The address of the space member to be removed. + function removeSpaceMember(address _account) external auth(UPDATE_ADDRESSES_PERMISSION_ID) { + if (!members[_account]) return; + + members[_account] = false; + emit RemovedSpaceMember({account: _account}); } /// @inheritdoc MajorityVotingBase @@ -113,9 +141,7 @@ contract MainVotingPlugin is IMembership, Addresslist, MajorityVotingBase { /// @notice Returns whether the given address holds membership/editor permission on the main voting plugin function isMember(address _account) public view returns (bool) { - return - isEditor(_account) || - dao().hasPermission(address(this), _account, MEMBER_PERMISSION_ID, bytes("")); + return members[_account] || isEditor(_account); } /// @notice Returns whether the given address is currently listed as an editor @@ -128,8 +154,6 @@ contract MainVotingPlugin is IMembership, Addresslist, MajorityVotingBase { bytes calldata _metadata, IDAO.Action[] calldata _actions, uint256 _allowFailureMap, - uint64, - uint64, VoteOption _voteOption, bool _tryEarlyExecution ) external override onlyMembers returns (uint256 proposalId) { diff --git a/packages/contracts/src/governance/MemberAccessPlugin.sol b/packages/contracts/src/governance/MemberAccessPlugin.sol index 22f0ae5..86e9a77 100644 --- a/packages/contracts/src/governance/MemberAccessPlugin.sol +++ b/packages/contracts/src/governance/MemberAccessPlugin.sol @@ -8,9 +8,8 @@ import {IDAO} from "@aragon/osx/core/dao/IDAO.sol"; import {PermissionManager} from "@aragon/osx/core/permission/PermissionManager.sol"; import {PluginUUPSUpgradeable} from "@aragon/osx/core/plugin/PluginUUPSUpgradeable.sol"; import {ProposalUpgradeable} from "@aragon/osx/core/plugin/proposal/ProposalUpgradeable.sol"; -import {IMultisig} from "@aragon/osx/plugins/governance/multisig/IMultisig.sol"; +import {IMultisig} from "./base/IMultisig.sol"; import {MainVotingPlugin, MAIN_SPACE_VOTING_INTERFACE_ID} from "./MainVotingPlugin.sol"; -import {MEMBER_PERMISSION_ID} from "../constants.sol"; bytes4 constant MEMBER_ACCESS_INTERFACE_ID = MemberAccessPlugin.initialize.selector ^ MemberAccessPlugin.updateMultisigSettings.selector ^ @@ -151,20 +150,6 @@ contract MemberAccessPlugin is IMultisig, PluginUUPSUpgradeable, ProposalUpgrade super.supportsInterface(_interfaceId); } - /// @notice This function is kept for compatibility with the multisig base class, but will not produce any effect. - function addAddresses( - address[] calldata - ) external view auth(UPDATE_MULTISIG_SETTINGS_PERMISSION_ID) { - revert AddresslistDisabled(); - } - - /// @notice This function is kept for compatibility with the multisig base class, but will not produce any effect. - function removeAddresses( - address[] calldata - ) external view auth(UPDATE_MULTISIG_SETTINGS_PERMISSION_ID) { - revert AddresslistDisabled(); - } - /// @notice Updates the plugin settings. /// @param _multisigSettings The new settings. function updateMultisigSettings( @@ -229,7 +214,7 @@ contract MemberAccessPlugin is IMultisig, PluginUUPSUpgradeable, ProposalUpgrade } // If the creator is an editor, we assume that the editor approves - approve(proposalId, false); + approve(proposalId); } else { proposal_.parameters.minApprovals = MIN_APPROVALS_WHEN_CREATED_BY_NON_EDITOR; } @@ -251,16 +236,9 @@ contract MemberAccessPlugin is IMultisig, PluginUUPSUpgradeable, ProposalUpgrade IDAO.Action[] memory _actions = new IDAO.Action[](1); _actions[0] = IDAO.Action({ - to: address(dao()), + to: address(multisigSettings.mainVotingPlugin), value: 0, - data: abi.encodeCall( - PermissionManager.grant, - ( - address(multisigSettings.mainVotingPlugin), // where - _proposedMember, // who - MEMBER_PERMISSION_ID // permission ID - ) - ) + data: abi.encodeCall(MainVotingPlugin.addSpaceMember, (_proposedMember)) }); return createProposal(_metadata, _actions); @@ -282,16 +260,9 @@ contract MemberAccessPlugin is IMultisig, PluginUUPSUpgradeable, ProposalUpgrade IDAO.Action[] memory _actions = new IDAO.Action[](1); _actions[0] = IDAO.Action({ - to: address(dao()), + to: address(multisigSettings.mainVotingPlugin), value: 0, - data: abi.encodeCall( - PermissionManager.revoke, - ( - address(multisigSettings.mainVotingPlugin), // where - _proposedMember, // who - MEMBER_PERMISSION_ID // permission ID - ) - ) + data: abi.encodeCall(MainVotingPlugin.removeSpaceMember, (_proposedMember)) }); return createProposal(_metadata, _actions); @@ -299,7 +270,7 @@ contract MemberAccessPlugin is IMultisig, PluginUUPSUpgradeable, ProposalUpgrade /// @inheritdoc IMultisig /// @dev The second parameter is left empty to keep compatibility with the existing multisig interface - function approve(uint256 _proposalId, bool) public { + function approve(uint256 _proposalId) public { address sender = _msgSender(); if (!_canApprove(_proposalId, sender)) { revert ApprovalCastForbidden(_proposalId, sender); @@ -393,14 +364,7 @@ contract MemberAccessPlugin is IMultisig, PluginUUPSUpgradeable, ProposalUpgrade /// @notice Returns whether the given address holds membership permission on the main voting plugin function isMember(address _account) public view returns (bool) { // Does the address hold the member or editor permission on the main voting plugin? - return - isEditor(_account) || - dao().hasPermission( - address(multisigSettings.mainVotingPlugin), - _account, - MEMBER_PERMISSION_ID, - bytes("") - ); + return multisigSettings.mainVotingPlugin.isMember(_account); } /// @notice Returns whether the given address holds editor permission on the main voting plugin diff --git a/packages/contracts/src/governance/base/IMultisig.sol b/packages/contracts/src/governance/base/IMultisig.sol new file mode 100644 index 0000000..99ade28 --- /dev/null +++ b/packages/contracts/src/governance/base/IMultisig.sol @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +pragma solidity 0.8.17; + +import {IDAO} from "@aragon/osx/core/dao/IDAO.sol"; + +/// @title IMultisig +/// @author Aragon Association - 2023 +/// @notice An interface for an on-chain multisig governance plugin in which a proposal passes if X out of Y approvals are met. +interface IMultisig { + /// @notice Approves and, optionally, executes the proposal. + /// @param _proposalId The ID of the proposal. + function approve(uint256 _proposalId) external; + + /// @notice Checks if an account can participate on a proposal vote. This can be because the vote + /// - was executed, or + /// - the voter is not listed. + /// @param _proposalId The proposal Id. + /// @param _account The address of the user to check. + /// @return Returns true if the account is allowed to vote. + /// @dev The function assumes the queried proposal exists. + function canApprove(uint256 _proposalId, address _account) external view returns (bool); + + /// @notice Checks if a proposal can be executed. + /// @param _proposalId The ID of the proposal to be checked. + /// @return True if the proposal can be executed, false otherwise. + function canExecute(uint256 _proposalId) external view returns (bool); + + /// @notice Returns whether the account has approved the proposal. Note, that this does not check if the account is listed. + /// @param _proposalId The ID of the proposal. + /// @param _account The account address to be checked. + /// @return The vote option cast by a voter for a certain proposal. + function hasApproved(uint256 _proposalId, address _account) external view returns (bool); + + /// @notice Executes a proposal. + /// @param _proposalId The ID of the proposal to be executed. + function execute(uint256 _proposalId) external; +} diff --git a/packages/contracts/src/governance/base/MajorityVotingBase.sol b/packages/contracts/src/governance/base/MajorityVotingBase.sol new file mode 100644 index 0000000..472a6ed --- /dev/null +++ b/packages/contracts/src/governance/base/MajorityVotingBase.sol @@ -0,0 +1,579 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +pragma solidity ^0.8.8; + +import {ERC165Upgradeable} from "@openzeppelin/contracts-upgradeable/utils/introspection/ERC165Upgradeable.sol"; +import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; +import {SafeCastUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/math/SafeCastUpgradeable.sol"; + +import {IProposal} from "@aragon/osx/core/plugin/proposal/IProposal.sol"; +import {ProposalUpgradeable} from "@aragon/osx/core/plugin/proposal/ProposalUpgradeable.sol"; +import {PluginUUPSUpgradeable} from "@aragon/osx/core/plugin/PluginUUPSUpgradeable.sol"; +import {IDAO} from "@aragon/osx/core/dao/IDAO.sol"; +import {RATIO_BASE, RatioOutOfBounds} from "@aragon/osx/plugins/utils/Ratio.sol"; +import {IMajorityVoting} from "@aragon/osx/plugins/governance/majority-voting/IMajorityVoting.sol"; + +/// @title MajorityVotingBase +/// @author Aragon Association - 2022-2023 +/// @notice The abstract implementation of majority voting plugins. +/// @notice Adapted to only make use of the required parameters and methods. +/// +/// ### Parameterization +/// +/// We define two parameters +/// $$\texttt{support} = \frac{N_\text{yes}}{N_\text{yes} + N_\text{no}} \in [0,1]$$ +/// and +/// $$\texttt{participation} = \frac{N_\text{yes} + N_\text{no} + N_\text{abstain}}{N_\text{total}} \in [0,1],$$ +/// where $N_\text{yes}$, $N_\text{no}$, and $N_\text{abstain}$ are the yes, no, and abstain votes that have been cast and $N_\text{total}$ is the total voting power available at proposal creation time. +/// +/// #### Limit Values: Support Threshold & Minimum Participation +/// +/// Two limit values are associated with these parameters and decide if a proposal execution should be possible: $\texttt{supportThreshold} \in [0,1]$ and $\texttt{minParticipation} \in [0,1]$. +/// +/// For threshold values, $>$ comparison is used. This **does not** include the threshold value. E.g., for $\texttt{supportThreshold} = 50\%$, the criterion is fulfilled if there is at least one more yes than no votes ($N_\text{yes} = N_\text{no} + 1$). +/// For minimum values, $\ge{}$ comparison is used. This **does** include the minimum participation value. E.g., for $\texttt{minParticipation} = 40\%$ and $N_\text{total} = 10$, the criterion is fulfilled if 4 out of 10 votes were casted. +/// +/// Majority voting implies that the support threshold is set with +/// $$\texttt{supportThreshold} \ge 50\% .$$ +/// However, this is not enforced by the contract code and developers can make unsafe parameters and only the frontend will warn about bad parameter settings. +/// +/// ### Execution Criteria +/// +/// After the vote is closed, two criteria decide if the proposal passes. +/// +/// #### The Support Criterion +/// +/// For a proposal to pass, the required ratio of yes and no votes must be met: +/// $$(1- \texttt{supportThreshold}) \cdot N_\text{yes} > \texttt{supportThreshold} \cdot N_\text{no}.$$ +/// Note, that the inequality yields the simple majority voting condition for $\texttt{supportThreshold}=\frac{1}{2}$. +/// +/// #### The Participation Criterion +/// +/// For a proposal to pass, the minimum voting power must have been cast: +/// $$N_\text{yes} + N_\text{no} + N_\text{abstain} \ge \texttt{minVotingPower},$$ +/// where $\texttt{minVotingPower} = \texttt{minParticipation} \cdot N_\text{total}$. +/// +/// ### Vote Replacement Execution +/// +/// The contract allows votes to be replaced. Voters can vote multiple times and only the latest voteOption is tallied. +/// +/// ### Early Execution +/// +/// This contract allows a proposal to be executed early, iff the vote outcome cannot change anymore by more people voting. Accordingly, vote replacement and early execution are /// mutually exclusive options. +/// The outcome cannot change anymore iff the support threshold is met even if all remaining votes are no votes. We call this number the worst-case number of no votes and define it as +/// +/// $$N_\text{no, worst-case} = N_\text{no, worst-case} + \texttt{remainingVotes}$$ +/// +/// where +/// +/// $$\texttt{remainingVotes} = N_\text{total}-\underbrace{(N_\text{yes}+N_\text{no}+N_\text{abstain})}_{\text{turnout}}.$$ +/// +/// We can use this quantity to calculate the worst-case support that would be obtained if all remaining votes are casted with no: +/// +/// $$ +/// \begin{align*} +/// \texttt{worstCaseSupport} +/// &= \frac{N_\text{yes}}{N_\text{yes} + (N_\text{no, worst-case})} \\[3mm] +/// &= \frac{N_\text{yes}}{N_\text{yes} + (N_\text{no} + \texttt{remainingVotes})} \\[3mm] +/// &= \frac{N_\text{yes}}{N_\text{yes} + N_\text{no} + N_\text{total} - (N_\text{yes} + N_\text{no} + N_\text{abstain})} \\[3mm] +/// &= \frac{N_\text{yes}}{N_\text{total} - N_\text{abstain}} +/// \end{align*} +/// $$ +/// +/// In analogy, we can modify [the support criterion](#the-support-criterion) from above to allow for early execution: +/// +/// $$ +/// \begin{align*} +/// (1 - \texttt{supportThreshold}) \cdot N_\text{yes} +/// &> \texttt{supportThreshold} \cdot N_\text{no, worst-case} \\[3mm] +/// &> \texttt{supportThreshold} \cdot (N_\text{no} + \texttt{remainingVotes}) \\[3mm] +/// &> \texttt{supportThreshold} \cdot (N_\text{no} + N_\text{total}-(N_\text{yes}+N_\text{no}+N_\text{abstain})) \\[3mm] +/// &> \texttt{supportThreshold} \cdot (N_\text{total} - N_\text{yes} - N_\text{abstain}) +/// \end{align*} +/// $$ +/// +/// Accordingly, early execution is possible when the vote is open, the modified support criterion, and the particicpation criterion are met. +/// @dev This contract implements the `IMajorityVoting` interface. +abstract contract MajorityVotingBase is + IMajorityVoting, + Initializable, + ERC165Upgradeable, + PluginUUPSUpgradeable, + ProposalUpgradeable +{ + using SafeCastUpgradeable for uint256; + + /// @notice The different voting modes available. + /// @param Standard In standard mode, early execution and vote replacement are disabled. + /// @param EarlyExecution In early execution mode, a proposal can be executed early before the end date if the vote outcome cannot mathematically change by more voters voting. + /// @param VoteReplacement In vote replacement mode, voters can change their vote multiple times and only the latest vote option is tallied. + enum VotingMode { + Standard, + EarlyExecution, + VoteReplacement + } + + /// @notice A container for the majority voting settings that will be applied as parameters on proposal creation. + /// @param votingMode A parameter to select the vote mode. In standard mode (0), early execution and vote replacement are disabled. In early execution mode (1), a proposal can be executed early before the end date if the vote outcome cannot mathematically change by more voters voting. In vote replacement mode (2), voters can change their vote multiple times and only the latest vote option is tallied. + /// @param supportThreshold The support threshold value. Its value has to be in the interval [0, 10^6] defined by `RATIO_BASE = 10**6`. + /// @param minParticipation The minimum participation value. Its value has to be in the interval [0, 10^6] defined by `RATIO_BASE = 10**6`. + /// @param minDuration The minimum duration of the proposal vote in seconds. + struct VotingSettings { + VotingMode votingMode; + uint32 supportThreshold; + uint32 minParticipation; + uint64 minDuration; + } + + /// @notice A container for proposal-related information. + /// @param executed Whether the proposal is executed or not. + /// @param parameters The proposal parameters at the time of the proposal creation. + /// @param tally The vote tally of the proposal. + /// @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. 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 { + bool executed; + ProposalParameters parameters; + Tally tally; + mapping(address => IMajorityVoting.VoteOption) voters; + IDAO.Action[] actions; + uint256 allowFailureMap; + } + + /// @notice A container for the proposal parameters at the time of proposal creation. + /// @param votingMode A parameter to select the vote mode. + /// @param supportThreshold The support threshold value. The value has to be in the interval [0, 10^6] defined by `RATIO_BASE = 10**6`. + /// @param startDate The start date of the proposal vote. + /// @param endDate The end date of the proposal vote. + /// @param snapshotBlock The number of the block prior to the proposal creation. + /// @param minVotingPower The minimum voting power needed. + struct ProposalParameters { + VotingMode votingMode; + uint32 supportThreshold; + uint64 startDate; + uint64 endDate; + uint64 snapshotBlock; + uint256 minVotingPower; + } + + /// @notice A container for the proposal vote tally. + /// @param abstain The number of abstain votes casted. + /// @param yes The number of yes votes casted. + /// @param no The number of no votes casted. + struct Tally { + uint256 abstain; + uint256 yes; + uint256 no; + } + + /// @notice The [ERC-165](https://eips.ethereum.org/EIPS/eip-165) interface ID of the contract. + bytes4 internal constant MAJORITY_VOTING_BASE_INTERFACE_ID = + this.minDuration.selector ^ + this.votingMode.selector ^ + this.totalVotingPower.selector ^ + this.getProposal.selector ^ + this.updateVotingSettings.selector ^ + this.createProposal.selector; + + /// @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 A mapping between proposal IDs and proposal information. + mapping(uint256 => Proposal) internal proposals; + + /// @notice The struct storing the voting settings. + VotingSettings private votingSettings; + + /// @notice Thrown if a date is out of bounds. + /// @param limit The limit value. + /// @param actual The actual value. + error DateOutOfBounds(uint64 limit, uint64 actual); + + /// @notice Thrown if the minimal duration value is out of bounds (less than one hour or greater than 1 year). + /// @param limit The limit value. + /// @param actual The actual value. + error MinDurationOutOfBounds(uint64 limit, uint64 actual); + + /// @notice Thrown when a sender is not allowed to create a proposal. + /// @param sender The sender address. + error ProposalCreationForbidden(address sender); + + /// @notice Thrown if an account is not allowed to cast a vote. This can be because the vote + /// - has not started, + /// - has ended, + /// - was executed, or + /// - the account doesn't have voting powers. + /// @param proposalId The ID of the proposal. + /// @param account The address of the _account. + /// @param voteOption The chosen vote option. + error VoteCastForbidden(uint256 proposalId, address account, VoteOption voteOption); + + /// @notice Thrown if the proposal execution is forbidden. + /// @param proposalId The ID of the proposal. + error ProposalExecutionForbidden(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. + /// @param minParticipation The minimum participation value. + /// @param minDuration The minimum duration of the proposal vote in seconds. + event VotingSettingsUpdated( + VotingMode votingMode, + uint32 supportThreshold, + uint32 minParticipation, + uint64 minDuration + ); + + /// @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. + /// @param _votingSettings The voting settings. + function __MajorityVotingBase_init( + IDAO _dao, + VotingSettings calldata _votingSettings + ) internal onlyInitializing { + __PluginUUPSUpgradeable_init(_dao); + _updateVotingSettings(_votingSettings); + } + + /// @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. + function supportsInterface( + bytes4 _interfaceId + ) + public + view + virtual + override(ERC165Upgradeable, PluginUUPSUpgradeable, ProposalUpgradeable) + returns (bool) + { + return + _interfaceId == MAJORITY_VOTING_BASE_INTERFACE_ID || + _interfaceId == type(IMajorityVoting).interfaceId || + super.supportsInterface(_interfaceId); + } + + /// @inheritdoc IMajorityVoting + function vote( + uint256 _proposalId, + VoteOption _voteOption, + bool _tryEarlyExecution + ) public virtual { + address account = _msgSender(); + + if (!_canVote(_proposalId, account, _voteOption)) { + revert VoteCastForbidden({ + proposalId: _proposalId, + account: account, + voteOption: _voteOption + }); + } + _vote(_proposalId, _voteOption, account, _tryEarlyExecution); + } + + /// @inheritdoc IMajorityVoting + function execute(uint256 _proposalId) public virtual { + if (!_canExecute(_proposalId)) { + revert ProposalExecutionForbidden(_proposalId); + } + _execute(_proposalId); + } + + /// @inheritdoc IMajorityVoting + function getVoteOption( + uint256 _proposalId, + address _voter + ) public view virtual returns (VoteOption) { + return proposals[_proposalId].voters[_voter]; + } + + /// @inheritdoc IMajorityVoting + function canVote( + uint256 _proposalId, + address _voter, + VoteOption _voteOption + ) public view virtual returns (bool) { + return _canVote(_proposalId, _voter, _voteOption); + } + + /// @inheritdoc IMajorityVoting + function canExecute(uint256 _proposalId) public view virtual returns (bool) { + return _canExecute(_proposalId); + } + + /// @inheritdoc IMajorityVoting + function isSupportThresholdReached(uint256 _proposalId) public view virtual returns (bool) { + Proposal storage proposal_ = proposals[_proposalId]; + + // The code below implements the formula of the support criterion explained in the top of this file. + // `(1 - supportThreshold) * N_yes > supportThreshold * N_no` + return + (RATIO_BASE - proposal_.parameters.supportThreshold) * proposal_.tally.yes > + proposal_.parameters.supportThreshold * proposal_.tally.no; + } + + /// @inheritdoc IMajorityVoting + function isSupportThresholdReachedEarly( + uint256 _proposalId + ) public view virtual returns (bool) { + Proposal storage proposal_ = proposals[_proposalId]; + + uint256 noVotesWorstCase = totalVotingPower(proposal_.parameters.snapshotBlock) - + proposal_.tally.yes - + proposal_.tally.abstain; + + // The code below implements the formula of the early execution support criterion explained in the top of this file. + // `(1 - supportThreshold) * N_yes > supportThreshold * N_no,worst-case` + return + (RATIO_BASE - proposal_.parameters.supportThreshold) * proposal_.tally.yes > + proposal_.parameters.supportThreshold * noVotesWorstCase; + } + + /// @inheritdoc IMajorityVoting + function isMinParticipationReached(uint256 _proposalId) public view virtual returns (bool) { + Proposal storage proposal_ = proposals[_proposalId]; + + // The code below implements the formula of the participation criterion explained in the top of this file. + // `N_yes + N_no + N_abstain >= minVotingPower = minParticipation * N_total` + return + proposal_.tally.yes + proposal_.tally.no + proposal_.tally.abstain >= + proposal_.parameters.minVotingPower; + } + + /// @inheritdoc IMajorityVoting + function supportThreshold() public view virtual returns (uint32) { + return votingSettings.supportThreshold; + } + + /// @inheritdoc IMajorityVoting + function minParticipation() public view virtual returns (uint32) { + return votingSettings.minParticipation; + } + + /// @notice Returns the minimum duration parameter stored in the voting settings. + /// @return The minimum duration parameter. + function minDuration() public view virtual returns (uint64) { + return votingSettings.minDuration; + } + + /// @notice Returns the vote mode stored in the voting settings. + /// @return The vote mode parameter. + function votingMode() public view virtual returns (VotingMode) { + return votingSettings.votingMode; + } + + /// @notice Returns the total voting power checkpointed for a specific block number. + /// @param _blockNumber The block number. + /// @return The total voting power. + function totalVotingPower(uint256 _blockNumber) public view virtual returns (uint256); + + /// @notice Returns all information for a proposal vote by its ID. + /// @param _proposalId The ID of the proposal. + /// @return open Whether the proposal is open or not. + /// @return executed Whether the proposal is executed or not. + /// @return parameters The parameters of the proposal vote. + /// @return tally The current tally of the proposal vote. + /// @return actions The actions to be executed in the associated DAO after the proposal has passed. + /// @return allowFailureMap The bit map representations of which actions are allowed to revert so tx still succeeds. + function getProposal( + uint256 _proposalId + ) + public + view + virtual + returns ( + bool open, + bool executed, + ProposalParameters memory parameters, + Tally memory tally, + IDAO.Action[] memory actions, + uint256 allowFailureMap + ) + { + Proposal storage proposal_ = proposals[_proposalId]; + + open = _isProposalOpen(proposal_); + executed = proposal_.executed; + parameters = proposal_.parameters; + tally = proposal_.tally; + actions = proposal_.actions; + allowFailureMap = proposal_.allowFailureMap; + } + + /// @notice Updates the voting settings. + /// @param _votingSettings The new voting settings. + function updateVotingSettings( + VotingSettings calldata _votingSettings + ) external virtual auth(UPDATE_VOTING_SETTINGS_PERMISSION_ID) { + _updateVotingSettings(_votingSettings); + } + + /// @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. + /// @param _allowFailureMap Allows proposal to succeed even if an action reverts. Uses bitmap representation. If the bit at index `x` is 1, the tx succeeds even if the action at `x` failed. Passing 0 will be treated as atomic execution. + /// @param _voteOption The chosen vote option to be casted on proposal creation. + /// @param _tryEarlyExecution If `true`, early execution is tried after the vote cast. The call does not revert if early execution is not possible. + /// @return proposalId The ID of the proposal. + function createProposal( + bytes calldata _metadata, + IDAO.Action[] calldata _actions, + uint256 _allowFailureMap, + VoteOption _voteOption, + bool _tryEarlyExecution + ) external virtual returns (uint256 proposalId); + + /// @notice Internal function to cast a vote. It assumes the queried vote exists. + /// @param _proposalId The ID of the proposal. + /// @param _voteOption The chosen vote option to be casted on the proposal vote. + /// @param _tryEarlyExecution If `true`, early execution is tried after the vote cast. The call does not revert if early execution is not possible. + function _vote( + uint256 _proposalId, + VoteOption _voteOption, + address _voter, + bool _tryEarlyExecution + ) internal virtual; + + /// @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; + + _executeProposal( + dao(), + _proposalId, + proposals[_proposalId].actions, + proposals[_proposalId].allowFailureMap + ); + } + + /// @notice Internal function to check if a voter can vote. It assumes the queried proposal exists. + /// @param _proposalId The ID of the proposal. + /// @param _voter The address of the voter to check. + /// @param _voteOption Whether the voter abstains, supports or opposes the proposal. + /// @return Returns `true` if the given voter can vote on a certain proposal and `false` otherwise. + function _canVote( + uint256 _proposalId, + address _voter, + VoteOption _voteOption + ) internal view virtual returns (bool); + + /// @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; + } + + if (_isProposalOpen(proposal_)) { + // Early execution + if (proposal_.parameters.votingMode != VotingMode.EarlyExecution) { + return false; + } + if (!isSupportThresholdReachedEarly(_proposalId)) { + return false; + } + } else { + // Normal execution + if (!isSupportThresholdReached(_proposalId)) { + return false; + } + } + if (!isMinParticipationReached(_proposalId)) { + return false; + } + + return true; + } + + /// @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. + function _isProposalOpen(Proposal storage proposal_) internal view virtual returns (bool) { + uint64 currentTime = block.timestamp.toUint64(); + + return + proposal_.parameters.startDate <= currentTime && + currentTime < proposal_.parameters.endDate && + !proposal_.executed; + } + + /// @notice Internal function to update the plugin-wide proposal vote settings. + /// @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. + if (_votingSettings.supportThreshold > RATIO_BASE - 1) { + revert RatioOutOfBounds({ + limit: RATIO_BASE - 1, + actual: _votingSettings.supportThreshold + }); + } + + // Require the minimum participation value to be in the interval [0, 10^6], because `>=` comparision is used in the participation criterion. + if (_votingSettings.minParticipation > RATIO_BASE) { + revert RatioOutOfBounds({limit: RATIO_BASE, actual: _votingSettings.minParticipation}); + } + + if (_votingSettings.minDuration < 60 minutes) { + revert MinDurationOutOfBounds({limit: 60 minutes, actual: _votingSettings.minDuration}); + } + + if (_votingSettings.minDuration > 365 days) { + revert MinDurationOutOfBounds({limit: 365 days, actual: _votingSettings.minDuration}); + } + + votingSettings = _votingSettings; + + emit VotingSettingsUpdated({ + votingMode: _votingSettings.votingMode, + supportThreshold: _votingSettings.supportThreshold, + minParticipation: _votingSettings.minParticipation, + minDuration: _votingSettings.minDuration + }); + } + + /// @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. + /// @param _end The end date of the proposal vote. If 0, `_start + minDuration` is used. + /// @return startDate The validated start date of the proposal vote. + /// @return endDate The validated end date of the proposal vote. + function _validateProposalDates( + uint64 _start, + uint64 _end + ) internal view virtual returns (uint64 startDate, uint64 endDate) { + uint64 currentTimestamp = block.timestamp.toUint64(); + + if (_start == 0) { + startDate = currentTimestamp; + } else { + startDate = _start; + + if (startDate < currentTimestamp) { + revert DateOutOfBounds({limit: currentTimestamp, actual: startDate}); + } + } + + uint64 earliestEndDate = startDate + votingSettings.minDuration; // Since `minDuration` is limited to 1 year, `startDate + minDuration` can only overflow if the `startDate` is after `type(uint64).max - minDuration`. In this case, the proposal creation will revert and another date can be picked. + + if (_end == 0) { + endDate = earliestEndDate; + } else { + endDate = _end; + + if (endDate < earliestEndDate) { + revert DateOutOfBounds({limit: earliestEndDate, actual: endDate}); + } + } + } + + /// @notice This empty reserved space is put in place to allow future versions to add 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; +} diff --git a/packages/contracts/src/test/TestGovernancePluginsSetup.sol b/packages/contracts/src/test/TestGovernancePluginsSetup.sol index a3a2743..1ae3e18 100644 --- a/packages/contracts/src/test/TestGovernancePluginsSetup.sol +++ b/packages/contracts/src/test/TestGovernancePluginsSetup.sol @@ -11,7 +11,7 @@ import {TestMemberAccessPlugin} from "./TestMemberAccessPlugin.sol"; import {MemberAccessExecuteCondition} from "../conditions/MemberAccessExecuteCondition.sol"; import {OnlyPluginUpgraderCondition} from "../conditions/OnlyPluginUpgraderCondition.sol"; import {MainVotingPlugin} from "../governance/MainVotingPlugin.sol"; -import {MajorityVotingBase} from "@aragon/osx/plugins/governance/majority-voting/MajorityVotingBase.sol"; +import {MajorityVotingBase} from "../governance/base/MajorityVotingBase.sol"; // Not ideal, but to test this E2E, the contract needs to be cloned contract TestGovernancePluginsSetup is PluginSetup {