From 62e1520bd048d7783d735528b103aae3319cf106 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B8r=E2=88=82=C2=A1?= Date: Wed, 7 Aug 2024 17:44:29 +0200 Subject: [PATCH] Adding an access contract for personal spaces --- .../src/governance/MemberAccessPlugin.sol | 11 +- .../src/personal/PersonalAccessHelper.sol | 319 ++++++++++++++++++ .../src/personal/PersonalSpaceAdminPlugin.sol | 9 +- 3 files changed, 331 insertions(+), 8 deletions(-) create mode 100644 packages/contracts/src/personal/PersonalAccessHelper.sol diff --git a/packages/contracts/src/governance/MemberAccessPlugin.sol b/packages/contracts/src/governance/MemberAccessPlugin.sol index a1777a7..22908a9 100644 --- a/packages/contracts/src/governance/MemberAccessPlugin.sol +++ b/packages/contracts/src/governance/MemberAccessPlugin.sol @@ -46,14 +46,15 @@ contract MemberAccessPlugin is IMultisig, PluginUUPSUpgradeable, ProposalUpgrade /// @param parameters The proposal-specific approve settings at the time of the proposal creation. /// @param approvers The approves casted by the approvers. /// @param actions The actions to be executed when the proposal passes. - /// @param _failsafeActionMap A bitmap allowing the proposal to succeed, even if certain 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. + /// @param destinationPlugin The address of the plugin where addMember should be called. + /// @param failsafeActionMap A bitmap allowing the proposal to succeed, even if certain 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; uint16 approvals; ProposalParameters parameters; mapping(address => bool) approvers; IDAO.Action[] actions; - MainVotingPlugin mainVotingPlugin; + MainVotingPlugin destinationPlugin; uint256 failsafeActionMap; } @@ -152,7 +153,7 @@ contract MemberAccessPlugin is IMultisig, PluginUUPSUpgradeable, ProposalUpgrade /// @notice Creates a proposal to add a new member. /// @param _metadata The metadata of the proposal. - /// @param _proposedMember The address of the member who may eveutnally be added. + /// @param _proposedMember The address of the member who may eventually be added. /// @param _proposer The address to use as the proposal creator. /// @return proposalId The ID of the proposal. function proposeAddMember( @@ -212,7 +213,7 @@ contract MemberAccessPlugin is IMultisig, PluginUUPSUpgradeable, ProposalUpgrade proposal_.parameters.startDate = _startDate; proposal_.parameters.endDate = _endDate; - proposal_.mainVotingPlugin = MainVotingPlugin(msg.sender); + proposal_.destinationPlugin = MainVotingPlugin(msg.sender); for (uint256 i; i < _actions.length; ) { proposal_.actions.push(_actions[i]); unchecked { @@ -360,7 +361,7 @@ contract MemberAccessPlugin is IMultisig, PluginUUPSUpgradeable, ProposalUpgrade if (!_isProposalOpen(proposal_)) { // The proposal was executed already return false; - } else if (!proposal_.mainVotingPlugin.isEditor(_account)) { + } else if (!proposal_.destinationPlugin.isEditor(_account)) { // The approver has no voting power. return false; } else if (proposal_.approvers[_account]) { diff --git a/packages/contracts/src/personal/PersonalAccessHelper.sol b/packages/contracts/src/personal/PersonalAccessHelper.sol new file mode 100644 index 0000000..f2cbb72 --- /dev/null +++ b/packages/contracts/src/personal/PersonalAccessHelper.sol @@ -0,0 +1,319 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +pragma solidity ^0.8.8; + +import {SafeCastUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/math/SafeCastUpgradeable.sol"; + +import {IDAO} from "@aragon/osx/core/dao/IDAO.sol"; +import {PermissionManager} from "@aragon/osx/core/permission/PermissionManager.sol"; +import {ProposalUpgradeable} from "@aragon/osx/core/plugin/proposal/ProposalUpgradeable.sol"; +import {PluginCloneable} from "@aragon/osx/core/plugin/PluginCloneable.sol"; +import {IEditors} from "../base/IEditors.sol"; +import {PersonalSpaceAdminPlugin} from "./PersonalSpaceAdminPlugin.sol"; + +bytes4 constant PERSONAL_ACCESS_INTERFACE_ID = PersonalAccessPlugin.initialize.selector ^ + PersonalAccessPlugin.updatePluginSettings.selector ^ + PersonalAccessPlugin.proposeAddMember.selector ^ + PersonalAccessPlugin.getProposal.selector; + +/// @title Personal access plugin (SingleApproval) - Release 1, Build 1 +/// @author Aragon - 2024 +/// @notice The on-chain governance plugin in which a proposal passes when approved by the first editor +contract PersonalAccessPlugin is PluginCloneable, ProposalUpgradeable { + using SafeCastUpgradeable for uint256; + + /// @notice The ID of the permission required to call the `addAddresses` functions. + bytes32 public constant UPDATE_PLUGIN_SETTINGS_PERMISSION_ID = + keccak256("UPDATE_PLUGIN_SETTINGS_PERMISSION"); + + /// @notice The ID of the permission required to create new membership proposals. + bytes32 public constant PROPOSER_PERMISSION_ID = keccak256("PROPOSER_PERMISSION"); + + /// @notice A container for proposal-related information. + /// @param executed Whether the proposal is executed or not. + /// @param approvals The number of approvals casted. + /// @param parameters The proposal-specific approve settings at the time of the proposal creation. + /// @param approvers The approves casted by the approvers. + /// @param actions The actions to be executed when the proposal passes. + /// @param destinationPlugin The address of the plugin where addMember should be called. + /// @param failsafeActionMap A bitmap allowing the proposal to succeed, even if certain 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; + IDAO.Action[] actions; + PersonalSpaceAdminPlugin destinationPlugin; + uint256 failsafeActionMap; + } + + /// @notice A container for the proposal parameters. + /// @param startDate The timestamp when the proposal starts. + /// @param endDate The timestamp when the proposal expires. + struct ProposalParameters { + uint64 startDate; + uint64 endDate; + } + + /// @notice A container for the plugin settings. + /// @param proposalDuration The amount of time before a non-approved proposal expires. + struct PluginSettings { + uint64 proposalDuration; + } + + /// @notice A mapping between proposal IDs and proposal information. + mapping(uint256 => Proposal) internal proposals; + + /// @notice The current plugin settings. + PluginSettings public pluginSettings; + + /// @notice Keeps track at which block number the plugin settings have been changed the last time. + /// @dev This variable prevents a proposal from being created in the same block in which the plugin settings change. + uint64 public lastPluginSettingsChange; + + /// @notice Thrown when creating a proposal at the same block that the settings were changed. + error ProposalCreationForbiddenOnSameBlock(); + + /// @notice Thrown if an approver is not allowed to cast an approve. This can be because the proposal + /// - is not open, + /// - was executed, or + /// - the approver is not on the address list + /// @param proposalId The ID of the proposal. + /// @param sender The address of the sender. + error ApprovalCastForbidden(uint256 proposalId, address sender); + + /// @notice Thrown if the proposal execution is forbidden. + /// @param proposalId The ID of the proposal. + error ProposalExecutionForbidden(uint256 proposalId); + + /// @notice Thrown when called from an incompatible contract. + error InvalidInterface(); + + /// @notice Emitted when a proposal is approved by an editor. + /// @param proposalId The ID of the proposal. + /// @param editor The editor casting the approve. + event Approved(uint256 indexed proposalId, address indexed editor); + + /// @notice Emitted when a proposal is rejected by an editor. + /// @param proposalId The ID of the proposal. + /// @param editor The editor casting the rejection. + event Rejected(uint256 indexed proposalId, address indexed editor); + + /// @notice Emitted when the plugin settings are set. + /// @param proposalDuration The amount of time before a non-approved proposal expires. + event PluginSettingsUpdated(uint64 proposalDuration); + + /// @notice Initializes Release 1, Build 1. + /// @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 _pluginSettings The multisig settings. + function initialize( + IDAO _dao, + PluginSettings calldata _pluginSettings + ) external virtual initializer { + __PluginCloneable_init(_dao); + + _updatePluginSettings(_pluginSettings); + } + + /// @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(PluginCloneable, ProposalUpgradeable) returns (bool) { + return + _interfaceId == PERSONAL_ACCESS_INTERFACE_ID || super.supportsInterface(_interfaceId); + } + + /// @notice Updates the plugin settings. + /// @param _pluginSettings The new settings. + function updatePluginSettings( + PluginSettings calldata _pluginSettings + ) external auth(UPDATE_PLUGIN_SETTINGS_PERMISSION_ID) { + _updatePluginSettings(_pluginSettings); + } + + /// @notice Creates a proposal to add a new member. + /// @param _metadata The metadata of the proposal. + /// @param _proposedMember The address of the member who may eventually be added. + /// @param _proposer The address to use as the proposal creator. + /// @return proposalId The ID of the proposal. + function proposeAddMember( + bytes calldata _metadata, + address _proposedMember, + address _proposer + ) public auth(PROPOSER_PERMISSION_ID) returns (uint256 proposalId) { + // Check that the caller supports the `addMember` function + if (!PersonalSpaceAdminPlugin(msg.sender).supportsInterface(type(IEditors).interfaceId)) { + revert InvalidInterface(); + } + + // Build the list of actions + IDAO.Action[] memory _actions = new IDAO.Action[](1); + + _actions[0] = IDAO.Action({ + to: address(msg.sender), // We are called by the PersonalSpaceAdminPlugin + value: 0, + data: abi.encodeCall(PersonalSpaceAdminPlugin.submitNewMember, (_proposedMember)) + }); + + // Create proposal + uint64 snapshotBlock; + unchecked { + snapshotBlock = block.number.toUint64() - 1; // The snapshot block must be mined already to protect the transaction against backrunning transactions causing census changes. + } + + // Revert if the settings have been changed in the same block as this proposal should be created in. + // This prevents a malicious party from voting with previous addresses and the new settings. + if (lastPluginSettingsChange > snapshotBlock) { + revert ProposalCreationForbiddenOnSameBlock(); + } + + uint64 _startDate = block.timestamp.toUint64(); + uint64 _endDate = _startDate + pluginSettings.proposalDuration; + + proposalId = _createProposalId(); + + emit ProposalCreated({ + proposalId: proposalId, + creator: _proposer, + metadata: _metadata, + startDate: _startDate, + endDate: _endDate, + actions: _actions, + allowFailureMap: uint8(0) + }); + + // Create the proposal + Proposal storage proposal_ = proposals[proposalId]; + + proposal_.parameters.startDate = _startDate; + proposal_.parameters.endDate = _endDate; + + proposal_.destinationPlugin = PersonalSpaceAdminPlugin(msg.sender); + for (uint256 i; i < _actions.length; ) { + proposal_.actions.push(_actions[i]); + unchecked { + ++i; + } + } + + // An editor needs to approve. If the proposer is an editor, approve right away. + if (PersonalSpaceAdminPlugin(msg.sender).isEditor(_proposer)) { + _approve(proposalId, _proposer); + } + } + + /// @param _proposalId The Id of the proposal to approve. + function approve(uint256 _proposalId) public { + _approve(_proposalId, msg.sender); + } + + /// @notice Internal implementation, allowing proposeAddMember() to specify the approver. + function _approve(uint256 _proposalId, address _approver) internal { + if (!_canApprove(_proposalId, _approver)) { + revert ApprovalCastForbidden(_proposalId, _approver); + } + + emit Approved({proposalId: _proposalId, editor: _approver}); + + _execute(_proposalId); + } + + /// @notice Rejects the given proposal immediately. + /// @param _proposalId The Id of the proposal to reject. + function reject(uint256 _proposalId) public { + if (!_canApprove(_proposalId, msg.sender)) { + revert ApprovalCastForbidden(_proposalId, msg.sender); + } + + Proposal storage proposal_ = proposals[_proposalId]; + + // Prevent any further approvals, expire it + proposal_.parameters.endDate = block.timestamp.toUint64(); + + emit Rejected({proposalId: _proposalId, editor: msg.sender}); + } + + function canApprove(uint256 _proposalId, address _account) external view returns (bool) { + return _canApprove(_proposalId, _account); + } + + /// @notice Returns all information for a proposal vote by its ID. + /// @param _proposalId The ID of the proposal. + /// @return executed Whether the proposal is executed or not. + /// @return parameters The parameters of the proposal vote. + /// @return actions The actions to be executed in the associated DAO after the proposal has passed. + /// @param failsafeActionMap 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. + function getProposal( + uint256 _proposalId + ) + public + view + returns ( + bool executed, + ProposalParameters memory parameters, + IDAO.Action[] memory actions, + uint256 failsafeActionMap + ) + { + Proposal storage proposal_ = proposals[_proposalId]; + + executed = proposal_.executed; + parameters = proposal_.parameters; + actions = proposal_.actions; + failsafeActionMap = proposal_.failsafeActionMap; + } + + /// @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 { + Proposal storage proposal_ = proposals[_proposalId]; + + proposal_.executed = true; + + _executeProposal( + dao(), + _proposalId, + proposals[_proposalId].actions, + proposals[_proposalId].failsafeActionMap + ); + } + + /// @notice Internal function to check if an account can approve. It assumes the queried proposal exists. + /// @param _proposalId The ID of the proposal. + /// @param _account The account to check. + /// @return Returns `true` if the given account can approve on a certain proposal and `false` otherwise. + function _canApprove(uint256 _proposalId, address _account) internal view returns (bool) { + Proposal storage proposal_ = proposals[_proposalId]; + + if (!_isProposalOpen(proposal_)) { + // The proposal was executed already + return false; + } else if (!proposal_.destinationPlugin.isEditor(_account)) { + // The approver has no voting power. + 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 returns (bool) { + uint64 currentTimestamp64 = block.timestamp.toUint64(); + return + !proposal_.executed && + proposal_.parameters.startDate <= currentTimestamp64 && + proposal_.parameters.endDate >= currentTimestamp64; + } + + /// @notice Internal function to update the plugin settings. + /// @param _pluginSettings The new settings. + function _updatePluginSettings(PluginSettings calldata _pluginSettings) internal { + pluginSettings = _pluginSettings; + lastPluginSettingsChange = block.number.toUint64(); + + emit PluginSettingsUpdated({proposalDuration: _pluginSettings.proposalDuration}); + } +} diff --git a/packages/contracts/src/personal/PersonalSpaceAdminPlugin.sol b/packages/contracts/src/personal/PersonalSpaceAdminPlugin.sol index 65974e6..cd47876 100644 --- a/packages/contracts/src/personal/PersonalSpaceAdminPlugin.sol +++ b/packages/contracts/src/personal/PersonalSpaceAdminPlugin.sol @@ -24,12 +24,15 @@ contract PersonalSpaceAdminPlugin is PluginCloneable, ProposalUpgradeable, IEdit this.submitEdits.selector ^ this.submitAcceptSubspace.selector ^ this.submitRemoveSubspace.selector ^ - this.submitNewMember.selector ^ + this.addMember.selector ^ this.submitRemoveMember.selector ^ this.submitNewEditor.selector ^ this.submitRemoveEditor.selector ^ this.leaveSpace.selector; + /// @notice The ID of the permission required to call the `addMember` function. + bytes32 public constant ADD_MEMBER_PERMISSION_ID = keccak256("ADD_MEMBER_PERMISSION"); + /// @notice Raised when a wallet who is not an editor or a member attempts to do something error NotAMember(address caller); @@ -139,9 +142,9 @@ contract PersonalSpaceAdminPlugin is PluginCloneable, ProposalUpgradeable, IEdit // The event will be emitted by the space plugin } - /// @notice Creates and executes a proposal that makes the DAO grant membership permission to the given address + /// @notice Creates and executes a proposal that makes the DAO grant membership permission to the given address. /// @param _newMember The address to grant member permission to - function submitNewMember(address _newMember) public auth(EDITOR_PERMISSION_ID) { + function addMember(address _newMember) public auth(ADD_MEMBER_PERMISSION_ID) { IDAO.Action[] memory _actions = new IDAO.Action[](1); _actions[0].to = address(dao()); _actions[0].data = abi.encodeCall(