diff --git a/script/Deploy.s.sol b/script/Deploy.s.sol index ee051f3..6a07714 100644 --- a/script/Deploy.s.sol +++ b/script/Deploy.s.sol @@ -11,7 +11,8 @@ import {GovernanceERC20} from "@aragon/osx/token/ERC20/governance/GovernanceERC2 import {GovernanceWrappedERC20} from "@aragon/osx/token/ERC20/governance/GovernanceWrappedERC20.sol"; import {PluginRepoFactory} from "@aragon/osx/framework/plugin/repo/PluginRepoFactory.sol"; import {hashHelpers, PluginSetupRef} from "@aragon/osx/framework/plugin/setup/PluginSetupProcessorHelpers.sol"; -import {Multisig} from "@aragon/osx/plugins/governance/multisig/Multisig.sol"; +import {Multisig} from "../src/Multisig.sol"; +import {EmergencyMultisig} from "../src/EmergencyMultisig.sol"; import {PluginRepo} from "@aragon/osx/framework/plugin/repo/PluginRepo.sol"; import {IPluginSetup} from "@aragon/osx/framework/plugin/setup/IPluginSetup.sol"; import {PluginSetupProcessor} from "@aragon/osx/framework/plugin/setup/PluginSetupProcessor.sol"; @@ -20,23 +21,25 @@ import {DAO} from "@aragon/osx/core/dao/DAO.sol"; import {createERC1967Proxy} from "@aragon/osx/utils/Proxy.sol"; contract Deploy is Script { - DAO daoImplementation; - Multisig multisigImplementation; - - address governanceERC20Base; - address governanceWrappedERC20Base; - PluginSetupProcessor pluginSetupProcessor; - address pluginRepoFactory; - address tokenAddress; + DAO immutable daoImplementation; + + address immutable governanceERC20Base; + address immutable governanceWrappedERC20Base; + PluginSetupProcessor immutable pluginSetupProcessor; + address immutable pluginRepoFactory; + address immutable tokenAddress; address[] multisigMembers; - uint16 minStdApprovals; - uint16 minEmergencyApprovals; + uint64 immutable minStdProposalDelay; // Minimum delay of proposals on the optimistic voting plugin + uint16 immutable minStdApprovals; + uint16 immutable minEmergencyApprovals; + + string stdMultisigEnsDomain; + string emergencyMultisigEnsDomain; constructor() { // Implementations daoImplementation = new DAO(); - multisigImplementation = new Multisig(); governanceERC20Base = vm.envAddress("GOVERNANCE_ERC20_BASE"); governanceWrappedERC20Base = vm.envAddress( @@ -48,9 +51,15 @@ contract Deploy is Script { pluginRepoFactory = vm.envAddress("PLUGIN_REPO_FACTORY"); tokenAddress = vm.envAddress("TOKEN_ADDRESS"); + minStdProposalDelay = uint64(vm.envUint("MIN_STD_PROPOSAL_DELAY")); minStdApprovals = uint16(vm.envUint("MIN_STD_APPROVALS")); minEmergencyApprovals = uint16(vm.envUint("MIN_EMERGENCY_APPROVALS")); + stdMultisigEnsDomain = vm.envString("STD_MULTISIG_ENS_DOMAIN"); + emergencyMultisigEnsDomain = vm.envString( + "EMERGENCY_MULTISIG_ENS_DOMAIN" + ); + // JSON list of members string memory root = vm.projectRoot(); string memory path = string.concat(root, "/utils/members.json"); @@ -154,14 +163,12 @@ contract Deploy is Script { returns (address, PluginRepo, IPluginSetup.PreparedSetupData memory) { // Deploy plugin setup - MultisigPluginSetup pluginSetup = new MultisigPluginSetup( - multisigImplementation - ); + MultisigPluginSetup pluginSetup = new MultisigPluginSetup(); // Publish repo PluginRepo pluginRepo = PluginRepoFactory(pluginRepoFactory) .createPluginRepoWithFirstVersion( - "ens-of-the-multisig", + stdMultisigEnsDomain, address(pluginSetup), msg.sender, "0x", @@ -172,7 +179,8 @@ contract Deploy is Script { multisigMembers, Multisig.MultisigSettings( true, // onlyListed - minStdApprovals // minAppovals + minStdApprovals, // minAppovals + minStdProposalDelay // destination minDuration ) ); @@ -200,14 +208,12 @@ contract Deploy is Script { returns (address, PluginRepo, IPluginSetup.PreparedSetupData memory) { // Deploy plugin setup - EmergencyMultisigPluginSetup pluginSetup = new EmergencyMultisigPluginSetup( - multisigImplementation - ); + EmergencyMultisigPluginSetup pluginSetup = new EmergencyMultisigPluginSetup(); // Publish repo PluginRepo pluginRepo = PluginRepoFactory(pluginRepoFactory) .createPluginRepoWithFirstVersion( - "ens-of-the-emergency-multisig", + emergencyMultisigEnsDomain, address(pluginSetup), msg.sender, "0x", @@ -216,7 +222,7 @@ contract Deploy is Script { bytes memory settingsData = pluginSetup.encodeInstallationParameters( multisigMembers, - Multisig.MultisigSettings( + EmergencyMultisig.MultisigSettings( true, // onlyListed minEmergencyApprovals // minAppovals ) @@ -245,7 +251,11 @@ contract Deploy is Script { address emergencyProposer ) internal - returns (address, PluginRepo, IPluginSetup.PreparedSetupData memory) + returns ( + address plugin, + PluginRepo pluginRepo, + IPluginSetup.PreparedSetupData memory preparedSetupData + ) { // Deploy plugin setup OptimisticTokenVotingPluginSetup pluginSetup = new OptimisticTokenVotingPluginSetup( @@ -254,7 +264,7 @@ contract Deploy is Script { ); // Publish repo - PluginRepo pluginRepo = PluginRepoFactory(pluginRepoFactory) + pluginRepo = PluginRepoFactory(pluginRepoFactory) .createPluginRepoWithFirstVersion( "ens-of-the-optimistic-token-voting", address(pluginSetup), @@ -264,42 +274,40 @@ contract Deploy is Script { ); // Plugin settings - OptimisticTokenVotingPlugin.OptimisticGovernanceSettings - memory votingSettings = OptimisticTokenVotingPlugin - .OptimisticGovernanceSettings( - 200000, // minVetoRatio - 20% - 0, // minDuration (the condition will enforce it) - 0 // minProposerVotingPower - ); - - OptimisticTokenVotingPluginSetup.TokenSettings - memory tokenSettings = OptimisticTokenVotingPluginSetup - .TokenSettings(tokenAddress, "", ""); - - GovernanceERC20.MintSettings memory mintSettings = GovernanceERC20 - .MintSettings(new address[](0), new uint256[](0)); - - bytes memory settingsData = pluginSetup.encodeInstallationParams( - votingSettings, - tokenSettings, - mintSettings, - stdProposer, - emergencyProposer - ); - - ( - address plugin, - IPluginSetup.PreparedSetupData memory preparedSetupData - ) = pluginSetupProcessor.prepareInstallation( - address(dao), - PluginSetupProcessor.PrepareInstallationParams( - PluginSetupRef( - PluginRepo.Tag(1, 1), - PluginRepo(pluginRepo) - ), - settingsData - ) + bytes memory settingsData; + { + OptimisticTokenVotingPlugin.OptimisticGovernanceSettings + memory votingSettings = OptimisticTokenVotingPlugin + .OptimisticGovernanceSettings( + 200000, // minVetoRatio - 20% + 0, // minDuration (the condition will enforce it) + 0 // minProposerVotingPower + ); + + OptimisticTokenVotingPluginSetup.TokenSettings + memory tokenSettings = OptimisticTokenVotingPluginSetup + .TokenSettings(tokenAddress, "", ""); + + GovernanceERC20.MintSettings memory mintSettings = GovernanceERC20 + .MintSettings(new address[](0), new uint256[](0)); + + settingsData = pluginSetup.encodeInstallationParams( + votingSettings, + tokenSettings, + mintSettings, + minStdProposalDelay, + stdProposer, + emergencyProposer ); + } + + (plugin, preparedSetupData) = pluginSetupProcessor.prepareInstallation( + address(dao), + PluginSetupProcessor.PrepareInstallationParams( + PluginSetupRef(PluginRepo.Tag(1, 1), PluginRepo(pluginRepo)), + settingsData + ) + ); return (plugin, pluginRepo, preparedSetupData); } diff --git a/src/EmergencyMultisig.sol b/src/EmergencyMultisig.sol new file mode 100644 index 0000000..9fbcada --- /dev/null +++ b/src/EmergencyMultisig.sol @@ -0,0 +1,490 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +pragma solidity 0.8.17; + +import {SafeCastUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/math/SafeCastUpgradeable.sol"; + +import {IDAO} from "@aragon/osx/core/dao/IDAO.sol"; +import {IMembership} from "@aragon/osx/core/plugin/membership/IMembership.sol"; +import {PluginUUPSUpgradeable} from "@aragon/osx/core/plugin/PluginUUPSUpgradeable.sol"; + +import {ProposalUpgradeable} from "@aragon/osx/core/plugin/proposal/ProposalUpgradeable.sol"; +import {Addresslist} from "@aragon/osx/plugins/utils/Addresslist.sol"; +import {IMultisig} from "./interfaces/IMultisig.sol"; + +/// @title Multisig - Release 1, Build 1 +/// @author Aragon Association - 2022-2023 +/// @notice The on-chain multisig governance plugin in which a proposal passes if X out of Y approvals are met. +contract EmergencyMultisig is + IMultisig, + IMembership, + PluginUUPSUpgradeable, + ProposalUpgradeable, + Addresslist +{ + using SafeCastUpgradeable for uint256; + + /// @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 _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; + uint16 approvals; + ProposalParameters parameters; + mapping(address => bool) approvers; + IDAO.Action[] actions; + uint256 allowFailureMap; + } + + /// @notice A container for the proposal parameters. + /// @param minApprovals The number of approvals required. + /// @param snapshotBlock The number of the block prior to the proposal creation. + /// @param startDate The timestamp when the proposal starts. + /// @param endDate The timestamp when the proposal expires. + struct ProposalParameters { + uint16 minApprovals; + uint64 snapshotBlock; + uint64 startDate; + uint64 endDate; + } + + /// @notice A container for the plugin settings. + /// @param onlyListed Whether only listed addresses can create a proposal or not. + /// @param minApprovals The minimal number of approvals required for a proposal to pass. + struct MultisigSettings { + bool onlyListed; + uint16 minApprovals; + } + + /// @notice The [ERC-165](https://eips.ethereum.org/EIPS/eip-165) interface ID of the contract. + bytes4 internal constant MULTISIG_INTERFACE_ID = + this.initialize.selector ^ + this.updateMultisigSettings.selector ^ + this.createProposal.selector ^ + this.getProposal.selector; + + /// @notice The ID of the permission required to call the `addAddresses` and `removeAddresses` functions. + bytes32 public constant UPDATE_MULTISIG_SETTINGS_PERMISSION_ID = + keccak256("UPDATE_MULTISIG_SETTINGS_PERMISSION"); + + /// @notice A mapping between proposal IDs and proposal information. + mapping(uint256 => Proposal) internal proposals; + + /// @notice The current plugin settings. + MultisigSettings public multisigSettings; + + /// @notice Keeps track at which block number the multisig settings have been changed the last time. + /// @dev This variable prevents a proposal from being created in the same block in which the multisig settings change. + uint64 public lastMultisigSettingsChange; + + /// @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 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 if the minimal approvals value is out of bounds (less than 1 or greater than the number of members in the address list). + /// @param limit The maximal value. + /// @param actual The actual value. + error MinApprovalsOutOfBounds(uint16 limit, uint16 actual); + + /// @notice Thrown if the address list length is out of bounds. + /// @param limit The limit value. + /// @param actual The actual value. + error AddresslistLengthOutOfBounds(uint16 limit, uint256 actual); + + /// @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 Emitted when a proposal is approve by an approver. + /// @param proposalId The ID of the proposal. + /// @param approver The approver casting the approve. + event Approved(uint256 indexed proposalId, address indexed approver); + + /// @notice Emitted when the plugin settings are set. + /// @param onlyListed Whether only listed addresses can create a proposal. + /// @param minApprovals The minimum amount of approvals needed to pass a proposal. + event MultisigSettingsUpdated(bool onlyListed, uint16 indexed minApprovals); + + /// @notice Initializes Release 1, Build 2. + /// @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 _members The addresses of the initial members to be added. + /// @param _multisigSettings The multisig settings. + function initialize( + IDAO _dao, + address[] calldata _members, + MultisigSettings calldata _multisigSettings + ) external initializer { + __PluginUUPSUpgradeable_init(_dao); + + if (_members.length > type(uint16).max) { + revert AddresslistLengthOutOfBounds({ + limit: type(uint16).max, + actual: _members.length + }); + } + + _addAddresses(_members); + emit MembersAdded({members: _members}); + + _updateMultisigSettings(_multisigSettings); + } + + /// @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(PluginUUPSUpgradeable, ProposalUpgradeable) + returns (bool) + { + return + _interfaceId == MULTISIG_INTERFACE_ID || + _interfaceId == type(IMultisig).interfaceId || + _interfaceId == type(Addresslist).interfaceId || + _interfaceId == type(IMembership).interfaceId || + super.supportsInterface(_interfaceId); + } + + /// @inheritdoc IMultisig + function addAddresses( + address[] calldata _members + ) external auth(UPDATE_MULTISIG_SETTINGS_PERMISSION_ID) { + uint256 newAddresslistLength = addresslistLength() + _members.length; + + // Check if the new address list length would be greater than `type(uint16).max`, the maximal number of approvals. + if (newAddresslistLength > type(uint16).max) { + revert AddresslistLengthOutOfBounds({ + limit: type(uint16).max, + actual: newAddresslistLength + }); + } + + _addAddresses(_members); + + emit MembersAdded({members: _members}); + } + + /// @inheritdoc IMultisig + function removeAddresses( + address[] calldata _members + ) external auth(UPDATE_MULTISIG_SETTINGS_PERMISSION_ID) { + uint16 newAddresslistLength = uint16( + addresslistLength() - _members.length + ); + + // Check if the new address list length would become less than the current minimum number of approvals required. + if (newAddresslistLength < multisigSettings.minApprovals) { + revert MinApprovalsOutOfBounds({ + limit: newAddresslistLength, + actual: multisigSettings.minApprovals + }); + } + + _removeAddresses(_members); + + emit MembersRemoved({members: _members}); + } + + /// @notice Updates the plugin settings. + /// @param _multisigSettings The new settings. + function updateMultisigSettings( + MultisigSettings calldata _multisigSettings + ) external auth(UPDATE_MULTISIG_SETTINGS_PERMISSION_ID) { + _updateMultisigSettings(_multisigSettings); + } + + /// @notice Creates a new multisig proposal. + /// @param _metadata The metadata of the proposal. + /// @param _actions The actions that will be executed after 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. + /// @param _approveProposal If `true`, the sender will approve the proposal. + /// @param _tryExecution If `true`, execution is tried after the vote cast. The call does not revert if early execution is not possible. + /// @param _startDate The start date of the proposal. + /// @param _endDate The end date of the proposal. + /// @return proposalId The ID of the proposal. + function createProposal( + bytes calldata _metadata, + IDAO.Action[] calldata _actions, + uint256 _allowFailureMap, + bool _approveProposal, + bool _tryExecution, + uint64 _startDate, + uint64 _endDate + ) external returns (uint256 proposalId) { + if (multisigSettings.onlyListed && !isListed(_msgSender())) { + revert ProposalCreationForbidden(_msgSender()); + } + + 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 (lastMultisigSettingsChange > snapshotBlock) { + revert ProposalCreationForbidden(_msgSender()); + } + + if (_startDate == 0) { + _startDate = block.timestamp.toUint64(); + } else if (_startDate < block.timestamp.toUint64()) { + revert DateOutOfBounds({ + limit: block.timestamp.toUint64(), + actual: _startDate + }); + } + + if (_endDate < _startDate) { + revert DateOutOfBounds({limit: _startDate, actual: _endDate}); + } + + proposalId = _createProposal({ + _creator: _msgSender(), + _metadata: _metadata, + _startDate: _startDate, + _endDate: _endDate, + _actions: _actions, + _allowFailureMap: _allowFailureMap + }); + + // Create the proposal + Proposal storage proposal_ = proposals[proposalId]; + + proposal_.parameters.snapshotBlock = snapshotBlock; + proposal_.parameters.startDate = _startDate; + proposal_.parameters.endDate = _endDate; + proposal_.parameters.minApprovals = multisigSettings.minApprovals; + + // Reduce costs + if (_allowFailureMap != 0) { + proposal_.allowFailureMap = _allowFailureMap; + } + + for (uint256 i; i < _actions.length; ) { + proposal_.actions.push(_actions[i]); + unchecked { + ++i; + } + } + + if (_approveProposal) { + approve(proposalId, _tryExecution); + } + } + + /// @inheritdoc IMultisig + function approve(uint256 _proposalId, bool _tryExecution) public { + address approver = _msgSender(); + if (!_canApprove(_proposalId, approver)) { + revert ApprovalCastForbidden(_proposalId, approver); + } + + Proposal storage proposal_ = proposals[_proposalId]; + + // As the list can never become more than type(uint16).max(due to addAddresses check) + // It's safe to use unchecked as it would never overflow. + unchecked { + proposal_.approvals += 1; + } + + proposal_.approvers[approver] = true; + + emit Approved({proposalId: _proposalId, approver: approver}); + + if (_tryExecution && _canExecute(_proposalId)) { + _execute(_proposalId); + } + } + + /// @inheritdoc IMultisig + function canApprove( + uint256 _proposalId, + address _account + ) external view returns (bool) { + return _canApprove(_proposalId, _account); + } + + /// @inheritdoc IMultisig + function canExecute(uint256 _proposalId) external view returns (bool) { + return _canExecute(_proposalId); + } + + /// @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 approvals The number of approvals casted. + /// @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 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. + function getProposal( + uint256 _proposalId + ) + public + view + returns ( + bool executed, + uint16 approvals, + ProposalParameters memory parameters, + IDAO.Action[] memory actions, + uint256 allowFailureMap + ) + { + Proposal storage proposal_ = proposals[_proposalId]; + + executed = proposal_.executed; + approvals = proposal_.approvals; + parameters = proposal_.parameters; + actions = proposal_.actions; + allowFailureMap = proposal_.allowFailureMap; + } + + /// @inheritdoc IMultisig + function hasApproved( + uint256 _proposalId, + address _account + ) public view returns (bool) { + return proposals[_proposalId].approvers[_account]; + } + + /// @inheritdoc IMultisig + function execute(uint256 _proposalId) public { + if (!_canExecute(_proposalId)) { + revert ProposalExecutionForbidden(_proposalId); + } + + _execute(_proposalId); + } + + /// @inheritdoc IMembership + function isMember(address _account) external view returns (bool) { + return isListed(_account); + } + + /// @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].allowFailureMap + ); + } + + /// @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; + } + + if (!isListedAtBlock(_account, proposal_.parameters.snapshotBlock)) { + // The approver has no voting power. + return false; + } + + if (proposal_.approvers[_account]) { + // The approver has already approved + 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 Returns `true` if the proposal can be executed and `false` otherwise. + function _canExecute(uint256 _proposalId) internal view returns (bool) { + Proposal storage proposal_ = proposals[_proposalId]; + + // Verify that the proposal has not been executed or expired. + if (!_isProposalOpen(proposal_)) { + return false; + } + + return proposal_.approvals >= proposal_.parameters.minApprovals; + } + + /// @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 _multisigSettings The new settings. + function _updateMultisigSettings( + MultisigSettings calldata _multisigSettings + ) internal { + uint16 addresslistLength_ = uint16(addresslistLength()); + + if (_multisigSettings.minApprovals > addresslistLength_) { + revert MinApprovalsOutOfBounds({ + limit: addresslistLength_, + actual: _multisigSettings.minApprovals + }); + } + + if (_multisigSettings.minApprovals < 1) { + revert MinApprovalsOutOfBounds({ + limit: 1, + actual: _multisigSettings.minApprovals + }); + } + + multisigSettings = _multisigSettings; + lastMultisigSettingsChange = block.number.toUint64(); + + emit MultisigSettingsUpdated({ + onlyListed: _multisigSettings.onlyListed, + minApprovals: _multisigSettings.minApprovals + }); + } + + /// @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 + uint256[47] private __gap; +} diff --git a/src/Multisig.sol b/src/Multisig.sol new file mode 100644 index 0000000..829360c --- /dev/null +++ b/src/Multisig.sol @@ -0,0 +1,535 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +pragma solidity 0.8.17; + +import {SafeCastUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/math/SafeCastUpgradeable.sol"; + +import {IDAO} from "@aragon/osx/core/dao/IDAO.sol"; +import {IMembership} from "@aragon/osx/core/plugin/membership/IMembership.sol"; +import {PluginUUPSUpgradeable} from "@aragon/osx/core/plugin/PluginUUPSUpgradeable.sol"; + +import {ProposalUpgradeable} from "@aragon/osx/core/plugin/proposal/ProposalUpgradeable.sol"; +import {Addresslist} from "@aragon/osx/plugins/utils/Addresslist.sol"; +import {IMultisig} from "./interfaces/IMultisig.sol"; +import {OptimisticTokenVotingPlugin} from "./OptimisticTokenVotingPlugin.sol"; + +uint64 constant MULTISIG_PROPOSAL_EXPIRATION_PERIOD = 10 days; + +/// @title Multisig - Release 1, Build 1 +/// @author Aragon Association - 2022-2023 +/// @notice The on-chain multisig governance plugin in which a proposal passes if X out of Y approvals are met. +contract Multisig is + IMultisig, + IMembership, + PluginUUPSUpgradeable, + ProposalUpgradeable, + Addresslist +{ + using SafeCastUpgradeable for uint256; + + /// @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 destinationActions The actions to be executed by the destination plugin when the proposal passes. + /// @param metadataURI The URI on which human readable data ca ben retrieved + /// @param destinationPlugin The address of the plugin where the proposal will be created if it passes. + struct Proposal { + bool executed; + uint16 approvals; + ProposalParameters parameters; + mapping(address => bool) approvers; + IDAO.Action[] destinationActions; + bytes metadataURI; + OptimisticTokenVotingPlugin destinationPlugin; + } + + /// @notice A container for the proposal parameters. + /// @param minApprovals The number of approvals required. + /// @param snapshotBlock The number of the block prior to the proposal creation. + /// @param expirationDate The timestamp after which non-executed proposals expire. + /// @param destinationStartDate The timestamp after which people can vote on the destination plugin. 0 means now. + /// @param destinationEndDate The timestamp after which people can no longer vote on the destination plugin. + struct ProposalParameters { + uint16 minApprovals; + uint64 snapshotBlock; + uint64 expirationDate; + uint64 destinationStartDate; + uint64 destinationEndDate; + } + + /// @notice A container for the plugin settings. + /// @param onlyListed Whether only listed addresses can create a proposal or not. + /// @param minApprovals The minimal number of approvals required for a proposal to pass. + struct MultisigSettings { + bool onlyListed; + uint16 minApprovals; + uint64 destinationMinDuration; + } + + /// @notice The [ERC-165](https://eips.ethereum.org/EIPS/eip-165) interface ID of the contract. + bytes4 internal constant MULTISIG_INTERFACE_ID = + this.initialize.selector ^ + this.updateMultisigSettings.selector ^ + this.createProposal.selector ^ + this.getProposal.selector; + + /// @notice The ID of the permission required to call the `addAddresses` and `removeAddresses` functions. + bytes32 public constant UPDATE_MULTISIG_SETTINGS_PERMISSION_ID = + keccak256("UPDATE_MULTISIG_SETTINGS_PERMISSION"); + + /// @notice A mapping between proposal IDs and proposal information. + mapping(uint256 => Proposal) internal proposals; + + /// @notice The current plugin settings. + MultisigSettings public multisigSettings; + + /// @notice Keeps track at which block number the multisig settings have been changed the last time. + /// @dev This variable prevents a proposal from being created in the same block in which the multisig settings change. + uint64 public lastMultisigSettingsChange; + + /// @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 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 if the minimal approvals value is out of bounds (less than 1 or greater than the number of members in the address list). + /// @param limit The maximal value. + /// @param actual The actual value. + error MinApprovalsOutOfBounds(uint16 limit, uint16 actual); + + /// @notice Thrown if the address list length is out of bounds. + /// @param limit The limit value. + /// @param actual The actual value. + error AddresslistLengthOutOfBounds(uint16 limit, uint256 actual); + + /// @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 Emitted when a proposal is approve by an approver. + /// @param proposalId The ID of the proposal. + /// @param approver The approver casting the approve. + event Approved(uint256 indexed proposalId, address indexed approver); + + /// @notice Emitted when a proposal passes and is relayed to the destination plugin. + /// @param proposalId The ID of the proposal. + event Executed(uint256 indexed proposalId); + + /// @notice Emitted when the plugin settings are set. + /// @param onlyListed Whether only listed addresses can create a proposal. + /// @param minApprovals The minimum amount of approvals needed to pass a proposal. + /// @param destinationMinDuration The minimum duration (in seconds) that will be required on the destination plugin + event MultisigSettingsUpdated( + bool onlyListed, + uint16 indexed minApprovals, + uint64 destinationMinDuration + ); + + /// @notice Initializes Release 1, Build 2. + /// @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 _members The addresses of the initial members to be added. + /// @param _multisigSettings The multisig settings. + function initialize( + IDAO _dao, + address[] calldata _members, + MultisigSettings calldata _multisigSettings + ) external initializer { + __PluginUUPSUpgradeable_init(_dao); + + if (_members.length > type(uint16).max) { + revert AddresslistLengthOutOfBounds({ + limit: type(uint16).max, + actual: _members.length + }); + } + + _addAddresses(_members); + emit MembersAdded({members: _members}); + + _updateMultisigSettings(_multisigSettings); + } + + /// @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(PluginUUPSUpgradeable, ProposalUpgradeable) + returns (bool) + { + return + _interfaceId == MULTISIG_INTERFACE_ID || + _interfaceId == type(IMultisig).interfaceId || + _interfaceId == type(Addresslist).interfaceId || + _interfaceId == type(IMembership).interfaceId || + super.supportsInterface(_interfaceId); + } + + /// @inheritdoc IMultisig + function addAddresses( + address[] calldata _members + ) external auth(UPDATE_MULTISIG_SETTINGS_PERMISSION_ID) { + uint256 newAddresslistLength = addresslistLength() + _members.length; + + // Check if the new address list length would be greater than `type(uint16).max`, the maximal number of approvals. + if (newAddresslistLength > type(uint16).max) { + revert AddresslistLengthOutOfBounds({ + limit: type(uint16).max, + actual: newAddresslistLength + }); + } + + _addAddresses(_members); + + emit MembersAdded({members: _members}); + } + + /// @inheritdoc IMultisig + function removeAddresses( + address[] calldata _members + ) external auth(UPDATE_MULTISIG_SETTINGS_PERMISSION_ID) { + uint16 newAddresslistLength = uint16( + addresslistLength() - _members.length + ); + + // Check if the new address list length would become less than the current minimum number of approvals required. + if (newAddresslistLength < multisigSettings.minApprovals) { + revert MinApprovalsOutOfBounds({ + limit: multisigSettings.minApprovals, + actual: newAddresslistLength + }); + } + + _removeAddresses(_members); + + emit MembersRemoved({members: _members}); + } + + /// @notice Updates the plugin settings. + /// @param _multisigSettings The new settings. + function updateMultisigSettings( + MultisigSettings calldata _multisigSettings + ) external auth(UPDATE_MULTISIG_SETTINGS_PERMISSION_ID) { + _updateMultisigSettings(_multisigSettings); + } + + /// @notice Creates a new multisig proposal. + /// @param _metadataURI The metadataURI of the proposal. + /// @param _destinationActions The actions that will be executed after the proposal passes. + /// @param _destinationPlugin The address of the plugin to forward the proposal to when it passes. + /// @param _approveProposal If `true`, the sender will approve the proposal. + /// @param _destinationStartDate The start date of the proposal. + /// @param _destinationEndDate The expiration date for proposals that have not been executed. + /// @return proposalId The ID of the proposal. + function createProposal( + bytes calldata _metadataURI, + IDAO.Action[] calldata _destinationActions, + OptimisticTokenVotingPlugin _destinationPlugin, + bool _approveProposal, + uint64 _destinationStartDate, + uint64 _destinationEndDate + ) external returns (uint256 proposalId) { + if (multisigSettings.onlyListed && !isListed(_msgSender())) { + revert ProposalCreationForbidden(_msgSender()); + } + + 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 (lastMultisigSettingsChange > snapshotBlock) { + revert ProposalCreationForbidden(_msgSender()); + } + + // Revert if the given timestamps would revert, even if it was executed in this very block + _validateProposalDates(_destinationStartDate, _destinationEndDate); + + proposalId = _createProposal({ + _creator: _msgSender(), + _metadata: _metadataURI, + _startDate: _destinationStartDate, + _endDate: _destinationEndDate, + _actions: _destinationActions, + _allowFailureMap: 0 + }); + + // Create the proposal + Proposal storage proposal_ = proposals[proposalId]; + proposal_.metadataURI = _metadataURI; + proposal_.destinationPlugin = _destinationPlugin; + + proposal_.parameters.snapshotBlock = snapshotBlock; + proposal_.parameters.expirationDate = + uint64(block.timestamp) + + MULTISIG_PROPOSAL_EXPIRATION_PERIOD; + proposal_.parameters.destinationStartDate = _destinationStartDate; + proposal_.parameters.destinationEndDate = _destinationEndDate; + proposal_.parameters.minApprovals = multisigSettings.minApprovals; + + for (uint256 i; i < _destinationActions.length; ) { + proposal_.destinationActions.push(_destinationActions[i]); + unchecked { + ++i; + } + } + + if (_approveProposal) { + approve(proposalId, false); + } + } + + /// @inheritdoc IMultisig + function approve(uint256 _proposalId, bool _tryExecution) public { + address approver = _msgSender(); + if (!_canApprove(_proposalId, approver)) { + revert ApprovalCastForbidden(_proposalId, approver); + } + + Proposal storage proposal_ = proposals[_proposalId]; + + // As the list can never become more than type(uint16).max(due to addAddresses check) + // It's safe to use unchecked as it would never overflow. + unchecked { + proposal_.approvals += 1; + } + + proposal_.approvers[approver] = true; + + emit Approved({proposalId: _proposalId, approver: approver}); + + if (_tryExecution && _canExecute(_proposalId)) { + _execute(_proposalId); + } + } + + /// @inheritdoc IMultisig + function canApprove( + uint256 _proposalId, + address _account + ) external view returns (bool) { + return _canApprove(_proposalId, _account); + } + + /// @inheritdoc IMultisig + function canExecute(uint256 _proposalId) external view returns (bool) { + return _canExecute(_proposalId); + } + + /// @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 approvals The number of approvals casted. + /// @return parameters The parameters of the proposal vote. + /// @return metadataURI The URI at which the corresponding human readable data can be found. + /// @return destinationActions The actions to be executed by the destination plugin after the proposal passes. + /// @return destinationPlugin The address of the plugin where the proposal will be forwarded to when executed. + function getProposal( + uint256 _proposalId + ) + public + view + returns ( + bool executed, + uint16 approvals, + ProposalParameters memory parameters, + bytes memory metadataURI, + IDAO.Action[] memory destinationActions, + OptimisticTokenVotingPlugin destinationPlugin + ) + { + Proposal storage proposal_ = proposals[_proposalId]; + + executed = proposal_.executed; + approvals = proposal_.approvals; + parameters = proposal_.parameters; + metadataURI = proposal_.metadataURI; + destinationActions = proposal_.destinationActions; + destinationPlugin = proposal_.destinationPlugin; + } + + /// @inheritdoc IMultisig + function hasApproved( + uint256 _proposalId, + address _account + ) public view returns (bool) { + return proposals[_proposalId].approvers[_account]; + } + + /// @inheritdoc IMultisig + function execute(uint256 _proposalId) public { + if (!_canExecute(_proposalId)) { + revert ProposalExecutionForbidden(_proposalId); + } + + _execute(_proposalId); + } + + /// @inheritdoc IMembership + function isMember(address _account) external view returns (bool) { + return isListed(_account); + } + + /// @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; + emit Executed(_proposalId); + + proposal_.destinationPlugin.createProposal( + proposal_.metadataURI, + proposal_.destinationActions, + 0, // allowFailureMap + proposal_.parameters.destinationStartDate, + proposal_.parameters.destinationEndDate + ); + } + + /// @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 is executed or expired + return false; + } + + if (!isListedAtBlock(_account, proposal_.parameters.snapshotBlock)) { + // The approver has no voting power. + return false; + } + + if (proposal_.approvers[_account]) { + // The approver has already approved + 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 Returns `true` if the proposal can be executed and `false` otherwise. + function _canExecute(uint256 _proposalId) internal view returns (bool) { + Proposal storage proposal_ = proposals[_proposalId]; + + // Verify that the proposal has not been executed or expired. + if (!_isProposalOpen(proposal_)) { + return false; + } + + return proposal_.approvals >= proposal_.parameters.minApprovals; + } + + /// @notice Internal function to check if a proposal 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.expirationDate >= currentTimestamp64; + } + + /// @notice Internal function to update the plugin settings. + /// @param _multisigSettings The new settings. + function _updateMultisigSettings( + MultisigSettings calldata _multisigSettings + ) internal { + uint16 addresslistLength_ = uint16(addresslistLength()); + + if (_multisigSettings.minApprovals > addresslistLength_) { + revert MinApprovalsOutOfBounds({ + limit: addresslistLength_, + actual: _multisigSettings.minApprovals + }); + } + + if (_multisigSettings.minApprovals < 1) { + revert MinApprovalsOutOfBounds({ + limit: 1, + actual: _multisigSettings.minApprovals + }); + } + + multisigSettings = _multisigSettings; + lastMultisigSettingsChange = block.number.toUint64(); + + emit MultisigSettingsUpdated({ + onlyListed: _multisigSettings.onlyListed, + minApprovals: _multisigSettings.minApprovals, + destinationMinDuration: _multisigSettings.destinationMinDuration + }); + } + + /// @notice Attempts to detect eventual issues on the destination plugin ahead of time. + /// @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. + function _validateProposalDates( + uint64 _start, + uint64 _end + ) internal view virtual { + uint64 currentTimestamp = block.timestamp.toUint64(); + uint64 startDate; + + if (_start == 0) { + startDate = currentTimestamp; + } else { + startDate = _start; + + if (startDate < currentTimestamp) { + revert DateOutOfBounds({ + limit: currentTimestamp, + actual: startDate + }); + } + } + + // Compare against the earliest end date + if ( + _end != 0 && + _end < startDate + multisigSettings.destinationMinDuration + ) { + revert DateOutOfBounds({ + limit: startDate + multisigSettings.destinationMinDuration, + actual: _end + }); + } + } + + /// @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 + uint256[47] private __gap; +} diff --git a/src/OptimisticTokenVotingPlugin.sol b/src/OptimisticTokenVotingPlugin.sol index 073ef95..a96cf40 100644 --- a/src/OptimisticTokenVotingPlugin.sol +++ b/src/OptimisticTokenVotingPlugin.sol @@ -84,7 +84,7 @@ contract OptimisticTokenVotingPlugin is this.updateOptimisticGovernanceSettings.selector; /// @notice An [OpenZeppelin `Votes`](https://docs.openzeppelin.com/contracts/4.x/api/governance#Votes) compatible contract referencing the token being used for voting. - IVotesUpgradeable private votingToken; + IVotesUpgradeable public votingToken; /// @notice The struct storing the governance settings. OptimisticGovernanceSettings private governanceSettings; diff --git a/src/conditions/StandardProposalCondition.sol b/src/conditions/StandardProposalCondition.sol index 1ab0a80..d6b8361 100644 --- a/src/conditions/StandardProposalCondition.sol +++ b/src/conditions/StandardProposalCondition.sol @@ -12,7 +12,7 @@ import {OptimisticTokenVotingPlugin} from "../OptimisticTokenVotingPlugin.sol"; /// @notice An abstract contract for non-upgradeable contracts instantiated via the `new` keyword to inherit from to support customary permissions depending on arbitrary on-chain state. contract StandardProposalCondition is ERC165, IPermissionCondition { address dao; - uint32 minDelay; + uint64 minDelay; error EmptyDao(); error EmptyDelay(); @@ -22,7 +22,7 @@ contract StandardProposalCondition is ERC165, IPermissionCondition { * @param _dao The address of the DAO on which permissions are defined * @param _minDelay The minimum amount of seconds to enforce for proposals created */ - constructor(address _dao, uint32 _minDelay) { + constructor(address _dao, uint64 _minDelay) { if (_dao == address(0)) revert EmptyDao(); else if (_minDelay == 0) revert EmptyDelay(); diff --git a/src/interfaces/IMultisig.sol b/src/interfaces/IMultisig.sol new file mode 100644 index 0000000..9ca5dcb --- /dev/null +++ b/src/interfaces/IMultisig.sol @@ -0,0 +1,53 @@ +// 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 Adds new members to the address list. Previously, it checks if the new address list length would be greater than `type(uint16).max`, the maximal number of approvals. + /// @param _members The addresses of the members to be added. + function addAddresses(address[] calldata _members) external; + + /// @notice Removes existing members from the address list. Previously, it checks if the new address list length is at least as long as the minimum approvals parameter requires. Note that `minApprovals` is must be at least 1 so the address list cannot become empty. + /// @param _members The addresses of the members to be removed. + function removeAddresses(address[] calldata _members) external; + + /// @notice Approves and, optionally, executes the proposal. + /// @param _proposalId The ID of the proposal. + /// @param _tryExecution If `true`, execution is tried after the approval cast. The call does not revert if execution is not possible. + function approve(uint256 _proposalId, bool _tryExecution) 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/src/metadata/optimistic-token-voting-build-metadata.json b/src/metadata/optimistic-token-voting-build-metadata.json deleted file mode 100644 index e80e96a..0000000 --- a/src/metadata/optimistic-token-voting-build-metadata.json +++ /dev/null @@ -1,93 +0,0 @@ -{ - "ui": {}, - "change": "Initial version of the plugin", - "pluginSetup": { - "prepareInstallation": { - "description": "The information required for the installation.", - "inputs": [ - { - "components": [ - { - "internalType": "uint32", - "name": "minVetoRatio", - "type": "uint32", - "description": "The minimum ratio of the token supply to veto a proposal. Its value has to be in the interval [0, 10^6] defined by `RATIO_BASE = 10**6`." - }, - { - "internalType": "uint64", - "name": "minDuration", - "type": "uint64", - "description": "The minimum duration of the proposal vote in seconds." - }, - { - "internalType": "uint256", - "name": "minProposerVotingPower", - "type": "uint256", - "description": "The minimum voting power required to create a proposal." - } - ], - "internalType": "struct OptimisticTokenVotingPlugin.OptimisticGovernanceSettings", - "name": "governanceSettings", - "type": "tuple", - "description": "The governance settings that will be enforced when proposals are created." - }, - { - "components": [ - { - "internalType": "address", - "name": "token", - "type": "address", - "description": "The token address. If this is `address(0)`, a new `GovernanceERC20` token is deployed. If not, the existing token is wrapped as an `GovernanceWrappedERC20`." - }, - { - "internalType": "string", - "name": "name", - "type": "string", - "description": "The token name. This parameter is only relevant if the token address is `address(0)`." - }, - { - "internalType": "string", - "name": "symbol", - "type": "string", - "description": "The token symbol. This parameter is only relevant if the token address is `address(0)`." - } - ], - "internalType": "struct OptimisticTokenVotingPluginSetup.TokenSettings", - "name": "tokenSettings", - "type": "tuple", - "description": "The token settings that either specify an existing ERC-20 token (`token = address(0)`) or the name and symbol of a new `GovernanceERC20` token to be created." - }, - { - "components": [ - { - "internalType": "address[]", - "name": "receivers", - "type": "address[]", - "description": "The receivers of the tokens." - }, - { - "internalType": "uint256[]", - "name": "amounts", - "type": "uint256[]", - "description": "The amounts of tokens to be minted for each receiver." - } - ], - "internalType": "struct GovernanceERC20.MintSettings", - "name": "mintSettings", - "type": "tuple", - "description": "The token mint settings struct containing the `receivers` and `amounts`." - }, - { - "internalType": "address[]", - "name": "proposers", - "type": "address[]", - "description": "The initial list of addresses that can create proposals." - } - ] - }, - "prepareUninstallation": { - "description": "No input is required for the uninstallation.", - "inputs": [] - } - } -} diff --git a/src/metadata/optimistic-token-voting-release-metadata.json b/src/metadata/optimistic-token-voting-release-metadata.json deleted file mode 100644 index 72cd94f..0000000 --- a/src/metadata/optimistic-token-voting-release-metadata.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "name": "Optimistic Token Voting Plugin", - "description": "", - "images": {} -} diff --git a/src/setup/EmergencyMultisigPluginSetup.sol b/src/setup/EmergencyMultisigPluginSetup.sol index 5a96b12..866c0aa 100644 --- a/src/setup/EmergencyMultisigPluginSetup.sol +++ b/src/setup/EmergencyMultisigPluginSetup.sol @@ -6,18 +6,18 @@ import {IDAO} from "@aragon/osx/core/dao/IDAO.sol"; import {DAO} from "@aragon/osx/core/dao/DAO.sol"; import {PermissionLib} from "@aragon/osx/core/permission/PermissionLib.sol"; import {PluginSetup, IPluginSetup} from "@aragon/osx/framework/plugin/setup/PluginSetup.sol"; -import {Multisig} from "@aragon/osx/plugins/governance/multisig/Multisig.sol"; +import {EmergencyMultisig} from "../EmergencyMultisig.sol"; -/// @title MultisigSetup - Release 1, Build 1 +/// @title EmergencyMultisigSetup - Release 1, Build 1 /// @author Aragon Association - 2022-2024 -/// @notice The setup contract of the `Multisig` plugin. +/// @notice The setup contract of the `EmergencyMultisig` plugin. contract EmergencyMultisigPluginSetup is PluginSetup { - /// @notice The address of `Multisig` plugin logic contract to be used in creating proxy contracts. - Multisig private immutable multisigBase; + /// @notice The address of `EmergencyMultisig` plugin logic contract to be used in creating proxy contracts. + EmergencyMultisig private immutable multisigBase; - /// @notice The contract constructor, that deploys the `Multisig` plugin logic contract. - constructor(Multisig _implementation) { - multisigBase = _implementation; + /// @notice The contract constructor, that deploys the `EmergencyMultisig` plugin logic contract. + constructor() { + multisigBase = new EmergencyMultisig(); } /// @inheritdoc IPluginSetup @@ -28,17 +28,17 @@ contract EmergencyMultisigPluginSetup is PluginSetup { external returns (address plugin, PreparedSetupData memory preparedSetupData) { - // Decode `_data` to extract the params needed for deploying and initializing `Multisig` plugin. + // Decode `_data` to extract the params needed for deploying and initializing `EmergencyMultisig` plugin. ( address[] memory members, - Multisig.MultisigSettings memory multisigSettings - ) = abi.decode(_data, (address[], Multisig.MultisigSettings)); + EmergencyMultisig.MultisigSettings memory multisigSettings + ) = decodeInstallationParams(_data); // Prepare and Deploy the plugin proxy. plugin = createERC1967Proxy( address(multisigBase), abi.encodeCall( - Multisig.initialize, + EmergencyMultisig.initialize, (IDAO(_dao), members, multisigSettings) ) ); @@ -121,7 +121,7 @@ contract EmergencyMultisigPluginSetup is PluginSetup { /// @notice Encodes the given installation parameters into a byte array function encodeInstallationParameters( address[] memory _members, - Multisig.MultisigSettings memory _multisigSettings + EmergencyMultisig.MultisigSettings memory _multisigSettings ) external pure returns (bytes memory) { return abi.encode(_members, _multisigSettings); } @@ -134,12 +134,12 @@ contract EmergencyMultisigPluginSetup is PluginSetup { pure returns ( address[] memory _members, - Multisig.MultisigSettings memory _multisigSettings + EmergencyMultisig.MultisigSettings memory _multisigSettings ) { (_members, _multisigSettings) = abi.decode( _data, - (address[], Multisig.MultisigSettings) + (address[], EmergencyMultisig.MultisigSettings) ); } } diff --git a/src/setup/MultisigPluginSetup.sol b/src/setup/MultisigPluginSetup.sol index b163cc2..4a65dbb 100644 --- a/src/setup/MultisigPluginSetup.sol +++ b/src/setup/MultisigPluginSetup.sol @@ -6,7 +6,7 @@ import {IDAO} from "@aragon/osx/core/dao/IDAO.sol"; import {DAO} from "@aragon/osx/core/dao/DAO.sol"; import {PermissionLib} from "@aragon/osx/core/permission/PermissionLib.sol"; import {PluginSetup, IPluginSetup} from "@aragon/osx/framework/plugin/setup/PluginSetup.sol"; -import {Multisig} from "@aragon/osx/plugins/governance/multisig/Multisig.sol"; +import {Multisig} from "../Multisig.sol"; /// @title MultisigSetup - Release 1, Build 1 /// @author Aragon Association - 2022-2023 @@ -16,8 +16,8 @@ contract MultisigPluginSetup is PluginSetup { Multisig private immutable multisigBase; /// @notice The contract constructor, that deploys the `Multisig` plugin logic contract. - constructor(Multisig _implementation) { - multisigBase = _implementation; + constructor() { + multisigBase = new Multisig(); } /// @inheritdoc IPluginSetup diff --git a/src/setup/OptimisticTokenVotingPluginSetup.sol b/src/setup/OptimisticTokenVotingPluginSetup.sol index 050aaf4..bb18feb 100644 --- a/src/setup/OptimisticTokenVotingPluginSetup.sol +++ b/src/setup/OptimisticTokenVotingPluginSetup.sol @@ -18,8 +18,6 @@ import {IGovernanceWrappedERC20} from "@aragon/osx/token/ERC20/governance/IGover import {OptimisticTokenVotingPlugin} from "../OptimisticTokenVotingPlugin.sol"; import {StandardProposalCondition} from "../conditions/StandardProposalCondition.sol"; -uint32 constant MIN_DELAY = 60 * 60 * 24 * 7 * 2; - /// @title OptimisticTokenVotingPluginSetup /// @author Aragon Association - 2022-2023 /// @notice The setup contract of the `OptimisticTokenVoting` plugin. @@ -89,6 +87,7 @@ contract OptimisticTokenVotingPluginSetup is PluginSetup { TokenSettings memory tokenSettings, // only used for GovernanceERC20 (when token is not passed) GovernanceERC20.MintSettings memory mintSettings, + uint64 stdProposalMinDelay, address stdProposer, address emergencyProposer ) = decodeInstallationParams(_installParameters); @@ -187,22 +186,23 @@ contract OptimisticTokenVotingPluginSetup is PluginSetup { condition: PermissionLib.NO_CONDITION, permissionId: DAO(payable(_dao)).EXECUTE_PERMISSION_ID() }); + { + // Deploy the Std proposal condition + StandardProposalCondition stdProposalCondition = new StandardProposalCondition( + address(_dao), + stdProposalMinDelay + ); - // Deploy the Std proposal condition - StandardProposalCondition stdProposalCondition = new StandardProposalCondition( - address(_dao), - MIN_DELAY - ); - - // Proposer plugins can create proposals - permissions[3] = PermissionLib.MultiTargetPermission({ - operation: PermissionLib.Operation.Grant, - where: plugin, - who: stdProposer, - condition: address(stdProposalCondition), - permissionId: optimisticTokenVotingPluginBase - .PROPOSER_PERMISSION_ID() - }); + // Proposer plugins can create proposals + permissions[3] = PermissionLib.MultiTargetPermission({ + operation: PermissionLib.Operation.Grant, + where: plugin, + who: stdProposer, + condition: address(stdProposalCondition), + permissionId: optimisticTokenVotingPluginBase + .PROPOSER_PERMISSION_ID() + }); + } permissions[4] = PermissionLib.MultiTargetPermission({ operation: PermissionLib.Operation.Grant, where: plugin, @@ -311,6 +311,7 @@ contract OptimisticTokenVotingPluginSetup is PluginSetup { TokenSettings calldata _tokenSettings, // only used for GovernanceERC20 (when a token is not passed) GovernanceERC20.MintSettings calldata _mintSettings, + uint64 _stdProposalMinDelay, address _stdProposer, address _emergencyProposer ) external pure returns (bytes memory) { @@ -319,6 +320,7 @@ contract OptimisticTokenVotingPluginSetup is PluginSetup { _votingSettings, _tokenSettings, _mintSettings, + _stdProposalMinDelay, _stdProposer, _emergencyProposer ); @@ -336,6 +338,7 @@ contract OptimisticTokenVotingPluginSetup is PluginSetup { TokenSettings memory tokenSettings, // only used for GovernanceERC20 (when token is not passed) GovernanceERC20.MintSettings memory mintSettings, + uint64 _stdProposalMinDelay, address _stdProposer, address _emergencyProposer ) @@ -344,6 +347,7 @@ contract OptimisticTokenVotingPluginSetup is PluginSetup { votingSettings, tokenSettings, mintSettings, + _stdProposalMinDelay, _stdProposer, _emergencyProposer ) = abi.decode( @@ -352,6 +356,7 @@ contract OptimisticTokenVotingPluginSetup is PluginSetup { OptimisticTokenVotingPlugin.OptimisticGovernanceSettings, TokenSettings, GovernanceERC20.MintSettings, + uint64, address, address ) diff --git a/test/Multisig.t.sol b/test/Multisig.t.sol new file mode 100644 index 0000000..9129953 --- /dev/null +++ b/test/Multisig.t.sol @@ -0,0 +1,3830 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.17; + +import {AragonTest} from "./base/AragonTest.sol"; +import {StandardProposalCondition} from "../src/conditions/StandardProposalCondition.sol"; +import {OptimisticTokenVotingPlugin} from "../src/OptimisticTokenVotingPlugin.sol"; +import {Multisig} from "../src/Multisig.sol"; +import {IMultisig} from "../src/interfaces/IMultisig.sol"; +import {DAO} from "@aragon/osx/core/dao/DAO.sol"; +import {IDAO} from "@aragon/osx/core/dao/IDAO.sol"; +import {PermissionManager} from "@aragon/osx/core/permission/PermissionManager.sol"; +import {IProposal} from "@aragon/osx/core/plugin/proposal/IProposal.sol"; +import {IPlugin} from "@aragon/osx/core/plugin/IPlugin.sol"; +import {IMembership} from "@aragon/osx/core/plugin/membership/IMembership.sol"; +import {Addresslist} from "@aragon/osx/plugins/utils/Addresslist.sol"; +import {DaoUnauthorized} from "@aragon/osx/core/utils/auth.sol"; +import {IERC165Upgradeable} from "@openzeppelin/contracts-upgradeable/utils/introspection/IERC165Upgradeable.sol"; +import {createProxyAndCall} from "./helpers.sol"; + +contract MultisigTest is AragonTest { + DAO dao; + Multisig plugin; + OptimisticTokenVotingPlugin optimisticPlugin; + + // Events/errors to be tested here (duplicate) + event MultisigSettingsUpdated( + bool onlyListed, + uint16 indexed minApprovals, + uint64 destinationMinDuration + ); + event MembersAdded(address[] members); + event MembersRemoved(address[] members); + error InvalidAddresslistUpdate(address member); + event ProposalCreated( + uint256 indexed proposalId, + address indexed creator, + uint64 startDate, + uint64 endDate, + bytes metadata, + IDAO.Action[] actions, + uint256 allowFailureMap + ); + event Approved(uint256 indexed proposalId, address indexed approver); + event Executed(uint256 indexed proposalId); + + function setUp() public { + vm.startPrank(alice); + + (dao, plugin, optimisticPlugin) = makeDaoWithMultisigAndOptimistic(alice); + } + + function test_RevertsIfTryingToReinitializa() public { + // Deploy a new multisig instance + Multisig.MultisigSettings memory settings = Multisig.MultisigSettings({ + onlyListed: true, + minApprovals: 3, + destinationMinDuration: 4 days + }); + address[] memory signers = new address[](4); + signers[0] = alice; + signers[1] = bob; + signers[2] = carol; + signers[3] = david; + + plugin = Multisig( + createProxyAndCall( + address(MULTISIG_BASE), + abi.encodeCall(Multisig.initialize, (dao, signers, settings)) + ) + ); + + // Reinitialize should fail + vm.expectRevert("Initializable: contract is already initialized"); + plugin.initialize(dao, signers, settings); + } + + function test_AddsInitialAddresses() public { + // Deploy with 4 signers + Multisig.MultisigSettings memory settings = Multisig.MultisigSettings({ + onlyListed: true, + minApprovals: 3, + destinationMinDuration: 4 days + }); + address[] memory signers = new address[](4); + signers[0] = alice; + signers[1] = bob; + signers[2] = carol; + signers[3] = david; + + plugin = Multisig( + createProxyAndCall( + address(MULTISIG_BASE), + abi.encodeCall(Multisig.initialize, (dao, signers, settings)) + ) + ); + + assertEq(plugin.isListed(alice), true, "Should be a member"); + assertEq(plugin.isListed(bob), true, "Should be a member"); + assertEq(plugin.isListed(carol), true, "Should be a member"); + assertEq(plugin.isListed(david), true, "Should be a member"); + + // Redeploy with just 2 signers + settings.minApprovals = 1; + + signers = new address[](2); + signers[0] = alice; + signers[1] = bob; + + plugin = Multisig( + createProxyAndCall( + address(MULTISIG_BASE), + abi.encodeCall(Multisig.initialize, (dao, signers, settings)) + ) + ); + + assertEq(plugin.isListed(alice), true, "Should be a member"); + assertEq(plugin.isListed(bob), true, "Should be a member"); + assertEq(plugin.isListed(carol), false, "Should not be a member"); + assertEq(plugin.isListed(david), false, "Should not be a member"); + } + + function test_ShouldSetMinApprovals() public { + // 2 + Multisig.MultisigSettings memory settings = Multisig.MultisigSettings({ + onlyListed: true, + minApprovals: 2, + destinationMinDuration: 4 days + }); + address[] memory signers = new address[](4); + signers[0] = alice; + signers[1] = bob; + signers[2] = carol; + signers[3] = david; + + plugin = Multisig( + createProxyAndCall( + address(MULTISIG_BASE), + abi.encodeCall(Multisig.initialize, (dao, signers, settings)) + ) + ); + + (, uint16 minApprovals, ) = plugin.multisigSettings(); + assertEq(minApprovals, uint16(2), "Incorrect minApprovals"); + + // Redeploy with 1 + settings.minApprovals = 1; + + plugin = Multisig( + createProxyAndCall( + address(MULTISIG_BASE), + abi.encodeCall(Multisig.initialize, (dao, signers, settings)) + ) + ); + + (, minApprovals, ) = plugin.multisigSettings(); + assertEq(minApprovals, uint16(1), "Incorrect minApprovals"); + } + + function test_ShouldSetOnlyListed() public { + // Deploy with true + Multisig.MultisigSettings memory settings = Multisig.MultisigSettings({ + onlyListed: true, + minApprovals: 3, + destinationMinDuration: 4 days + }); + address[] memory signers = new address[](4); + signers[0] = alice; + signers[1] = bob; + signers[2] = carol; + signers[3] = david; + + plugin = Multisig( + createProxyAndCall( + address(MULTISIG_BASE), + abi.encodeCall(Multisig.initialize, (dao, signers, settings)) + ) + ); + + (bool onlyListed, , ) = plugin.multisigSettings(); + assertEq(onlyListed, true, "Incorrect onlyListed"); + + // Redeploy with false + settings.onlyListed = false; + + plugin = Multisig( + createProxyAndCall( + address(MULTISIG_BASE), + abi.encodeCall(Multisig.initialize, (dao, signers, settings)) + ) + ); + + (onlyListed, , ) = plugin.multisigSettings(); + assertEq(onlyListed, false, "Incorrect onlyListed"); + } + + function test_ShouldSetDestinationMinDuration() public { + // Deploy with 5 days + Multisig.MultisigSettings memory settings = Multisig.MultisigSettings({ + onlyListed: true, + minApprovals: 3, + destinationMinDuration: 5 days + }); + address[] memory signers = new address[](4); + signers[0] = alice; + signers[1] = bob; + signers[2] = carol; + signers[3] = david; + + plugin = Multisig( + createProxyAndCall( + address(MULTISIG_BASE), + abi.encodeCall(Multisig.initialize, (dao, signers, settings)) + ) + ); + + (, , uint64 minDuration) = plugin.multisigSettings(); + assertEq(minDuration, 5 days, "Incorrect minDuration"); + + // Redeploy with 3 days + settings.destinationMinDuration = 3 days; + + plugin = Multisig( + createProxyAndCall( + address(MULTISIG_BASE), + abi.encodeCall(Multisig.initialize, (dao, signers, settings)) + ) + ); + + (, , minDuration) = plugin.multisigSettings(); + assertEq(minDuration, 3 days, "Incorrect minDuration"); + } + + function test_ShouldEmitMultisigSettingsUpdatedOnInstall() public { + // Deploy with true/3/2 + Multisig.MultisigSettings memory settings = Multisig.MultisigSettings({ + onlyListed: true, + minApprovals: 3, + destinationMinDuration: 4 days + }); + address[] memory signers = new address[](4); + signers[0] = alice; + signers[1] = bob; + signers[2] = carol; + signers[3] = david; + + vm.expectEmit(); + emit MultisigSettingsUpdated(true, uint16(3), 4 days); + + plugin = Multisig( + createProxyAndCall( + address(MULTISIG_BASE), + abi.encodeCall(Multisig.initialize, (dao, signers, settings)) + ) + ); + + // Deploy with false/2/7 + settings = Multisig.MultisigSettings({ + onlyListed: false, + minApprovals: 2, + destinationMinDuration: 7 days + }); + vm.expectEmit(); + emit MultisigSettingsUpdated(false, uint16(2), 7 days); + + plugin = Multisig( + createProxyAndCall( + address(MULTISIG_BASE), + abi.encodeCall(Multisig.initialize, (dao, signers, settings)) + ) + ); + } + + function test_ShouldRevertIfMembersListIsTooLong() public { + Multisig.MultisigSettings memory settings = Multisig.MultisigSettings({ + onlyListed: true, + minApprovals: 3, + destinationMinDuration: 4 days + }); + address[] memory signers = new address[](65537); + + vm.expectRevert( + abi.encodeWithSelector( + Multisig.AddresslistLengthOutOfBounds.selector, + 65535, + 65537 + ) + ); + plugin = Multisig( + createProxyAndCall( + address(MULTISIG_BASE), + abi.encodeCall(Multisig.initialize, (dao, signers, settings)) + ) + ); + } + + // INTERFACES + + function test_DoesntSupportTheEmptyInterface() public view { + bool supported = plugin.supportsInterface(0); + assertEq(supported, false, "Should not support the empty interface"); + } + + function test_SupportsIERC165Upgradeable() public view { + bool supported = plugin.supportsInterface( + type(IERC165Upgradeable).interfaceId + ); + assertEq(supported, true, "Should support IERC165Upgradeable"); + } + + function test_SupportsIPlugin() public view { + bool supported = plugin.supportsInterface(type(IPlugin).interfaceId); + assertEq(supported, true, "Should support IPlugin"); + } + + function test_SupportsIProposal() public view { + bool supported = plugin.supportsInterface(type(IProposal).interfaceId); + assertEq(supported, true, "Should support IProposal"); + } + + function test_SupportsIMembership() public view { + bool supported = plugin.supportsInterface( + type(IMembership).interfaceId + ); + assertEq(supported, true, "Should support IMembership"); + } + + function test_SupportsAddresslist() public view { + bool supported = plugin.supportsInterface( + type(Addresslist).interfaceId + ); + assertEq(supported, true, "Should support Addresslist"); + } + + function test_SupportsIMultisig() public view { + bool supported = plugin.supportsInterface(type(IMultisig).interfaceId); + assertEq(supported, true, "Should support IMultisig"); + } + + // UPDATE MULTISIG SETTINGS + + function test_ShouldntAllowMinApprovalsHigherThenAddrListLength() public { + Multisig.MultisigSettings memory settings = Multisig.MultisigSettings({ + onlyListed: true, + minApprovals: 5, + destinationMinDuration: 4 days // Greater than 4 members below + }); + address[] memory signers = new address[](4); + signers[0] = alice; + signers[1] = bob; + signers[2] = carol; + signers[3] = david; + + vm.expectRevert( + abi.encodeWithSelector( + Multisig.MinApprovalsOutOfBounds.selector, + 4, + 5 + ) + ); + + plugin = Multisig( + createProxyAndCall( + address(MULTISIG_BASE), + abi.encodeCall(Multisig.initialize, (dao, signers, settings)) + ) + ); + + // Retry with onlyListed false + settings = Multisig.MultisigSettings({ + onlyListed: false, + minApprovals: 6, + destinationMinDuration: 4 days // Greater than 4 members below + }); + vm.expectRevert( + abi.encodeWithSelector( + Multisig.MinApprovalsOutOfBounds.selector, + 4, + 6 + ) + ); + plugin = Multisig( + createProxyAndCall( + address(MULTISIG_BASE), + abi.encodeCall(Multisig.initialize, (dao, signers, settings)) + ) + ); + } + + function test_ShouldNotAllowMinApprovalsZero() public { + Multisig.MultisigSettings memory settings = Multisig.MultisigSettings({ + onlyListed: true, + minApprovals: 0, + destinationMinDuration: 4 days + }); + address[] memory signers = new address[](4); + signers[0] = alice; + signers[1] = bob; + signers[2] = carol; + signers[3] = david; + + vm.expectRevert( + abi.encodeWithSelector( + Multisig.MinApprovalsOutOfBounds.selector, + 1, + 0 + ) + ); + + plugin = Multisig( + createProxyAndCall( + address(MULTISIG_BASE), + abi.encodeCall(Multisig.initialize, (dao, signers, settings)) + ) + ); + + // Retry with onlyListed false + settings = Multisig.MultisigSettings({ + onlyListed: false, + minApprovals: 0, + destinationMinDuration: 4 days + }); + vm.expectRevert( + abi.encodeWithSelector( + Multisig.MinApprovalsOutOfBounds.selector, + 1, + 0 + ) + ); + plugin = Multisig( + createProxyAndCall( + address(MULTISIG_BASE), + abi.encodeCall(Multisig.initialize, (dao, signers, settings)) + ) + ); + } + + function test_ShouldEmitMultisigSettingsUpdated() public { + dao.grant( + address(plugin), + address(alice), + plugin.UPDATE_MULTISIG_SETTINGS_PERMISSION_ID() + ); + + // 1 + Multisig.MultisigSettings memory settings = Multisig.MultisigSettings({ + onlyListed: true, + minApprovals: 1, + destinationMinDuration: 4 days + }); + + vm.expectEmit(); + emit MultisigSettingsUpdated(true, 1, 4 days); + plugin.updateMultisigSettings(settings); + + // 2 + settings = Multisig.MultisigSettings({ + onlyListed: true, + minApprovals: 2, + destinationMinDuration: 5 days + }); + + vm.expectEmit(); + emit MultisigSettingsUpdated(true, 2, 5 days); + plugin.updateMultisigSettings(settings); + + // 3 + settings = Multisig.MultisigSettings({ + onlyListed: false, + minApprovals: 3, + destinationMinDuration: 0 + }); + + vm.expectEmit(); + emit MultisigSettingsUpdated(false, 3, 0); + plugin.updateMultisigSettings(settings); + + // 4 + settings = Multisig.MultisigSettings({ + onlyListed: false, + minApprovals: 4, + destinationMinDuration: 1 days + }); + + vm.expectEmit(); + emit MultisigSettingsUpdated(false, 4, 1 days); + plugin.updateMultisigSettings(settings); + } + + function test_onlyWalletWithPermissionsCanUpdateSettings() public { + Multisig.MultisigSettings memory settings = Multisig.MultisigSettings({ + onlyListed: true, + minApprovals: 1, + destinationMinDuration: 3 days + }); + vm.expectRevert( + abi.encodeWithSelector( + DaoUnauthorized.selector, + address(dao), + address(plugin), + alice, + plugin.UPDATE_MULTISIG_SETTINGS_PERMISSION_ID() + ) + ); + plugin.updateMultisigSettings(settings); + + // Retry with the permission + dao.grant( + address(plugin), + alice, + plugin.UPDATE_MULTISIG_SETTINGS_PERMISSION_ID() + ); + + vm.expectEmit(); + emit MultisigSettingsUpdated(true, 1, 3 days); + plugin.updateMultisigSettings(settings); + } + + function test_IsMemberShouldReturnWhenApropriate() public { + Multisig.MultisigSettings memory settings = Multisig.MultisigSettings({ + onlyListed: true, + minApprovals: 1, + destinationMinDuration: 4 days + }); + address[] memory signers = new address[](1); + signers[0] = alice; + + plugin = Multisig( + createProxyAndCall( + address(MULTISIG_BASE), + abi.encodeCall(Multisig.initialize, (dao, signers, settings)) + ) + ); + + assertEq(plugin.isMember(alice), true, "Should be a member"); + assertEq(plugin.isMember(bob), false, "Should not be a member"); + assertEq(plugin.isMember(carol), false, "Should not be a member"); + assertEq(plugin.isMember(david), false, "Should not be a member"); + + // More members + settings = Multisig.MultisigSettings({ + onlyListed: true, + minApprovals: 1, + destinationMinDuration: 4 days + }); + signers = new address[](3); + signers[0] = bob; + signers[1] = carol; + signers[2] = david; + + plugin = Multisig( + createProxyAndCall( + address(MULTISIG_BASE), + abi.encodeCall(Multisig.initialize, (dao, signers, settings)) + ) + ); + + assertEq(plugin.isMember(alice), false, "Should not be a member"); + assertEq(plugin.isMember(bob), true, "Should be a member"); + assertEq(plugin.isMember(carol), true, "Should be a member"); + assertEq(plugin.isMember(david), true, "Should be a member"); + } + + function test_IsMemberIsListedShouldReturnTheSameValue() public { + Multisig.MultisigSettings memory settings = Multisig.MultisigSettings({ + onlyListed: true, + minApprovals: 1, + destinationMinDuration: 4 days + }); + address[] memory signers = new address[](1); + signers[0] = alice; + + plugin = Multisig( + createProxyAndCall( + address(MULTISIG_BASE), + abi.encodeCall(Multisig.initialize, (dao, signers, settings)) + ) + ); + + assertEq( + plugin.isListed(alice), + plugin.isMember(alice), + "isMember isListed should be equal" + ); + assertEq( + plugin.isListed(bob), + plugin.isMember(bob), + "isMember isListed should be equal" + ); + assertEq( + plugin.isListed(carol), + plugin.isMember(carol), + "isMember isListed should be equal" + ); + assertEq( + plugin.isListed(david), + plugin.isMember(david), + "isMember isListed should be equal" + ); + + // More members + settings = Multisig.MultisigSettings({ + onlyListed: true, + minApprovals: 1, + destinationMinDuration: 4 days + }); + signers = new address[](3); + signers[0] = bob; + signers[1] = carol; + signers[2] = david; + + plugin = Multisig( + createProxyAndCall( + address(MULTISIG_BASE), + abi.encodeCall(Multisig.initialize, (dao, signers, settings)) + ) + ); + + assertEq( + plugin.isListed(alice), + plugin.isMember(alice), + "isMember isListed should be equal" + ); + assertEq( + plugin.isListed(bob), + plugin.isMember(bob), + "isMember isListed should be equal" + ); + assertEq( + plugin.isListed(carol), + plugin.isMember(carol), + "isMember isListed should be equal" + ); + assertEq( + plugin.isListed(david), + plugin.isMember(david), + "isMember isListed should be equal" + ); + } + + function testFuzz_IsMemberIsFalseByDefault(uint256 _randomEntropy) public { + Multisig.MultisigSettings memory settings = Multisig.MultisigSettings({ + onlyListed: true, + minApprovals: 1, + destinationMinDuration: 4 days + }); + address[] memory signers = new address[](1); // 0x0... would be a member but the chance is negligible + + plugin = Multisig( + createProxyAndCall( + address(MULTISIG_BASE), + abi.encodeCall(Multisig.initialize, (dao, signers, settings)) + ) + ); + + assertEq(plugin.isListed(randomWallet), false, "Should be false"); + assertEq( + plugin.isListed( + vm.addr(uint256(keccak256(abi.encodePacked(_randomEntropy)))) + ), + false, + "Should be false" + ); + } + + function test_AddsNewMembersAndEmits() public { + dao.grant( + address(plugin), + alice, + plugin.UPDATE_MULTISIG_SETTINGS_PERMISSION_ID() + ); + + // No + assertEq( + plugin.isMember(randomWallet), + false, + "Should not be a member" + ); + + address[] memory addrs = new address[](1); + addrs[0] = randomWallet; + + vm.expectEmit(); + emit MembersAdded({members: addrs}); + plugin.addAddresses(addrs); + + // Yes + assertEq(plugin.isMember(randomWallet), true, "Should be a member"); + + // Next + addrs = new address[](3); + addrs[0] = vm.addr(1234); + addrs[1] = vm.addr(2345); + addrs[2] = vm.addr(3456); + + // No + assertEq(plugin.isMember(addrs[0]), false, "Should not be a member"); + assertEq(plugin.isMember(addrs[1]), false, "Should not be a member"); + assertEq(plugin.isMember(addrs[2]), false, "Should not be a member"); + + vm.expectEmit(); + emit MembersAdded({members: addrs}); + plugin.addAddresses(addrs); + + // Yes + assertEq(plugin.isMember(addrs[0]), true, "Should be a member"); + assertEq(plugin.isMember(addrs[1]), true, "Should be a member"); + assertEq(plugin.isMember(addrs[2]), true, "Should be a member"); + } + + function test_RemovesMembersAndEmits() public { + dao.grant( + address(plugin), + alice, + plugin.UPDATE_MULTISIG_SETTINGS_PERMISSION_ID() + ); + Multisig.MultisigSettings memory settings = Multisig.MultisigSettings({ + onlyListed: true, + minApprovals: 1, + destinationMinDuration: 4 days + }); + plugin.updateMultisigSettings(settings); + + // Before + assertEq(plugin.isMember(alice), true, "Should be a member"); + assertEq(plugin.isMember(bob), true, "Should be a member"); + assertEq(plugin.isMember(carol), true, "Should be a member"); + assertEq(plugin.isMember(david), true, "Should be a member"); + + address[] memory addrs = new address[](2); + addrs[0] = alice; + addrs[1] = bob; + + vm.expectEmit(); + emit MembersRemoved({members: addrs}); + plugin.removeAddresses(addrs); + + // After + assertEq(plugin.isMember(alice), false, "Should not be a member"); + assertEq(plugin.isMember(bob), false, "Should not be a member"); + assertEq(plugin.isMember(carol), true, "Should be a member"); + assertEq(plugin.isMember(david), true, "Should be a member"); + + // Next + addrs = new address[](3); + addrs[0] = vm.addr(1234); + addrs[1] = vm.addr(2345); + addrs[2] = vm.addr(3456); + plugin.addAddresses(addrs); + + // Remove + addrs = new address[](2); + addrs[0] = carol; + addrs[1] = david; + + vm.expectEmit(); + emit MembersRemoved({members: addrs}); + plugin.removeAddresses(addrs); + + // Yes + assertEq(plugin.isMember(carol), false, "Should not be a member"); + assertEq(plugin.isMember(david), false, "Should not be a member"); + } + + function test_ShouldRevertIfEmptySignersList() public { + dao.grant( + address(plugin), + alice, + plugin.UPDATE_MULTISIG_SETTINGS_PERMISSION_ID() + ); + Multisig.MultisigSettings memory settings = Multisig.MultisigSettings({ + onlyListed: true, + minApprovals: 1, + destinationMinDuration: 4 days + }); + plugin.updateMultisigSettings(settings); + + // Before + assertEq(plugin.isMember(alice), true, "Should be a member"); + assertEq(plugin.isMember(bob), true, "Should be a member"); + assertEq(plugin.isMember(carol), true, "Should be a member"); + assertEq(plugin.isMember(david), true, "Should be a member"); + + // ok + address[] memory addrs = new address[](1); + addrs[0] = alice; + plugin.removeAddresses(addrs); + + addrs[0] = bob; + plugin.removeAddresses(addrs); + + addrs[0] = carol; + plugin.removeAddresses(addrs); + + assertEq(plugin.isMember(alice), false, "Should not be a member"); + assertEq(plugin.isMember(bob), false, "Should not be a member"); + assertEq(plugin.isMember(carol), false, "Should not be a member"); + assertEq(plugin.isMember(david), true, "Should be a member"); + + // ko + addrs[0] = david; + vm.expectRevert( + abi.encodeWithSelector( + Multisig.MinApprovalsOutOfBounds.selector, + 1, + 0 + ) + ); + plugin.removeAddresses(addrs); + + // Next + addrs = new address[](1); + addrs[0] = vm.addr(1234); + plugin.addAddresses(addrs); + + // Retry removing David + addrs = new address[](1); + addrs[0] = david; + + plugin.removeAddresses(addrs); + + // Yes + assertEq(plugin.isMember(david), false, "Should not be a member"); + } + + function test_ShouldRevertIfLessThanMinApproval() public { + dao.grant( + address(plugin), + alice, + plugin.UPDATE_MULTISIG_SETTINGS_PERMISSION_ID() + ); + + // Before + assertEq(plugin.isMember(alice), true, "Should be a member"); + assertEq(plugin.isMember(bob), true, "Should be a member"); + assertEq(plugin.isMember(carol), true, "Should be a member"); + assertEq(plugin.isMember(david), true, "Should be a member"); + + // ok + address[] memory addrs = new address[](1); + addrs[0] = alice; + plugin.removeAddresses(addrs); + + // ko + addrs[0] = bob; + vm.expectRevert( + abi.encodeWithSelector( + Multisig.MinApprovalsOutOfBounds.selector, + 3, + 2 + ) + ); + plugin.removeAddresses(addrs); + + // ko + addrs[0] = carol; + vm.expectRevert( + abi.encodeWithSelector( + Multisig.MinApprovalsOutOfBounds.selector, + 3, + 2 + ) + ); + plugin.removeAddresses(addrs); + + // ko + addrs[0] = david; + vm.expectRevert( + abi.encodeWithSelector( + Multisig.MinApprovalsOutOfBounds.selector, + 3, + 2 + ) + ); + plugin.removeAddresses(addrs); + + // Add and retry removing + + addrs = new address[](1); + addrs[0] = vm.addr(1234); + plugin.addAddresses(addrs); + + addrs = new address[](1); + addrs[0] = bob; + plugin.removeAddresses(addrs); + + // 2 + addrs = new address[](1); + addrs[0] = vm.addr(2345); + plugin.addAddresses(addrs); + + addrs = new address[](1); + addrs[0] = carol; + plugin.removeAddresses(addrs); + + // 3 + addrs = new address[](1); + addrs[0] = vm.addr(3456); + plugin.addAddresses(addrs); + + addrs = new address[](1); + addrs[0] = david; + plugin.removeAddresses(addrs); + } + + function test_MinApprovalsBiggerThanTheListReverts() public { + // MinApprovals should be within the boundaries of the list + dao.grant( + address(plugin), + alice, + plugin.UPDATE_MULTISIG_SETTINGS_PERMISSION_ID() + ); + + Multisig.MultisigSettings memory settings = Multisig.MultisigSettings({ + onlyListed: true, + minApprovals: 5, + destinationMinDuration: 4 days // More than 4 + }); + vm.expectRevert( + abi.encodeWithSelector( + Multisig.MinApprovalsOutOfBounds.selector, + 4, + 5 + ) + ); + plugin.updateMultisigSettings(settings); + + // More signers + + address[] memory signers = new address[](1); + signers[0] = randomWallet; + plugin.addAddresses(signers); + + // should not fail now + plugin.updateMultisigSettings(settings); + + // More than that, should fail again + settings = Multisig.MultisigSettings({ + onlyListed: true, + minApprovals: 6, + destinationMinDuration: 4 days // More than 5 + }); + vm.expectRevert( + abi.encodeWithSelector( + Multisig.MinApprovalsOutOfBounds.selector, + 5, + 6 + ) + ); + plugin.updateMultisigSettings(settings); + } + + function test_ShouldRevertIfDuplicatingAddresses() public { + dao.grant( + address(plugin), + alice, + plugin.UPDATE_MULTISIG_SETTINGS_PERMISSION_ID() + ); + + // ok + address[] memory addrs = new address[](1); + addrs[0] = vm.addr(1234); + plugin.addAddresses(addrs); + + // ko + vm.expectRevert( + abi.encodeWithSelector(InvalidAddresslistUpdate.selector, addrs[0]) + ); + plugin.addAddresses(addrs); + + // 1 + addrs[0] = alice; + vm.expectRevert( + abi.encodeWithSelector(InvalidAddresslistUpdate.selector, addrs[0]) + ); + plugin.addAddresses(addrs); + + // 2 + addrs[0] = bob; + vm.expectRevert( + abi.encodeWithSelector(InvalidAddresslistUpdate.selector, addrs[0]) + ); + plugin.addAddresses(addrs); + + // 3 + addrs[0] = carol; + vm.expectRevert( + abi.encodeWithSelector(InvalidAddresslistUpdate.selector, addrs[0]) + ); + plugin.addAddresses(addrs); + + // 4 + addrs[0] = david; + vm.expectRevert( + abi.encodeWithSelector(InvalidAddresslistUpdate.selector, addrs[0]) + ); + plugin.addAddresses(addrs); + + // ok + addrs[0] = vm.addr(1234); + plugin.removeAddresses(addrs); + + // ko + vm.expectRevert( + abi.encodeWithSelector(InvalidAddresslistUpdate.selector, addrs[0]) + ); + plugin.removeAddresses(addrs); + + addrs[0] = vm.addr(2345); + vm.expectRevert( + abi.encodeWithSelector(InvalidAddresslistUpdate.selector, addrs[0]) + ); + plugin.removeAddresses(addrs); + + addrs[0] = vm.addr(3456); + vm.expectRevert( + abi.encodeWithSelector(InvalidAddresslistUpdate.selector, addrs[0]) + ); + plugin.removeAddresses(addrs); + + addrs[0] = vm.addr(4567); + vm.expectRevert( + abi.encodeWithSelector(InvalidAddresslistUpdate.selector, addrs[0]) + ); + plugin.removeAddresses(addrs); + + addrs[0] = randomWallet; + vm.expectRevert( + abi.encodeWithSelector(InvalidAddresslistUpdate.selector, addrs[0]) + ); + plugin.removeAddresses(addrs); + } + + function test_onlyWalletWithPermissionsCanAddRemove() public { + // ko + address[] memory addrs = new address[](1); + addrs[0] = vm.addr(1234); + vm.expectRevert( + abi.encodeWithSelector( + DaoUnauthorized.selector, + address(dao), + address(plugin), + alice, + plugin.UPDATE_MULTISIG_SETTINGS_PERMISSION_ID() + ) + ); + plugin.addAddresses(addrs); + + // ko + addrs[0] = alice; + vm.expectRevert( + abi.encodeWithSelector( + DaoUnauthorized.selector, + address(dao), + address(plugin), + alice, + plugin.UPDATE_MULTISIG_SETTINGS_PERMISSION_ID() + ) + ); + plugin.removeAddresses(addrs); + + // Permission + dao.grant( + address(plugin), + alice, + plugin.UPDATE_MULTISIG_SETTINGS_PERMISSION_ID() + ); + + // ok + addrs[0] = vm.addr(1234); + plugin.addAddresses(addrs); + + addrs[0] = alice; + plugin.removeAddresses(addrs); + } + + function testFuzz_PermissionedAddRemoveMembers( + address randomAccount + ) public { + dao.grant( + address(plugin), + alice, + plugin.UPDATE_MULTISIG_SETTINGS_PERMISSION_ID() + ); + + assertEq(plugin.isMember(randomWallet), false, "Should be false"); + + // in + address[] memory addrs = new address[](1); + addrs[0] = randomWallet; + plugin.addAddresses(addrs); + assertEq(plugin.isMember(randomWallet), true, "Should be true"); + + // out + plugin.removeAddresses(addrs); + assertEq(plugin.isMember(randomWallet), false, "Should be false"); + + // someone else + if (randomAccount != alice) { + vm.stopPrank(); + vm.startPrank(randomAccount); + vm.expectRevert( + abi.encodeWithSelector( + DaoUnauthorized.selector, + address(dao), + address(plugin), + randomAccount, + plugin.UPDATE_MULTISIG_SETTINGS_PERMISSION_ID() + ) + ); + plugin.addAddresses(addrs); + assertEq(plugin.isMember(randomWallet), false, "Should be false"); + + addrs[0] = carol; + assertEq(plugin.isMember(carol), true, "Should be true"); + vm.expectRevert( + abi.encodeWithSelector( + DaoUnauthorized.selector, + address(dao), + address(plugin), + randomAccount, + plugin.UPDATE_MULTISIG_SETTINGS_PERMISSION_ID() + ) + ); + plugin.removeAddresses(addrs); + + assertEq(plugin.isMember(carol), true, "Should be true"); + } + + vm.stopPrank(); + vm.startPrank(alice); + } + + function testFuzz_PermissionedUpdateSettings(address randomAccount) public { + dao.grant( + address(plugin), + alice, + plugin.UPDATE_MULTISIG_SETTINGS_PERMISSION_ID() + ); + + (bool onlyListed, uint16 minApprovals, uint64 destMinDuration) = plugin + .multisigSettings(); + assertEq(minApprovals, 3, "Should be 3"); + assertEq(onlyListed, true, "Should be true"); + assertEq(destMinDuration, 4 days, "Incorrect destMinDuration"); + + // in + Multisig.MultisigSettings memory newSettings = Multisig + .MultisigSettings({ + onlyListed: false, + minApprovals: 2, + destinationMinDuration: 5 days + }); + plugin.updateMultisigSettings(newSettings); + + (onlyListed, minApprovals, destMinDuration) = plugin.multisigSettings(); + assertEq(minApprovals, 2, "Should be 2"); + assertEq(onlyListed, false, "Should be false"); + assertEq(destMinDuration, 5 days, "Incorrect destMinDuration"); + + // out + newSettings = Multisig.MultisigSettings({ + onlyListed: true, + minApprovals: 1, + destinationMinDuration: 6 days + }); + plugin.updateMultisigSettings(newSettings); + (onlyListed, minApprovals, destMinDuration) = plugin.multisigSettings(); + assertEq(minApprovals, 1, "Should be 1"); + assertEq(onlyListed, true, "Should be true"); + assertEq(destMinDuration, 6 days, "Incorrect destMinDuration"); + + vm.roll(block.number + 1); + + // someone else + if (randomAccount != alice) { + vm.stopPrank(); + vm.startPrank(randomAccount); + + newSettings = Multisig.MultisigSettings({ + onlyListed: false, + minApprovals: 4, + destinationMinDuration: 4 days + }); + + vm.expectRevert( + abi.encodeWithSelector( + DaoUnauthorized.selector, + address(dao), + address(plugin), + randomAccount, + plugin.UPDATE_MULTISIG_SETTINGS_PERMISSION_ID() + ) + ); + plugin.updateMultisigSettings(newSettings); + + (onlyListed, minApprovals, destMinDuration) = plugin + .multisigSettings(); + assertEq(minApprovals, 1, "Should still be 1"); + assertEq(onlyListed, true, "Should still be true"); + assertEq(destMinDuration, 6 days, "Should still be 6 days"); + } + + vm.stopPrank(); + vm.startPrank(alice); + } + + // PROPOSAL CREATION + + function test_IncrementsTheProposalCounter() public { + // increments the proposal counter + vm.roll(block.number + 1); + + assertEq(plugin.proposalCount(), 0, "Should have no proposals"); + + // 1 + IDAO.Action[] memory actions = new IDAO.Action[](0); + plugin.createProposal("", actions, optimisticPlugin, false, 0, 0); + + assertEq(plugin.proposalCount(), 1, "Should have 1 proposal"); + + // 2 + plugin.createProposal("ipfs://", actions, optimisticPlugin, true, 0, 0); + + assertEq(plugin.proposalCount(), 2, "Should have 2 proposals"); + } + + function test_CreatesAndReturnsUniqueProposalIds() public { + // creates unique proposal IDs for each proposal + vm.roll(block.number + 1); + + // 1 + IDAO.Action[] memory actions = new IDAO.Action[](0); + uint pid = plugin.createProposal( + "", + actions, + optimisticPlugin, + false, + 0, + 0 + ); + + assertEq(pid, 0, "Should be 0"); + + // 2 + pid = plugin.createProposal( + "ipfs://", + actions, + optimisticPlugin, + true, + 0, + 0 + ); + + assertEq(pid, 1, "Should be 1"); + + // 3 + pid = plugin.createProposal( + "ipfs://more", + actions, + optimisticPlugin, + true, + 0, + 0 + ); + + assertEq(pid, 2, "Should be 2"); + } + + function test_EmitsProposalCreated() public { + // emits the `ProposalCreated` event + vm.roll(block.number + 1); + + IDAO.Action[] memory actions = new IDAO.Action[](0); + vm.expectEmit(); + emit ProposalCreated({ + proposalId: 0, + creator: alice, + metadata: "", + startDate: 0, + endDate: 0, + actions: actions, + allowFailureMap: 0 + }); + plugin.createProposal("", actions, optimisticPlugin, true, 0, 0); + + // 2 + vm.stopPrank(); + vm.startPrank(bob); + vm.roll(block.number + 1); + + actions = new IDAO.Action[](1); + actions[0].to = carol; + actions[0].value = 1 ether; + address[] memory addrs = new address[](1); + actions[0].data = abi.encodeCall(Multisig.addAddresses, (addrs)); + + vm.expectEmit(); + emit ProposalCreated({ + proposalId: 1, + creator: bob, + metadata: "ipfs://", + startDate: 50 days, + endDate: 100 days, + actions: actions, + allowFailureMap: 0 + }); + plugin.createProposal( + "ipfs://", + actions, + optimisticPlugin, + false, + 50 days, + 100 days + ); + + // undo + vm.stopPrank(); + vm.startPrank(alice); + } + + function test_RevertsIfSettingsChangedInSameBlock() public { + // reverts if the multisig settings have changed in the same block + + // 1 + IDAO.Action[] memory actions = new IDAO.Action[](0); + vm.expectRevert( + abi.encodeWithSelector( + Multisig.ProposalCreationForbidden.selector, + alice + ) + ); + plugin.createProposal("", actions, optimisticPlugin, false, 0, 0); + + // Next block + vm.roll(block.number + 1); + plugin.createProposal("", actions, optimisticPlugin, false, 0, 0); + } + + function test_CreatesWhenUnlistedAccountsAllowed() public { + // creates a proposal when unlisted accounts are allowed + + // Deploy a new multisig instance + Multisig.MultisigSettings memory settings = Multisig.MultisigSettings({ + onlyListed: false, + minApprovals: 3, + destinationMinDuration: 4 days + }); + address[] memory signers = new address[](4); + signers[0] = alice; + signers[1] = bob; + signers[2] = carol; + signers[3] = david; + + plugin = Multisig( + createProxyAndCall( + address(MULTISIG_BASE), + abi.encodeCall(Multisig.initialize, (dao, signers, settings)) + ) + ); + + vm.stopPrank(); + vm.startPrank(randomWallet); + vm.roll(block.number + 1); + + IDAO.Action[] memory actions = new IDAO.Action[](0); + plugin.createProposal("", actions, optimisticPlugin, false, 0, 0); + + vm.stopPrank(); + vm.startPrank(alice); + } + + function test_RevertsWhenOnlyListedAndAnotherWalletCreates() public { + // reverts if the user is not on the list and only listed accounts can create proposals + + vm.stopPrank(); + vm.startPrank(randomWallet); + vm.roll(block.number + 1); + + IDAO.Action[] memory actions = new IDAO.Action[](0); + vm.expectRevert( + abi.encodeWithSelector( + Multisig.ProposalCreationForbidden.selector, + randomWallet + ) + ); + plugin.createProposal( + "", + actions, + optimisticPlugin, + false, + 0, + uint64(block.timestamp + 10) + ); + + vm.stopPrank(); + vm.startPrank(alice); + } + + function test_RevertsWhenCreatorWasListedBeforeButNotNow() public { + // reverts if `_msgSender` is not listed before although she was listed in the last block + + // Deploy a new multisig instance + Multisig.MultisigSettings memory settings = Multisig.MultisigSettings({ + onlyListed: true, + minApprovals: 1, + destinationMinDuration: 4 days + }); + address[] memory addrs = new address[](1); + addrs[0] = alice; + + plugin = Multisig( + createProxyAndCall( + address(MULTISIG_BASE), + abi.encodeCall(Multisig.initialize, (dao, addrs, settings)) + ) + ); + dao.grant( + address(plugin), + alice, + plugin.UPDATE_MULTISIG_SETTINGS_PERMISSION_ID() + ); + vm.roll(block.number + 1); + + // Add+remove + addrs[0] = bob; + plugin.addAddresses(addrs); + + addrs[0] = alice; + plugin.removeAddresses(addrs); + + // Alice cannot create now + IDAO.Action[] memory actions = new IDAO.Action[](0); + vm.expectRevert( + abi.encodeWithSelector( + Multisig.ProposalCreationForbidden.selector, + alice + ) + ); + plugin.createProposal("", actions, optimisticPlugin, false, 0, 0); + + // Bob can create now + vm.stopPrank(); + vm.startPrank(bob); + + plugin.createProposal("", actions, optimisticPlugin, false, 0, 0); + + assertEq(plugin.isListed(alice), false, "Should not be listed"); + assertEq(plugin.isListed(bob), true, "Should be listed"); + } + + function test_CreatesProposalWithoutApprovingIfUnspecified() public { + // creates a proposal successfully and does not approve if not specified + + vm.roll(block.number + 1); + + IDAO.Action[] memory actions = new IDAO.Action[](0); + uint pid = plugin.createProposal( + "", + actions, + optimisticPlugin, + false, // approveProposal + 0, + 0 + ); + + assertEq( + plugin.hasApproved(pid, alice), + false, + "Should not have approved" + ); + (, uint16 approvals, , , , ) = plugin.getProposal(pid); + assertEq(approvals, 0, "Should be 0"); + + plugin.approve(pid, false); + + assertEq(plugin.hasApproved(pid, alice), true, "Should have approved"); + (, approvals, , , , ) = plugin.getProposal(pid); + assertEq(approvals, 1, "Should be 1"); + } + + function test_CreatesAndApprovesWhenSpecified() public { + // creates a proposal successfully and approves if specified + + vm.roll(block.number + 1); + + IDAO.Action[] memory actions = new IDAO.Action[](0); + uint pid = plugin.createProposal( + "", + actions, + optimisticPlugin, + true, // approveProposal + 0, + 0 + ); + assertEq(plugin.hasApproved(pid, alice), true, "Should have approved"); + (, uint16 approvals, , , , ) = plugin.getProposal(pid); + assertEq(approvals, 1, "Should be 1"); + } + + function test_ShouldRevertWhenStartDateLessThanNow() public { + // should revert if startDate is < than now + + vm.roll(block.number + 1); + vm.warp(10); // timestamp = 10 + + IDAO.Action[] memory actions = new IDAO.Action[](0); + vm.expectRevert( + abi.encodeWithSelector(Multisig.DateOutOfBounds.selector, 10, 5) + ); + plugin.createProposal( + "", + actions, + optimisticPlugin, + false, + 5, // startDate = 5, now = 10 + 0 + ); + + // 2 + vm.expectRevert( + abi.encodeWithSelector(Multisig.DateOutOfBounds.selector, 10, 9) + ); + plugin.createProposal( + "", + actions, + optimisticPlugin, + false, + 9, // startDate = 9, now = 10 + 0 + ); + + // ok + plugin.createProposal( + "", + actions, + optimisticPlugin, + false, + 0, // using now() + 0 + ); + plugin.createProposal( + "", + actions, + optimisticPlugin, + false, + 10, // startDate = 10, now = 10 + 0 + ); + plugin.createProposal( + "", + actions, + optimisticPlugin, + false, + 20, // startDate = 20, now = 10 + 0 + ); + } + + function test_ShouldRevertIfMinDurationWillFailOnDestinationPlugin() + public + { + // should revert if the duration will be less than the destination plugin allows + + vm.roll(block.number + 1); + vm.warp(10); // timestamp = 10 + + IDAO.Action[] memory actions = new IDAO.Action[](0); + + // Start now (0) will be less than minDuration + vm.expectRevert( + abi.encodeWithSelector( + Multisig.DateOutOfBounds.selector, + 10 + 4 days, + 1234 + ) + ); + plugin.createProposal("", actions, optimisticPlugin, false, 0, 1234); + + // Explicit start/end will be less than minDuration + vm.expectRevert( + abi.encodeWithSelector( + Multisig.DateOutOfBounds.selector, + 50 + 4 days, + 49 + ) + ); + plugin.createProposal("", actions, optimisticPlugin, false, 50, 49); + + vm.expectRevert( + abi.encodeWithSelector( + Multisig.DateOutOfBounds.selector, + 100 + 4 days, + 1234 + ) + ); + plugin.createProposal("", actions, optimisticPlugin, false, 100, 1234); + + // ok + plugin.createProposal( + "", + actions, + optimisticPlugin, + false, + 20, + 20 + 4 days + ); + plugin.createProposal( + "", + actions, + optimisticPlugin, + false, + 20, + 30 + 4 days + ); + plugin.createProposal( + "", + actions, + optimisticPlugin, + false, + 20, + 100 + 4 days + ); + } + + // CAN APPROVE + + function testFuzz_CanApproveReturnsfFalseIfNotListed( + address _randomWallet + ) public { + // returns `false` if the approver is not listed + + { + // Deploy a new multisig instance + Multisig.MultisigSettings memory settings = Multisig + .MultisigSettings({ + onlyListed: true, + minApprovals: 1, + destinationMinDuration: 4 days + }); + address[] memory signers = new address[](1); + signers[0] = alice; + + plugin = Multisig( + createProxyAndCall( + address(MULTISIG_BASE), + abi.encodeCall( + Multisig.initialize, + (dao, signers, settings) + ) + ) + ); + vm.roll(block.number + 1); + } + + IDAO.Action[] memory actions = new IDAO.Action[](0); + uint pid = plugin.createProposal( + "", + actions, + optimisticPlugin, + false, + 0, + 0 + ); + + // ko + if (_randomWallet != alice) { + assertEq( + plugin.canApprove(pid, _randomWallet), + false, + "Should be false" + ); + } + + // static ko + assertEq( + plugin.canApprove(pid, randomWallet), + false, + "Should be false" + ); + + // static ok + assertEq(plugin.canApprove(pid, alice), true, "Should be true"); + } + + function test_CanApproveReturnsFalseIfApproved() public { + // returns `false` if the approver has already approved + { + Multisig.MultisigSettings memory settings = Multisig + .MultisigSettings({ + onlyListed: true, + minApprovals: 4, + destinationMinDuration: 4 days + }); + address[] memory signers = new address[](4); + signers[0] = alice; + signers[1] = bob; + signers[2] = carol; + signers[3] = david; + + plugin = Multisig( + createProxyAndCall( + address(MULTISIG_BASE), + abi.encodeCall( + Multisig.initialize, + (dao, signers, settings) + ) + ) + ); + } + + vm.roll(block.number + 1); + + IDAO.Action[] memory actions = new IDAO.Action[](0); + uint pid = plugin.createProposal( + "", + actions, + optimisticPlugin, + false, + 0, + 0 + ); + + // Alice + assertEq(plugin.canApprove(pid, alice), true, "Should be true"); + plugin.approve(pid, false); + assertEq(plugin.canApprove(pid, alice), false, "Should be false"); + + // Bob + assertEq(plugin.canApprove(pid, bob), true, "Should be true"); + vm.stopPrank(); + vm.startPrank(bob); + plugin.approve(pid, false); + assertEq(plugin.canApprove(pid, bob), false, "Should be false"); + + // Carol + assertEq(plugin.canApprove(pid, carol), true, "Should be true"); + vm.stopPrank(); + vm.startPrank(carol); + plugin.approve(pid, false); + assertEq(plugin.canApprove(pid, carol), false, "Should be false"); + + // David + assertEq(plugin.canApprove(pid, david), true, "Should be true"); + vm.stopPrank(); + vm.startPrank(david); + plugin.approve(pid, false); + assertEq(plugin.canApprove(pid, david), false, "Should be false"); + + vm.stopPrank(); + vm.startPrank(alice); + } + + function test_CanApproveReturnsFalseIfExpired() public { + // returns `false` if the proposal has ended + + vm.roll(block.number + 1); + vm.warp(0); // timestamp = 0 + + IDAO.Action[] memory actions = new IDAO.Action[](0); + uint pid = plugin.createProposal( + "", + actions, + optimisticPlugin, + false, + 0, + 0 + ); + + assertEq(plugin.canApprove(pid, alice), true, "Should be true"); + + vm.warp(10 days - 1); // multisig expiration time - 1 + assertEq(plugin.canApprove(pid, alice), true, "Should be true"); + + vm.warp(10 days + 1); // multisig expiration time + assertEq(plugin.canApprove(pid, alice), false, "Should be false"); + + // Start later + vm.warp(1000); + pid = plugin.createProposal("", actions, optimisticPlugin, false, 0, 0); + + assertEq(plugin.canApprove(pid, alice), true, "Should be true"); + + vm.warp(10 days + 1000); // expiration time - 1 + assertEq(plugin.canApprove(pid, alice), true, "Should be true"); + + vm.warp(10 days + 1001); // expiration time + assertEq(plugin.canApprove(pid, alice), false, "Should be false"); + } + + function test_CanApproveReturnsFalseIfExecuted() public { + // returns `false` if the proposal is already executed + + dao.grant( + address(optimisticPlugin), + address(plugin), + optimisticPlugin.PROPOSER_PERMISSION_ID() + ); + + vm.roll(block.number + 1); + + bool executed; + IDAO.Action[] memory actions = new IDAO.Action[](0); + uint pid = plugin.createProposal( + "", + actions, + optimisticPlugin, + false, + 0, + 0 + ); + + // Alice + plugin.approve(pid, false); + + (executed, , , , , ) = plugin.getProposal(pid); + assertEq(executed, false, "Should not be executed"); + + // Bob + vm.stopPrank(); + vm.startPrank(bob); + plugin.approve(pid, false); + + (executed, , , , , ) = plugin.getProposal(pid); + assertEq(executed, false, "Should not be executed"); + + // Carol + vm.stopPrank(); + vm.startPrank(carol); + plugin.approve(pid, true); // auto execute + + (executed, , , , , ) = plugin.getProposal(pid); + assertEq(executed, true, "Should be executed"); + + // David cannot approve + assertEq(plugin.canApprove(pid, david), false, "Should be false"); + + vm.stopPrank(); + vm.startPrank(alice); + } + + function test_CanApproveReturnsTrueIfListed() public { + // returns `true` if the approver is listed + + vm.roll(block.number + 1); + vm.warp(10); // timestamp = 10 + + IDAO.Action[] memory actions = new IDAO.Action[](0); + uint pid = plugin.createProposal( + "", + actions, + optimisticPlugin, + false, + 0, + 0 + ); + + assertEq(plugin.canApprove(pid, alice), true, "Should be true"); + assertEq(plugin.canApprove(pid, bob), true, "Should be true"); + assertEq(plugin.canApprove(pid, carol), true, "Should be true"); + assertEq(plugin.canApprove(pid, david), true, "Should be true"); + + { + // Deploy a new multisig instance + Multisig.MultisigSettings memory settings = Multisig + .MultisigSettings({ + onlyListed: false, + minApprovals: 1, + destinationMinDuration: 4 days + }); + address[] memory signers = new address[](1); + signers[0] = randomWallet; + + plugin = Multisig( + createProxyAndCall( + address(MULTISIG_BASE), + abi.encodeCall( + Multisig.initialize, + (dao, signers, settings) + ) + ) + ); + vm.roll(block.number + 1); + } + + // now ko + actions = new IDAO.Action[](0); + pid = plugin.createProposal("", actions, optimisticPlugin, false, 0, 0); + + assertEq(plugin.canApprove(pid, alice), false, "Should be false"); + assertEq(plugin.canApprove(pid, bob), false, "Should be false"); + assertEq(plugin.canApprove(pid, carol), false, "Should be false"); + assertEq(plugin.canApprove(pid, david), false, "Should be false"); + + // ok + assertEq(plugin.canApprove(pid, randomWallet), true, "Should be true"); + } + + // HAS APPROVED + + function test_HasApprovedReturnsFalseWhenNotApproved() public { + // returns `false` if user hasn't approved yet + + vm.roll(block.number + 1); + + IDAO.Action[] memory actions = new IDAO.Action[](0); + uint pid = plugin.createProposal( + "", + actions, + optimisticPlugin, + false, + 0, + 0 + ); + + // Alice + assertEq(plugin.hasApproved(pid, alice), false, "Should be false"); + assertEq(plugin.hasApproved(pid, bob), false, "Should be false"); + assertEq(plugin.hasApproved(pid, carol), false, "Should be false"); + assertEq(plugin.hasApproved(pid, david), false, "Should be false"); + } + + function test_HasApprovedReturnsTrueWhenUserApproved() public { + // returns `true` if user has approved + + vm.roll(block.number + 1); + + IDAO.Action[] memory actions = new IDAO.Action[](0); + uint pid = plugin.createProposal( + "", + actions, + optimisticPlugin, + false, + 0, + 0 + ); + + // Alice + assertEq(plugin.hasApproved(pid, alice), false, "Should be false"); + plugin.approve(pid, false); + assertEq(plugin.hasApproved(pid, alice), true, "Should be true"); + + // Bob + vm.stopPrank(); + vm.startPrank(bob); + assertEq(plugin.hasApproved(pid, bob), false, "Should be false"); + plugin.approve(pid, false); + assertEq(plugin.hasApproved(pid, bob), true, "Should be true"); + + // Carol + vm.stopPrank(); + vm.startPrank(carol); + assertEq(plugin.hasApproved(pid, carol), false, "Should be false"); + plugin.approve(pid, false); + assertEq(plugin.hasApproved(pid, carol), true, "Should be true"); + + // David + vm.stopPrank(); + vm.startPrank(david); + assertEq(plugin.hasApproved(pid, david), false, "Should be false"); + plugin.approve(pid, false); + assertEq(plugin.hasApproved(pid, david), true, "Should be true"); + + vm.stopPrank(); + vm.startPrank(alice); + } + + function test_ApproveRevertsIfApprovingMultipleTimes() public { + // reverts when approving multiple times + + vm.roll(block.number + 1); + + IDAO.Action[] memory actions = new IDAO.Action[](0); + uint pid = plugin.createProposal( + "", + actions, + optimisticPlugin, + false, + 0, + 0 + ); + + // Alice + plugin.approve(pid, true); + + vm.expectRevert( + abi.encodeWithSelector( + Multisig.ApprovalCastForbidden.selector, + pid, + alice + ) + ); + plugin.approve(pid, true); + + // Bob + vm.stopPrank(); + vm.startPrank(bob); + plugin.approve(pid, true); + + vm.expectRevert( + abi.encodeWithSelector( + Multisig.ApprovalCastForbidden.selector, + pid, + bob + ) + ); + plugin.approve(pid, false); + + // Carol + vm.stopPrank(); + vm.startPrank(carol); + plugin.approve(pid, false); + + vm.expectRevert( + abi.encodeWithSelector( + Multisig.ApprovalCastForbidden.selector, + pid, + carol + ) + ); + plugin.approve(pid, true); + + vm.stopPrank(); + vm.startPrank(alice); + } + + // APPROVE + + function test_ApprovesWithTheSenderAddress() public { + // approves with the msg.sender address + // Same as test_HasApprovedReturnsTrueWhenUserApproved() + + vm.roll(block.number + 1); + + IDAO.Action[] memory actions = new IDAO.Action[](0); + uint pid = plugin.createProposal( + "", + actions, + optimisticPlugin, + false, + 0, + 0 + ); + + // Alice + assertEq(plugin.hasApproved(pid, alice), false, "Should be false"); + plugin.approve(pid, false); + assertEq(plugin.hasApproved(pid, alice), true, "Should be true"); + + // Bob + vm.stopPrank(); + vm.startPrank(bob); + assertEq(plugin.hasApproved(pid, bob), false, "Should be false"); + plugin.approve(pid, false); + assertEq(plugin.hasApproved(pid, bob), true, "Should be true"); + + // Carol + vm.stopPrank(); + vm.startPrank(carol); + assertEq(plugin.hasApproved(pid, carol), false, "Should be false"); + plugin.approve(pid, false); + assertEq(plugin.hasApproved(pid, carol), true, "Should be true"); + + // David + vm.stopPrank(); + vm.startPrank(david); + assertEq(plugin.hasApproved(pid, david), false, "Should be false"); + plugin.approve(pid, false); + assertEq(plugin.hasApproved(pid, david), true, "Should be true"); + + vm.stopPrank(); + vm.startPrank(alice); + } + + function test_ApproveRevertsIfExpired() public { + // reverts if the proposal has ended + + vm.roll(block.number + 1); + vm.warp(0); // timestamp = 0 + + IDAO.Action[] memory actions = new IDAO.Action[](0); + uint pid = plugin.createProposal( + "", + actions, + optimisticPlugin, + false, + 0, + 0 + ); + + assertEq(plugin.canApprove(pid, alice), true, "Should be true"); + + vm.warp(10 days + 1); + vm.expectRevert( + abi.encodeWithSelector( + Multisig.ApprovalCastForbidden.selector, + pid, + alice + ) + ); + plugin.approve(pid, false); + + vm.warp(15 days); + vm.expectRevert( + abi.encodeWithSelector( + Multisig.ApprovalCastForbidden.selector, + pid, + alice + ) + ); + plugin.approve(pid, false); + + // 2 + vm.warp(10); // timestamp = 10 + pid = plugin.createProposal("", actions, optimisticPlugin, false, 0, 0); + + assertEq(plugin.canApprove(pid, alice), true, "Should be true"); + + vm.warp(10 + 10 days + 1); + vm.expectRevert( + abi.encodeWithSelector( + Multisig.ApprovalCastForbidden.selector, + pid, + alice + ) + ); + plugin.approve(pid, true); + + vm.warp(10 + 10 days + 500); + vm.expectRevert( + abi.encodeWithSelector( + Multisig.ApprovalCastForbidden.selector, + pid, + alice + ) + ); + plugin.approve(pid, true); + } + + function test_ApprovingProposalsEmits() public { + // Approving a proposal emits the Approved event + + vm.roll(block.number + 1); + vm.warp(10); // timestamp = 10 + + IDAO.Action[] memory actions = new IDAO.Action[](0); + uint pid = plugin.createProposal( + "", + actions, + optimisticPlugin, + false, + 0, + 0 + ); + + vm.expectEmit(); + emit Approved(pid, alice); + plugin.approve(pid, false); + + // Bob + vm.stopPrank(); + vm.startPrank(bob); + vm.expectEmit(); + emit Approved(pid, bob); + plugin.approve(pid, false); + + // Carol + vm.stopPrank(); + vm.startPrank(carol); + vm.expectEmit(); + emit Approved(pid, carol); + plugin.approve(pid, false); + + // David (even if it already passed) + vm.stopPrank(); + vm.startPrank(david); + vm.expectEmit(); + emit Approved(pid, david); + plugin.approve(pid, false); + + vm.stopPrank(); + vm.startPrank(alice); + } + + // CAN EXECUTE + + function test_CanExecuteReturnsFalseIfBelowMinApprovals() public { + // returns `false` if the proposal has not reached the minimum approvals yet + + { + // Deploy a new multisig instance + Multisig.MultisigSettings memory settings = Multisig + .MultisigSettings({ + onlyListed: true, + minApprovals: 2, + destinationMinDuration: 4 days + }); + address[] memory signers = new address[](4); + signers[0] = alice; + signers[1] = bob; + signers[2] = carol; + signers[3] = david; + + plugin = Multisig( + createProxyAndCall( + address(MULTISIG_BASE), + abi.encodeCall( + Multisig.initialize, + (dao, signers, settings) + ) + ) + ); + dao.grant( + address(optimisticPlugin), + address(plugin), + optimisticPlugin.PROPOSER_PERMISSION_ID() + ); + vm.roll(block.number + 1); + } + IDAO.Action[] memory actions = new IDAO.Action[](0); + uint pid = plugin.createProposal( + "", + actions, + optimisticPlugin, + false, + 0, + 0 + ); + + // Alice + plugin.approve(pid, false); + assertEq(plugin.canExecute(pid), false, "Should be false"); + + // Bob + vm.stopPrank(); + vm.startPrank(bob); + plugin.approve(pid, false); + assertEq(plugin.canExecute(pid), true, "Should be true"); + + vm.stopPrank(); + vm.startPrank(alice); + + // More approvals required (4) + + { + // Deploy a new multisig instance + Multisig.MultisigSettings memory settings = Multisig + .MultisigSettings({ + onlyListed: true, + minApprovals: 4, + destinationMinDuration: 4 days + }); + address[] memory signers = new address[](4); + signers[0] = alice; + signers[1] = bob; + signers[2] = carol; + signers[3] = david; + + plugin = Multisig( + createProxyAndCall( + address(MULTISIG_BASE), + abi.encodeCall( + Multisig.initialize, + (dao, signers, settings) + ) + ) + ); + dao.grant( + address(optimisticPlugin), + address(plugin), + optimisticPlugin.PROPOSER_PERMISSION_ID() + ); + vm.roll(block.number + 1); + } + + pid = plugin.createProposal("", actions, optimisticPlugin, false, 0, 0); + + // Alice + vm.stopPrank(); + vm.startPrank(alice); + plugin.approve(pid, false); + assertEq(plugin.canExecute(pid), false, "Should be false"); + + // Bob + vm.stopPrank(); + vm.startPrank(bob); + plugin.approve(pid, false); + assertEq(plugin.canExecute(pid), false, "Should be false"); + + // Carol + vm.stopPrank(); + vm.startPrank(carol); + plugin.approve(pid, false); + assertEq(plugin.canExecute(pid), false, "Should be false"); + + // David + vm.stopPrank(); + vm.startPrank(david); + plugin.approve(pid, false); + assertEq(plugin.canExecute(pid), true, "Should be true"); + + vm.stopPrank(); + vm.startPrank(alice); + } + + function test_CanExecuteReturnsFalseIfExpired() public { + // returns `false` if the proposal has ended + + vm.roll(block.number + 1); + + // 1 + vm.warp(0); // timestamp = 0 + + IDAO.Action[] memory actions = new IDAO.Action[](0); + uint pid = plugin.createProposal( + "", + actions, + optimisticPlugin, + false, + 0, + 0 + ); + + plugin.approve(pid, false); + vm.stopPrank(); + vm.startPrank(bob); + plugin.approve(pid, false); + vm.stopPrank(); + vm.startPrank(carol); + plugin.approve(pid, false); + assertEq(plugin.canExecute(pid), true, "Should be true"); + + vm.warp(10 days); + assertEq(plugin.canExecute(pid), true, "Should be true"); + + vm.warp(10 days + 1); + assertEq(plugin.canExecute(pid), false, "Should be false"); + + // 2 + vm.warp(0); // timestamp = 0 + + actions = new IDAO.Action[](0); + pid = plugin.createProposal( + "", + actions, + optimisticPlugin, + false, + 0, + 1000 days + ); + + vm.stopPrank(); + vm.startPrank(alice); + plugin.approve(pid, false); + vm.stopPrank(); + vm.startPrank(bob); + plugin.approve(pid, false); + vm.stopPrank(); + vm.startPrank(carol); + plugin.approve(pid, false); + assertEq(plugin.canExecute(pid), true, "Should be true"); + + vm.warp(10 days); + assertEq(plugin.canExecute(pid), true, "Should be true"); + + vm.warp(10 days + 1); + assertEq(plugin.canExecute(pid), false, "Should be false"); + + vm.stopPrank(); + vm.startPrank(alice); + } + + function test_CanExecuteReturnsFalseIfExecuted() public { + // returns `false` if the proposal is already executed + + dao.grant( + address(optimisticPlugin), + address(plugin), + optimisticPlugin.PROPOSER_PERMISSION_ID() + ); + vm.roll(block.number + 1); + + IDAO.Action[] memory actions = new IDAO.Action[](0); + uint pid = plugin.createProposal( + "", + actions, + optimisticPlugin, + false, + 0, + 0 + ); + + // Alice + plugin.approve(pid, false); + + // Bob + vm.stopPrank(); + vm.startPrank(bob); + plugin.approve(pid, false); + + // Carol + vm.stopPrank(); + vm.startPrank(carol); + plugin.approve(pid, false); + + assertEq(plugin.canExecute(pid), true, "Should be true"); + plugin.execute(pid); + + assertEq(plugin.canExecute(pid), false, "Should be false"); + + vm.stopPrank(); + vm.startPrank(alice); + } + + function test_CanExecuteReturnsTrueWhenAllGood() public { + // returns `true` if the proposal can be executed + + vm.roll(block.number + 1); + + IDAO.Action[] memory actions = new IDAO.Action[](0); + uint pid = plugin.createProposal( + "", + actions, + optimisticPlugin, + false, + 0, + 0 + ); + assertEq(plugin.canExecute(pid), false, "Should be false"); + + // Alice + plugin.approve(pid, false); + assertEq(plugin.canExecute(pid), false, "Should be false"); + + // Bob + vm.stopPrank(); + vm.startPrank(bob); + plugin.approve(pid, false); + assertEq(plugin.canExecute(pid), false, "Should be false"); + + // Carol + vm.stopPrank(); + vm.startPrank(carol); + plugin.approve(pid, false); + + assertEq(plugin.canExecute(pid), true, "Should be true"); + } + + // EXECUTE + + function test_ExecuteRevertsIfBelowMinApprovals() public { + // reverts if minApprovals is not met yet + + { + // Deploy a new multisig instance + Multisig.MultisigSettings memory settings = Multisig + .MultisigSettings({ + onlyListed: true, + minApprovals: 2, + destinationMinDuration: 4 days + }); + address[] memory signers = new address[](4); + signers[0] = alice; + signers[1] = bob; + signers[2] = carol; + signers[3] = david; + + plugin = Multisig( + createProxyAndCall( + address(MULTISIG_BASE), + abi.encodeCall( + Multisig.initialize, + (dao, signers, settings) + ) + ) + ); + dao.grant( + address(optimisticPlugin), + address(plugin), + optimisticPlugin.PROPOSER_PERMISSION_ID() + ); + vm.roll(block.number + 1); + } + IDAO.Action[] memory actions = new IDAO.Action[](0); + uint pid = plugin.createProposal( + "", + actions, + optimisticPlugin, + false, + 0, + 0 + ); + + // Alice + plugin.approve(pid, false); + vm.expectRevert( + abi.encodeWithSelector( + Multisig.ProposalExecutionForbidden.selector, + pid + ) + ); + plugin.execute(pid); + + // Bob + vm.stopPrank(); + vm.startPrank(bob); + plugin.approve(pid, false); + plugin.execute(pid); // ok + + // More approvals required (4) + vm.stopPrank(); + vm.startPrank(alice); + + { + // Deploy a new multisig instance + Multisig.MultisigSettings memory settings = Multisig + .MultisigSettings({ + onlyListed: true, + minApprovals: 4, + destinationMinDuration: 4 days + }); + address[] memory signers = new address[](4); + signers[0] = alice; + signers[1] = bob; + signers[2] = carol; + signers[3] = david; + + plugin = Multisig( + createProxyAndCall( + address(MULTISIG_BASE), + abi.encodeCall( + Multisig.initialize, + (dao, signers, settings) + ) + ) + ); + dao.grant( + address(optimisticPlugin), + address(plugin), + optimisticPlugin.PROPOSER_PERMISSION_ID() + ); + vm.roll(block.number + 1); + } + + pid = plugin.createProposal("", actions, optimisticPlugin, false, 0, 0); + + // Alice + vm.stopPrank(); + vm.startPrank(alice); + plugin.approve(pid, false); + vm.expectRevert( + abi.encodeWithSelector( + Multisig.ProposalExecutionForbidden.selector, + pid + ) + ); + plugin.execute(pid); + + // Bob + vm.stopPrank(); + vm.startPrank(bob); + plugin.approve(pid, false); + vm.expectRevert( + abi.encodeWithSelector( + Multisig.ProposalExecutionForbidden.selector, + pid + ) + ); + plugin.execute(pid); + + // Carol + vm.stopPrank(); + vm.startPrank(carol); + plugin.approve(pid, false); + vm.expectRevert( + abi.encodeWithSelector( + Multisig.ProposalExecutionForbidden.selector, + pid + ) + ); + plugin.execute(pid); + + // David + vm.stopPrank(); + vm.startPrank(david); + plugin.approve(pid, false); + plugin.execute(pid); + + vm.stopPrank(); + vm.startPrank(alice); + } + + function test_ExecuteRevertsIfExpired() public { + // reverts if the proposal has expired + + vm.roll(block.number + 1); + vm.warp(0); + + // 1 + IDAO.Action[] memory actions = new IDAO.Action[](0); + uint pid = plugin.createProposal( + "", + actions, + optimisticPlugin, + false, + 0, + 0 + ); + + plugin.approve(pid, false); + vm.stopPrank(); + vm.startPrank(bob); + plugin.approve(pid, false); + vm.stopPrank(); + vm.startPrank(carol); + plugin.approve(pid, false); + assertEq(plugin.canExecute(pid), true, "Should be true"); + + vm.warp(10 days + 1); + + vm.expectRevert( + abi.encodeWithSelector( + Multisig.ProposalExecutionForbidden.selector, + pid + ) + ); + plugin.execute(pid); + + vm.warp(100 days); + + // 2 + pid = plugin.createProposal( + "", + actions, + optimisticPlugin, + false, + 0, + 1000 days + ); + + vm.stopPrank(); + vm.startPrank(alice); + plugin.approve(pid, false); + vm.stopPrank(); + vm.startPrank(bob); + plugin.approve(pid, false); + vm.stopPrank(); + vm.startPrank(carol); + plugin.approve(pid, false); + assertEq(plugin.canExecute(pid), true, "Should be true"); + + vm.warp(100 days + 10 days + 1); + + vm.expectRevert( + abi.encodeWithSelector( + Multisig.ProposalExecutionForbidden.selector, + pid + ) + ); + plugin.execute(pid); + + vm.stopPrank(); + vm.startPrank(alice); + } + + function test_ExecuteRevertsWhenAlreadyExecuted() public { + // executes if the minimum approval is met when multisig with the `tryExecution` option + + dao.grant( + address(optimisticPlugin), + address(plugin), + optimisticPlugin.PROPOSER_PERMISSION_ID() + ); + vm.roll(block.number + 1); + + IDAO.Action[] memory actions = new IDAO.Action[](0); + uint pid = plugin.createProposal( + "", + actions, + optimisticPlugin, + false, + 0, + 0 + ); + + // Alice + plugin.approve(pid, false); + + // Bob + vm.stopPrank(); + vm.startPrank(bob); + plugin.approve(pid, false); + + // Carol + vm.stopPrank(); + vm.startPrank(carol); + plugin.approve(pid, false); + + assertEq(plugin.canExecute(pid), true, "Should be true"); + plugin.execute(pid); + + vm.expectRevert( + abi.encodeWithSelector( + Multisig.ProposalExecutionForbidden.selector, + pid + ) + ); + plugin.execute(pid); + + vm.stopPrank(); + vm.startPrank(alice); + } + + function test_ExecuteEmitsEvents() public { + // emits the `ProposalExecuted` and `ProposalCreated` events + + vm.roll(block.number + 1); + + dao.grant( + address(optimisticPlugin), + address(plugin), + optimisticPlugin.PROPOSER_PERMISSION_ID() + ); + vm.warp(0); + + IDAO.Action[] memory actions = new IDAO.Action[](0); + uint pid = plugin.createProposal( + "", + actions, + optimisticPlugin, + false, + 0, + 0 + ); + + // Alice + plugin.approve(pid, false); + + // Bob + vm.stopPrank(); + vm.startPrank(bob); + plugin.approve(pid, false); + + // Carol + vm.stopPrank(); + vm.startPrank(carol); + plugin.approve(pid, false); + + // event + vm.expectEmit(); + emit Executed(pid); + vm.expectEmit(); + emit ProposalCreated( + 0, + address(plugin), + uint64(block.timestamp), + uint64(block.timestamp) + 4 days, + "", + actions, + 0 + ); + plugin.execute(pid); + + // 2 + actions = new IDAO.Action[](1); + actions[0].value = 1 ether; + actions[0].to = address(bob); + actions[0].data = hex"00112233"; + pid = plugin.createProposal( + "ipfs://", + actions, + optimisticPlugin, + false, + 10 days, + 50 days + ); + + // Alice + vm.stopPrank(); + vm.startPrank(alice); + plugin.approve(pid, false); + + // Bob + vm.stopPrank(); + vm.startPrank(bob); + plugin.approve(pid, false); + + // Carol + vm.stopPrank(); + vm.startPrank(carol); + plugin.approve(pid, false); + + // events + vm.expectEmit(); + emit Executed(pid); + vm.expectEmit(); + emit ProposalCreated( + 1, + address(plugin), + 10 days, + 50 days, + "ipfs://", + actions, + 0 + ); + plugin.execute(pid); + } + + function test_ExecutesWhenApprovingWithTryExecutionAndEnoughApprovals() + public + { + // executes if the minimum approval is met when multisig with the `tryExecution` option + + vm.roll(block.number + 1); + + dao.grant( + address(optimisticPlugin), + address(plugin), + optimisticPlugin.PROPOSER_PERMISSION_ID() + ); + IDAO.Action[] memory actions = new IDAO.Action[](0); + uint pid = plugin.createProposal( + "", + actions, + optimisticPlugin, + false, + 0, + 0 + ); + (bool executed, , , , , ) = plugin.getProposal(pid); + assertEq(executed, false, "Should not be executed"); + + // Alice + plugin.approve(pid, true); + (executed, , , , , ) = plugin.getProposal(pid); + assertEq(executed, false, "Should not be executed"); + + // Bob + vm.stopPrank(); + vm.startPrank(bob); + plugin.approve(pid, true); + (executed, , , , , ) = plugin.getProposal(pid); + assertEq(executed, false, "Should not be executed"); + + // Carol + vm.stopPrank(); + vm.startPrank(carol); + plugin.approve(pid, true); + + (executed, , , , , ) = plugin.getProposal(pid); + assertEq(executed, true, "Should be executed"); + + vm.stopPrank(); + vm.startPrank(alice); + } + + function test_ExecuteEmitsWhenAutoExecutedFromApprove() public { + // emits the `Approved`, `ProposalExecuted`, and `ProposalCreated` events if execute is called inside the `approve` method + + dao.grant( + address(optimisticPlugin), + address(plugin), + optimisticPlugin.PROPOSER_PERMISSION_ID() + ); + vm.roll(block.number + 1); + + IDAO.Action[] memory actions = new IDAO.Action[](0); + uint pid = plugin.createProposal( + "", + actions, + optimisticPlugin, + false, + 0, + 0 + ); + + // Alice + plugin.approve(pid, true); + + // Bob + vm.stopPrank(); + vm.startPrank(bob); + plugin.approve(pid, true); + + // Carol + vm.stopPrank(); + vm.startPrank(carol); + vm.expectEmit(); + emit Approved(pid, carol); + vm.expectEmit(); + emit Executed(pid); + vm.expectEmit(); + emit ProposalCreated( + 0, // foreign pid + address(plugin), + uint64(block.timestamp), + uint64(block.timestamp) + 4 days, + "", + actions, + 0 + ); + plugin.approve(pid, true); + + // 2 + actions = new IDAO.Action[](1); + actions[0].value = 1 ether; + actions[0].to = address(bob); + actions[0].data = hex"00112233"; + pid = plugin.createProposal( + "ipfs://", + actions, + optimisticPlugin, + false, + 5 days, + 20 days + ); + + // Alice + vm.stopPrank(); + vm.startPrank(alice); + plugin.approve(pid, true); + + // Bob + vm.stopPrank(); + vm.startPrank(bob); + plugin.approve(pid, true); + + // Carol + vm.stopPrank(); + vm.startPrank(carol); + vm.expectEmit(); + emit Approved(pid, carol); + vm.expectEmit(); + emit Executed(pid); + vm.expectEmit(); + emit ProposalCreated( + 1, // foreign pid + address(plugin), + 5 days, + 20 days, + "ipfs://", + actions, + 0 + ); + plugin.approve(pid, true); + + // 3 + actions = new IDAO.Action[](1); + actions[0].value = 5 ether; + actions[0].to = address(carol); + actions[0].data = hex"44556677"; + pid = plugin.createProposal( + "ipfs://...", + actions, + optimisticPlugin, + false, + 3 days, + 500 days + ); + + // Alice + vm.stopPrank(); + vm.startPrank(alice); + plugin.approve(pid, true); + + // Bob + vm.stopPrank(); + vm.startPrank(bob); + plugin.approve(pid, true); + + // Carol + vm.stopPrank(); + vm.startPrank(carol); + vm.expectEmit(); + emit Approved(pid, carol); + vm.expectEmit(); + emit Executed(pid); + vm.expectEmit(); + emit ProposalCreated( + 2, // foreign pid + address(plugin), + 3 days, + 500 days, + "ipfs://...", + actions, + 0 + ); + plugin.approve(pid, true); + + vm.stopPrank(); + vm.startPrank(alice); + } + + function test_ExecutesWithEnoughApprovalsOnTime() public { + // executes if the minimum approval is met + + dao.grant( + address(optimisticPlugin), + address(plugin), + optimisticPlugin.PROPOSER_PERMISSION_ID() + ); + vm.roll(block.number + 1); + + IDAO.Action[] memory actions = new IDAO.Action[](0); + uint pid = plugin.createProposal( + "", + actions, + optimisticPlugin, + false, + 0, + 0 + ); + + // Alice + plugin.approve(pid, false); + (bool executed, , , , , ) = plugin.getProposal(pid); + assertEq(executed, false, "Should not be executed"); + + // Bob + vm.stopPrank(); + vm.startPrank(bob); + plugin.approve(pid, false); + (executed, , , , , ) = plugin.getProposal(pid); + assertEq(executed, false, "Should not be executed"); + + // Carol + vm.stopPrank(); + vm.startPrank(carol); + plugin.approve(pid, false); + (executed, , , , , ) = plugin.getProposal(pid); + assertEq(executed, false, "Should not be executed"); + + plugin.execute(pid); + (executed, , , , , ) = plugin.getProposal(pid); + assertEq(executed, true, "Should be executed"); + + // 2 + actions = new IDAO.Action[](1); + actions[0].value = 1 ether; + actions[0].to = address(bob); + actions[0].data = hex"00112233"; + pid = plugin.createProposal( + "ipfs://", + actions, + optimisticPlugin, + false, + 0, + 0 + ); + + // Alice + vm.stopPrank(); + vm.startPrank(alice); + plugin.approve(pid, false); + (executed, , , , , ) = plugin.getProposal(pid); + assertEq(executed, false, "Should not be executed"); + + // Bob + vm.stopPrank(); + vm.startPrank(bob); + plugin.approve(pid, false); + (executed, , , , , ) = plugin.getProposal(pid); + assertEq(executed, false, "Should not be executed"); + + // Carol + vm.stopPrank(); + vm.startPrank(carol); + plugin.approve(pid, false); + (executed, , , , , ) = plugin.getProposal(pid); + assertEq(executed, false, "Should not be executed"); + + plugin.execute(pid); + + (executed, , , , , ) = plugin.getProposal(pid); + assertEq(executed, true, "Should be executed"); + + vm.stopPrank(); + vm.startPrank(alice); + } + + function test_ExecuteWhenPassedAndCalledByAnyone() public { + // executes if the minimum approval is met and can be called by an unlisted accounts + + dao.grant( + address(optimisticPlugin), + address(plugin), + optimisticPlugin.PROPOSER_PERMISSION_ID() + ); + vm.roll(block.number + 1); + + IDAO.Action[] memory actions = new IDAO.Action[](0); + uint pid = plugin.createProposal( + "", + actions, + optimisticPlugin, + false, + 0, + 0 + ); + + // Alice + plugin.approve(pid, false); + (bool executed, , , , , ) = plugin.getProposal(pid); + assertEq(executed, false, "Should not be executed"); + + // Bob + vm.stopPrank(); + vm.startPrank(bob); + plugin.approve(pid, false); + (executed, , , , , ) = plugin.getProposal(pid); + assertEq(executed, false, "Should not be executed"); + + // Carol + vm.stopPrank(); + vm.startPrank(carol); + plugin.approve(pid, false); + (executed, , , , , ) = plugin.getProposal(pid); + assertEq(executed, false, "Should not be executed"); + + vm.stopPrank(); + vm.startPrank(randomWallet); + plugin.execute(pid); + (executed, , , , , ) = plugin.getProposal(pid); + assertEq(executed, true, "Should be executed"); + + // 2 + vm.stopPrank(); + vm.startPrank(alice); + + actions = new IDAO.Action[](1); + actions[0].value = 1 ether; + actions[0].to = address(bob); + actions[0].data = hex"00112233"; + pid = plugin.createProposal( + "ipfs://", + actions, + optimisticPlugin, + false, + 0, + 0 + ); + + // Alice + plugin.approve(pid, false); + (executed, , , , , ) = plugin.getProposal(pid); + assertEq(executed, false, "Should not be executed"); + + // Bob + vm.stopPrank(); + vm.startPrank(bob); + plugin.approve(pid, false); + (executed, , , , , ) = plugin.getProposal(pid); + assertEq(executed, false, "Should not be executed"); + + // Carol + vm.stopPrank(); + vm.startPrank(carol); + plugin.approve(pid, false); + (executed, , , , , ) = plugin.getProposal(pid); + assertEq(executed, false, "Should not be executed"); + + vm.stopPrank(); + vm.startPrank(randomWallet); + plugin.execute(pid); + + (executed, , , , , ) = plugin.getProposal(pid); + assertEq(executed, true, "Should be executed"); + + vm.stopPrank(); + vm.startPrank(alice); + } + + function test_GetProposalReturnsTheRightValues() public { + // Get proposal returns the right values + + bool executed; + uint16 approvals; + Multisig.ProposalParameters memory parameters; + bytes memory metadataURI; + IDAO.Action[] memory actions; + OptimisticTokenVotingPlugin destPlugin; + + vm.roll(block.number + 1); + dao.grant( + address(optimisticPlugin), + address(plugin), + optimisticPlugin.PROPOSER_PERMISSION_ID() + ); + vm.warp(10); // timestamp = 10 + + IDAO.Action[] memory createActions = new IDAO.Action[](3); + createActions[0].to = alice; + createActions[0].value = 1 ether; + createActions[0].data = hex"001122334455"; + createActions[1].to = bob; + createActions[1].value = 2 ether; + createActions[1].data = hex"112233445566"; + createActions[2].to = carol; + createActions[2].value = 3 ether; + createActions[2].data = hex"223344556677"; + + uint pid = plugin.createProposal( + "ipfs://metadata", + createActions, + optimisticPlugin, + false, + 0, + 15 days + ); + assertEq(pid, 0, "PID should be 0"); + + // Check round 1 + ( + executed, + approvals, + parameters, + metadataURI, + actions, + destPlugin + ) = plugin.getProposal(pid); + + assertEq(executed, false, "Should not be executed"); + assertEq(approvals, 0, "Should be 0"); + + assertEq(parameters.minApprovals, 3, "Incorrect minApprovals"); + assertEq( + parameters.snapshotBlock, + block.number - 1, + "Incorrect snapshotBlock" + ); + assertEq( + parameters.expirationDate, + block.timestamp + 10 days, + "Incorrect expirationDate" + ); + assertEq(parameters.destinationStartDate, 0, "Incorrect startDate"); + assertEq(parameters.destinationEndDate, 15 days, "Incorrect endDate"); + + assertEq(actions.length, 3, "Should be 3"); + + assertEq(actions[0].to, alice, "Incorrect to"); + assertEq(actions[0].value, 1 ether, "Incorrect value"); + assertEq(actions[0].data, hex"001122334455", "Incorrect data"); + assertEq(actions[1].to, bob, "Incorrect to"); + assertEq(actions[1].value, 2 ether, "Incorrect value"); + assertEq(actions[1].data, hex"112233445566", "Incorrect data"); + assertEq(actions[2].to, carol, "Incorrect to"); + assertEq(actions[2].value, 3 ether, "Incorrect value"); + assertEq(actions[2].data, hex"223344556677", "Incorrect data"); + + assertEq(metadataURI, "ipfs://metadata", "Incorrect metadata URI"); + assertEq( + address(destPlugin), + address(optimisticPlugin), + "Incorrect destPlugin" + ); + + // Approve + plugin.approve(pid, false); + + // Check round 2 + ( + executed, + approvals, + parameters, + metadataURI, + actions, + destPlugin + ) = plugin.getProposal(pid); + + assertEq(executed, false, "Should not be executed"); + assertEq(approvals, 1, "Should be 1"); + + assertEq(parameters.minApprovals, 3, "Incorrect minApprovals"); + assertEq( + parameters.snapshotBlock, + block.number - 1, + "Incorrect snapshotBlock" + ); + assertEq( + parameters.expirationDate, + block.timestamp + 10 days, + "Incorrect expirationDate" + ); + assertEq(parameters.destinationStartDate, 0, "Incorrect startDate"); + assertEq(parameters.destinationEndDate, 15 days, "Incorrect endDate"); + + assertEq(actions.length, 3, "Should be 3"); + + assertEq(actions[0].to, alice, "Incorrect to"); + assertEq(actions[0].value, 1 ether, "Incorrect value"); + assertEq(actions[0].data, hex"001122334455", "Incorrect data"); + assertEq(actions[1].to, bob, "Incorrect to"); + assertEq(actions[1].value, 2 ether, "Incorrect value"); + assertEq(actions[1].data, hex"112233445566", "Incorrect data"); + assertEq(actions[2].to, carol, "Incorrect to"); + assertEq(actions[2].value, 3 ether, "Incorrect value"); + assertEq(actions[2].data, hex"223344556677", "Incorrect data"); + + assertEq(metadataURI, "ipfs://metadata", "Incorrect metadata URI"); + assertEq( + address(destPlugin), + address(optimisticPlugin), + "Incorrect destPlugin" + ); + + // Approve + vm.stopPrank(); + vm.startPrank(bob); + plugin.approve(pid, false); + vm.stopPrank(); + vm.startPrank(carol); + plugin.approve(pid, false); + + // Check round 3 + ( + executed, + approvals, + parameters, + metadataURI, + actions, + destPlugin + ) = plugin.getProposal(pid); + + assertEq(executed, false, "Should not be executed"); + assertEq(approvals, 3, "Should be 3"); + + assertEq(parameters.minApprovals, 3, "Incorrect minApprovals"); + assertEq( + parameters.snapshotBlock, + block.number - 1, + "Incorrect snapshotBlock" + ); + assertEq( + parameters.expirationDate, + block.timestamp + 10 days, + "Incorrect expirationDate" + ); + assertEq(parameters.destinationStartDate, 0, "Incorrect startDate"); + assertEq(parameters.destinationEndDate, 15 days, "Incorrect endDate"); + + assertEq(actions.length, 3, "Should be 3"); + + assertEq(actions[0].to, alice, "Incorrect to"); + assertEq(actions[0].value, 1 ether, "Incorrect value"); + assertEq(actions[0].data, hex"001122334455", "Incorrect data"); + assertEq(actions[1].to, bob, "Incorrect to"); + assertEq(actions[1].value, 2 ether, "Incorrect value"); + assertEq(actions[1].data, hex"112233445566", "Incorrect data"); + assertEq(actions[2].to, carol, "Incorrect to"); + assertEq(actions[2].value, 3 ether, "Incorrect value"); + assertEq(actions[2].data, hex"223344556677", "Incorrect data"); + + assertEq(metadataURI, "ipfs://metadata", "Incorrect metadata URI"); + assertEq( + address(destPlugin), + address(optimisticPlugin), + "Incorrect destPlugin" + ); + + // Execute + vm.stopPrank(); + vm.startPrank(alice); + plugin.execute(pid); + + // Check round 4 + ( + executed, + approvals, + parameters, + metadataURI, + actions, + destPlugin + ) = plugin.getProposal(pid); + + assertEq(executed, true, "Should be executed"); + assertEq(approvals, 3, "Should be 3"); + + assertEq(parameters.minApprovals, 3, "Incorrect minApprovals"); + assertEq( + parameters.snapshotBlock, + block.number - 1, + "Incorrect snapshotBlock" + ); + assertEq( + parameters.expirationDate, + block.timestamp + 10 days, + "Incorrect expirationDate" + ); + assertEq(parameters.destinationStartDate, 0, "Incorrect startDate"); + assertEq(parameters.destinationEndDate, 15 days, "Incorrect endDate"); + + assertEq(actions.length, 3, "Should be 3"); + + assertEq(actions[0].to, alice, "Incorrect to"); + assertEq(actions[0].value, 1 ether, "Incorrect value"); + assertEq(actions[0].data, hex"001122334455", "Incorrect data"); + assertEq(actions[1].to, bob, "Incorrect to"); + assertEq(actions[1].value, 2 ether, "Incorrect value"); + assertEq(actions[1].data, hex"112233445566", "Incorrect data"); + assertEq(actions[2].to, carol, "Incorrect to"); + assertEq(actions[2].value, 3 ether, "Incorrect value"); + assertEq(actions[2].data, hex"223344556677", "Incorrect data"); + + assertEq(metadataURI, "ipfs://metadata", "Incorrect metadata URI"); + assertEq( + address(destPlugin), + address(optimisticPlugin), + "Incorrect destPlugin" + ); + + // New proposal, new settings + vm.stopPrank(); + vm.startPrank(alice); + + { + // Deploy a new multisig instance + Multisig.MultisigSettings memory settings = Multisig + .MultisigSettings({ + onlyListed: true, + minApprovals: 2, + destinationMinDuration: 4 days + }); + address[] memory signers = new address[](3); + signers[0] = alice; + signers[1] = bob; + signers[2] = carol; + + plugin = Multisig( + createProxyAndCall( + address(MULTISIG_BASE), + abi.encodeCall( + Multisig.initialize, + (dao, signers, settings) + ) + ) + ); + vm.roll(block.number + 1); + dao.grant( + address(optimisticPlugin), + address(plugin), + optimisticPlugin.PROPOSER_PERMISSION_ID() + ); + } + createActions = new IDAO.Action[](2); + createActions[1].to = alice; + createActions[1].value = 1 ether; + createActions[1].data = hex"001122334455"; + createActions[0].to = carol; + createActions[0].value = 3 ether; + createActions[0].data = hex"223344556677"; + + vm.warp(50); // Timestamp = 50 + + pid = plugin.createProposal( + "ipfs://different-metadata", + createActions, + optimisticPlugin, + true, + 1 days, + 16 days + ); + assertEq(pid, 0, "PID should be 0"); + + // Check round 1 + ( + executed, + approvals, + parameters, + metadataURI, + actions, + destPlugin + ) = plugin.getProposal(pid); + + assertEq(executed, false, "Should not be executed"); + assertEq(approvals, 1, "Should be 1"); + + assertEq(parameters.minApprovals, 2, "Incorrect minApprovals"); + assertEq( + parameters.snapshotBlock, + block.number - 1, + "Incorrect snapshotBlock" + ); + assertEq( + parameters.expirationDate, + block.timestamp + 10 days, + "Incorrect expirationDate" + ); + assertEq( + parameters.destinationStartDate, + 1 days, + "Incorrect startDate" + ); + assertEq(parameters.destinationEndDate, 16 days, "Incorrect endDate"); + + assertEq(actions.length, 2, "Should be 2"); + + assertEq(actions[1].to, alice, "Incorrect to"); + assertEq(actions[1].value, 1 ether, "Incorrect value"); + assertEq(actions[1].data, hex"001122334455", "Incorrect data"); + assertEq(actions[0].to, carol, "Incorrect to"); + assertEq(actions[0].value, 3 ether, "Incorrect value"); + assertEq(actions[0].data, hex"223344556677", "Incorrect data"); + + assertEq( + metadataURI, + "ipfs://different-metadata", + "Incorrect metadata URI" + ); + assertEq( + address(destPlugin), + address(optimisticPlugin), + "Incorrect destPlugin" + ); + + // Approve + vm.stopPrank(); + vm.startPrank(bob); + plugin.approve(pid, false); + + // Check round 2 + ( + executed, + approvals, + parameters, + metadataURI, + actions, + destPlugin + ) = plugin.getProposal(pid); + + assertEq(executed, false, "Should not be executed"); + assertEq(approvals, 2, "Should be 2"); + + assertEq(parameters.minApprovals, 2, "Incorrect minApprovals"); + assertEq( + parameters.snapshotBlock, + block.number - 1, + "Incorrect snapshotBlock" + ); + assertEq( + parameters.expirationDate, + block.timestamp + 10 days, + "Incorrect expirationDate" + ); + assertEq( + parameters.destinationStartDate, + 1 days, + "Incorrect startDate" + ); + assertEq(parameters.destinationEndDate, 16 days, "Incorrect endDate"); + + assertEq(actions.length, 2, "Should be 2"); + + assertEq(actions[1].to, alice, "Incorrect to"); + assertEq(actions[1].value, 1 ether, "Incorrect value"); + assertEq(actions[1].data, hex"001122334455", "Incorrect data"); + assertEq(actions[0].to, carol, "Incorrect to"); + assertEq(actions[0].value, 3 ether, "Incorrect value"); + assertEq(actions[0].data, hex"223344556677", "Incorrect data"); + + assertEq( + metadataURI, + "ipfs://different-metadata", + "Incorrect metadata URI" + ); + assertEq( + address(destPlugin), + address(optimisticPlugin), + "Incorrect destPlugin" + ); + + // Approve + vm.stopPrank(); + vm.startPrank(carol); + plugin.approve(pid, false); + + // Check round 3 + ( + executed, + approvals, + parameters, + metadataURI, + actions, + destPlugin + ) = plugin.getProposal(pid); + + assertEq(executed, false, "Should not be executed"); + assertEq(approvals, 3, "Should be 3"); + + assertEq(parameters.minApprovals, 2, "Incorrect minApprovals"); + assertEq( + parameters.snapshotBlock, + block.number - 1, + "Incorrect snapshotBlock" + ); + assertEq( + parameters.expirationDate, + block.timestamp + 10 days, + "Incorrect expirationDate" + ); + assertEq( + parameters.destinationStartDate, + 1 days, + "Incorrect startDate" + ); + assertEq(parameters.destinationEndDate, 16 days, "Incorrect endDate"); + + assertEq(actions.length, 2, "Should be 2"); + + assertEq(actions[1].to, alice, "Incorrect to"); + assertEq(actions[1].value, 1 ether, "Incorrect value"); + assertEq(actions[1].data, hex"001122334455", "Incorrect data"); + assertEq(actions[0].to, carol, "Incorrect to"); + assertEq(actions[0].value, 3 ether, "Incorrect value"); + assertEq(actions[0].data, hex"223344556677", "Incorrect data"); + + assertEq( + metadataURI, + "ipfs://different-metadata", + "Incorrect metadata URI" + ); + assertEq( + address(destPlugin), + address(optimisticPlugin), + "Incorrect destPlugin" + ); + + // Execute + vm.stopPrank(); + vm.startPrank(alice); + plugin.execute(pid); + + // Check round 4 + ( + executed, + approvals, + parameters, + metadataURI, + actions, + destPlugin + ) = plugin.getProposal(pid); + + assertEq(executed, true, "Should be executed"); + assertEq(approvals, 3, "Should be 3"); + + assertEq(parameters.minApprovals, 2, "Incorrect minApprovals"); + assertEq( + parameters.snapshotBlock, + block.number - 1, + "Incorrect snapshotBlock" + ); + assertEq( + parameters.expirationDate, + block.timestamp + 10 days, + "Incorrect expirationDate" + ); + assertEq( + parameters.destinationStartDate, + 1 days, + "Incorrect startDate" + ); + assertEq(parameters.destinationEndDate, 16 days, "Incorrect endDate"); + + assertEq(actions.length, 2, "Should be 2"); + + assertEq(actions[1].to, alice, "Incorrect to"); + assertEq(actions[1].value, 1 ether, "Incorrect value"); + assertEq(actions[1].data, hex"001122334455", "Incorrect data"); + assertEq(actions[0].to, carol, "Incorrect to"); + assertEq(actions[0].value, 3 ether, "Incorrect value"); + assertEq(actions[0].data, hex"223344556677", "Incorrect data"); + + assertEq( + metadataURI, + "ipfs://different-metadata", + "Incorrect metadata URI" + ); + assertEq( + address(destPlugin), + address(optimisticPlugin), + "Incorrect destPlugin" + ); + } + + function test_ProxiedProposalHasTheSameSettingsAsTheOriginal() public { + // Recreated proposal has the same settings and actions as registered here + + bool open; + bool executed; + OptimisticTokenVotingPlugin.ProposalParameters memory parameters; + uint256 vetoTally; + IDAO.Action[] memory actions; + uint256 allowFailureMap; + + vm.roll(block.number + 1); + dao.grant( + address(optimisticPlugin), + address(plugin), + optimisticPlugin.PROPOSER_PERMISSION_ID() + ); + vm.warp(0); // timestamp = 0 + + IDAO.Action[] memory createActions = new IDAO.Action[](3); + createActions[0].to = alice; + createActions[0].value = 1 ether; + createActions[0].data = hex"001122334455"; + createActions[1].to = bob; + createActions[1].value = 2 ether; + createActions[1].data = hex"112233445566"; + createActions[2].to = carol; + createActions[2].value = 3 ether; + createActions[2].data = hex"223344556677"; + + uint pid = plugin.createProposal( + "ipfs://metadata", + createActions, + optimisticPlugin, + false, + 0, + 0 + ); + + // Approve + plugin.approve(pid, false); + vm.stopPrank(); + vm.startPrank(bob); + plugin.approve(pid, false); + vm.stopPrank(); + vm.startPrank(carol); + plugin.approve(pid, false); + + vm.stopPrank(); + vm.startPrank(alice); + plugin.execute(pid); + + // Check round + ( + open, + executed, + parameters, + vetoTally, + actions, + allowFailureMap + ) = optimisticPlugin.getProposal(pid); + + assertEq(open, true, "Should be open"); + assertEq(executed, false, "Should not be executed"); + assertEq(vetoTally, 0, "Should be 0"); + + assertEq(parameters.startDate, block.timestamp, "Incorrect startDate"); + assertEq( + parameters.endDate, + block.timestamp + 4 days, + "Incorrect endDate" + ); + + assertEq(actions.length, 3, "Should be 3"); + + assertEq(actions[0].to, alice, "Incorrect to"); + assertEq(actions[0].value, 1 ether, "Incorrect value"); + assertEq(actions[0].data, hex"001122334455", "Incorrect data"); + assertEq(actions[1].to, bob, "Incorrect to"); + assertEq(actions[1].value, 2 ether, "Incorrect value"); + assertEq(actions[1].data, hex"112233445566", "Incorrect data"); + assertEq(actions[2].to, carol, "Incorrect to"); + assertEq(actions[2].value, 3 ether, "Incorrect value"); + assertEq(actions[2].data, hex"223344556677", "Incorrect data"); + + assertEq(allowFailureMap, 0, "Should be 0"); + + // New proposal + + createActions = new IDAO.Action[](2); + createActions[1].to = alice; + createActions[1].value = 1 ether; + createActions[1].data = hex"001122334455"; + createActions[0].to = carol; + createActions[0].value = 3 ether; + createActions[0].data = hex"223344556677"; + + pid = plugin.createProposal( + "ipfs://more-metadata", + createActions, + optimisticPlugin, + false, + 1 days, + 6 days + ); + + // Approve + plugin.approve(pid, false); + vm.stopPrank(); + vm.startPrank(bob); + plugin.approve(pid, false); + vm.stopPrank(); + vm.startPrank(carol); + plugin.approve(pid, false); + + vm.stopPrank(); + vm.startPrank(alice); + plugin.execute(pid); + + // Check round + ( + open, + executed, + parameters, + vetoTally, + actions, + allowFailureMap + ) = optimisticPlugin.getProposal(pid); + + assertEq(open, false, "Should not be open"); + assertEq(executed, false, "Should not be executed"); + assertEq(vetoTally, 0, "Should be 0"); + + assertEq(parameters.startDate, 1 days, "Incorrect startDate"); + assertEq(parameters.endDate, 6 days, "Incorrect endDate"); + + assertEq(actions.length, 2, "Should be 2"); + + assertEq(actions[1].to, alice, "Incorrect to"); + assertEq(actions[1].value, 1 ether, "Incorrect value"); + assertEq(actions[1].data, hex"001122334455", "Incorrect data"); + assertEq(actions[0].to, carol, "Incorrect to"); + assertEq(actions[0].value, 3 ether, "Incorrect value"); + assertEq(actions[0].data, hex"223344556677", "Incorrect data"); + + assertEq(allowFailureMap, 0, "Should be 0"); + } +} diff --git a/test/OptimisticTokenVotingPlugin.t.sol b/test/OptimisticTokenVotingPlugin.t.sol index c6cca13..c31d295 100644 --- a/test/OptimisticTokenVotingPlugin.t.sol +++ b/test/OptimisticTokenVotingPlugin.t.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: UNLICENSED pragma solidity ^0.8.17; -import {Test} from "forge-std/Test.sol"; +import {AragonTest} from "./base/AragonTest.sol"; import {OptimisticTokenVotingPlugin} from "../src/OptimisticTokenVotingPlugin.sol"; import {IOptimisticTokenVoting} from "../src/interfaces/IOptimisticTokenVoting.sol"; import {DAO} from "@aragon/osx/core/dao/DAO.sol"; @@ -12,23 +12,15 @@ import {IMembership} from "@aragon/osx/core/plugin/membership/IMembership.sol"; import {RATIO_BASE, RatioOutOfBounds} from "@aragon/osx/plugins/utils/Ratio.sol"; import {DaoUnauthorized} from "@aragon/osx/core/utils/auth.sol"; import {ERC20VotesMock} from "./mocks/ERC20VotesMock.sol"; -import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; import {IERC165Upgradeable} from "@openzeppelin/contracts-upgradeable/utils/introspection/IERC165Upgradeable.sol"; import {IERC1822ProxiableUpgradeable} from "@openzeppelin/contracts-upgradeable/interfaces/draft-IERC1822Upgradeable.sol"; +import {createProxyAndCall} from "./helpers.sol"; -contract OptimisticTokenVotingPluginTest is Test { - address immutable daoBase = address(new DAO()); - address immutable pluginBase = address(new OptimisticTokenVotingPlugin()); - address immutable votingTokenBase = address(new ERC20VotesMock()); - +contract OptimisticTokenVotingPluginTest is AragonTest { DAO public dao; OptimisticTokenVotingPlugin public plugin; ERC20VotesMock votingToken; - address alice = address(0xa11ce); - address bob = address(0xB0B); - address randomWallet = vm.addr(1234567890); - // Events from external contracts event Initialized(uint8 version); event ProposalCreated( @@ -56,47 +48,8 @@ contract OptimisticTokenVotingPluginTest is Test { function setUp() public { vm.startPrank(alice); - // Deploy a DAO with Alice as root - dao = DAO( - payable( - createProxyAndCall( - address(daoBase), - abi.encodeCall( - DAO.initialize, - ("", alice, address(0x0), "") - ) - ) - ) - ); - - // Deploy ERC20 token - votingToken = ERC20VotesMock( - createProxyAndCall( - address(votingTokenBase), - abi.encodeCall(ERC20VotesMock.initialize, ()) - ) - ); - votingToken.mint(alice, 10 ether); - votingToken.delegate(alice); - vm.roll(block.number + 1); - - // Deploy a new plugin instance - OptimisticTokenVotingPlugin.OptimisticGovernanceSettings - memory settings = OptimisticTokenVotingPlugin - .OptimisticGovernanceSettings({ - minVetoRatio: uint32(RATIO_BASE / 10), - minDuration: 10 days, - minProposerVotingPower: 0 - }); - - plugin = OptimisticTokenVotingPlugin( - createProxyAndCall( - address(pluginBase), - abi.encodeCall( - OptimisticTokenVotingPlugin.initialize, - (dao, settings, votingToken) - ) - ) + (dao, plugin, votingToken) = makeDaoWithOptimisticTokenVoting( + alice ); // The plugin can execute on the DAO @@ -133,7 +86,7 @@ contract OptimisticTokenVotingPluginTest is Test { }); plugin = OptimisticTokenVotingPlugin( createProxyAndCall( - address(pluginBase), + address(OPTIMISTIC_BASE), abi.encodeCall( OptimisticTokenVotingPlugin.initialize, (dao, settings, votingToken) @@ -161,7 +114,7 @@ contract OptimisticTokenVotingPluginTest is Test { settings.minVetoRatio = uint32(RATIO_BASE / 5); plugin = OptimisticTokenVotingPlugin( createProxyAndCall( - address(pluginBase), + address(OPTIMISTIC_BASE), abi.encodeCall( OptimisticTokenVotingPlugin.initialize, (dao, settings, votingToken) @@ -178,7 +131,7 @@ contract OptimisticTokenVotingPluginTest is Test { settings.minDuration = 25 days; plugin = OptimisticTokenVotingPlugin( createProxyAndCall( - address(pluginBase), + address(OPTIMISTIC_BASE), abi.encodeCall( OptimisticTokenVotingPlugin.initialize, (dao, settings, votingToken) @@ -190,7 +143,7 @@ contract OptimisticTokenVotingPluginTest is Test { // A token with 10 eth supply votingToken = ERC20VotesMock( createProxyAndCall( - address(votingTokenBase), + address(VOTING_TOKEN_BASE), abi.encodeCall(ERC20VotesMock.initialize, ()) ) ); @@ -199,7 +152,7 @@ contract OptimisticTokenVotingPluginTest is Test { plugin = OptimisticTokenVotingPlugin( createProxyAndCall( - address(pluginBase), + address(OPTIMISTIC_BASE), abi.encodeCall( OptimisticTokenVotingPlugin.initialize, (dao, settings, votingToken) @@ -216,7 +169,7 @@ contract OptimisticTokenVotingPluginTest is Test { settings.minProposerVotingPower = 1 ether; plugin = OptimisticTokenVotingPlugin( createProxyAndCall( - address(pluginBase), + address(OPTIMISTIC_BASE), abi.encodeCall( OptimisticTokenVotingPlugin.initialize, (dao, settings, votingToken) @@ -244,7 +197,7 @@ contract OptimisticTokenVotingPluginTest is Test { plugin = OptimisticTokenVotingPlugin( createProxyAndCall( - address(pluginBase), + address(OPTIMISTIC_BASE), abi.encodeCall( OptimisticTokenVotingPlugin.initialize, (dao, settings, votingToken) @@ -332,7 +285,7 @@ contract OptimisticTokenVotingPluginTest is Test { // New token votingToken = ERC20VotesMock( createProxyAndCall( - address(votingTokenBase), + address(VOTING_TOKEN_BASE), abi.encodeCall(ERC20VotesMock.initialize, ()) ) ); @@ -348,7 +301,7 @@ contract OptimisticTokenVotingPluginTest is Test { plugin = OptimisticTokenVotingPlugin( createProxyAndCall( - address(pluginBase), + address(OPTIMISTIC_BASE), abi.encodeCall( OptimisticTokenVotingPlugin.initialize, (dao, settings, votingToken) @@ -383,7 +336,7 @@ contract OptimisticTokenVotingPluginTest is Test { // New token votingToken = ERC20VotesMock( createProxyAndCall( - address(votingTokenBase), + address(VOTING_TOKEN_BASE), abi.encodeCall(ERC20VotesMock.initialize, ()) ) ); @@ -401,7 +354,7 @@ contract OptimisticTokenVotingPluginTest is Test { plugin = OptimisticTokenVotingPlugin( createProxyAndCall( - address(pluginBase), + address(OPTIMISTIC_BASE), abi.encodeCall( OptimisticTokenVotingPlugin.initialize, (dao, settings, votingToken) @@ -439,7 +392,7 @@ contract OptimisticTokenVotingPluginTest is Test { plugin = OptimisticTokenVotingPlugin( createProxyAndCall( - address(pluginBase), + address(OPTIMISTIC_BASE), abi.encodeCall( OptimisticTokenVotingPlugin.initialize, (dao, settings, votingToken) @@ -468,7 +421,7 @@ contract OptimisticTokenVotingPluginTest is Test { plugin = OptimisticTokenVotingPlugin( createProxyAndCall( - address(pluginBase), + address(OPTIMISTIC_BASE), abi.encodeCall( OptimisticTokenVotingPlugin.initialize, (dao, settings, votingToken) @@ -489,7 +442,7 @@ contract OptimisticTokenVotingPluginTest is Test { // New token votingToken = ERC20VotesMock( createProxyAndCall( - address(votingTokenBase), + address(VOTING_TOKEN_BASE), abi.encodeCall(ERC20VotesMock.initialize, ()) ) ); @@ -507,7 +460,7 @@ contract OptimisticTokenVotingPluginTest is Test { plugin = OptimisticTokenVotingPlugin( createProxyAndCall( - address(pluginBase), + address(OPTIMISTIC_BASE), abi.encodeCall( OptimisticTokenVotingPlugin.initialize, (dao, settings, votingToken) @@ -534,7 +487,7 @@ contract OptimisticTokenVotingPluginTest is Test { // New token votingToken = ERC20VotesMock( createProxyAndCall( - address(votingTokenBase), + address(VOTING_TOKEN_BASE), abi.encodeCall(ERC20VotesMock.initialize, ()) ) ); @@ -553,7 +506,7 @@ contract OptimisticTokenVotingPluginTest is Test { plugin = OptimisticTokenVotingPlugin( createProxyAndCall( - address(pluginBase), + address(OPTIMISTIC_BASE), abi.encodeCall( OptimisticTokenVotingPlugin.initialize, (dao, settings, votingToken) @@ -648,7 +601,7 @@ contract OptimisticTokenVotingPluginTest is Test { // Deploy ERC20 token (0 supply) votingToken = ERC20VotesMock( createProxyAndCall( - address(votingTokenBase), + address(VOTING_TOKEN_BASE), abi.encodeCall(ERC20VotesMock.initialize, ()) ) ); @@ -664,7 +617,7 @@ contract OptimisticTokenVotingPluginTest is Test { }); plugin = OptimisticTokenVotingPlugin( createProxyAndCall( - address(pluginBase), + address(OPTIMISTIC_BASE), abi.encodeCall( OptimisticTokenVotingPlugin.initialize, (dao, settings, votingToken) @@ -813,7 +766,7 @@ contract OptimisticTokenVotingPluginTest is Test { }); plugin = OptimisticTokenVotingPlugin( createProxyAndCall( - address(pluginBase), + address(OPTIMISTIC_BASE), abi.encodeCall( OptimisticTokenVotingPlugin.initialize, (dao, settings, votingToken) @@ -1469,7 +1422,7 @@ contract OptimisticTokenVotingPluginTest is Test { // Deploy ERC20 token votingToken = ERC20VotesMock( createProxyAndCall( - address(votingTokenBase), + address(VOTING_TOKEN_BASE), abi.encodeCall(ERC20VotesMock.initialize, ()) ) ); @@ -1500,7 +1453,7 @@ contract OptimisticTokenVotingPluginTest is Test { plugin = OptimisticTokenVotingPlugin( createProxyAndCall( - address(pluginBase), + address(OPTIMISTIC_BASE), abi.encodeCall( OptimisticTokenVotingPlugin.initialize, (dao, settings, votingToken) @@ -2062,12 +2015,4 @@ contract OptimisticTokenVotingPluginTest is Test { ) ); } - - // HELPERS - function createProxyAndCall( - address _logic, - bytes memory _data - ) private returns (address) { - return address(new ERC1967Proxy(_logic, _data)); - } } diff --git a/test/OptimisticTokenVotingPluginSetup.t.sol b/test/OptimisticTokenVotingPluginSetup.t.sol index 3746063..d7a5f86 100644 --- a/test/OptimisticTokenVotingPluginSetup.t.sol +++ b/test/OptimisticTokenVotingPluginSetup.t.sol @@ -26,6 +26,7 @@ contract OptimisticTokenVotingPluginSetupTest is Test { OptimisticTokenVotingPlugin.OptimisticGovernanceSettings votingSettings; OptimisticTokenVotingPluginSetup.TokenSettings tokenSettings; GovernanceERC20.MintSettings mintSettings; + uint64 stdProposalMinDuration; address stdProposer; address emergencyProposer; @@ -84,7 +85,9 @@ contract OptimisticTokenVotingPluginSetupTest is Test { receivers: new address[](0), amounts: new uint256[](0) }); + stdProposalMinDuration = 10 days; stdProposer = address(0x1234567890); + emergencyProposer = address(0x2345678901); } function test_ShouldEncodeInstallationParams_Default() public view { @@ -93,12 +96,13 @@ contract OptimisticTokenVotingPluginSetupTest is Test { votingSettings, tokenSettings, mintSettings, + stdProposalMinDuration, stdProposer, emergencyProposer ); bytes - memory expected = hex"00000000000000000000000000000000000000000000000000000000000186a00000000000000000000000000000000000000000000000000000000000069780000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000e000000000000000000000000000000000000000000000000000000000000001c0000000000000000000000000000000000000000000000000000000123456789000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002e234dae75c793f67a35089c9d99245e1c58470b000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000a0000000000000000000000000000000000000000000000000000000000000000d5772617070656420546f6b656e00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000377544b00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"; + memory expected = hex"00000000000000000000000000000000000000000000000000000000000186a000000000000000000000000000000000000000000000000000000000000697800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000001e000000000000000000000000000000000000000000000000000000000000d2f00000000000000000000000000000000000000000000000000000000123456789000000000000000000000000000000000000000000000000000000023456789010000000000000000000000002e234dae75c793f67a35089c9d99245e1c58470b000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000a0000000000000000000000000000000000000000000000000000000000000000d5772617070656420546f6b656e00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000377544b00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"; assertEq(output, expected, "Incorrect encoded bytes"); } @@ -114,12 +118,13 @@ contract OptimisticTokenVotingPluginSetupTest is Test { votingSettings, tokenSettings, mintSettings, + stdProposalMinDuration, stdProposer, emergencyProposer ); bytes - memory expected = hex"0000000000000000000000000000000000000000000000000000000000030d400000000000000000000000000000000000000000000000000000000000069780000000000000000000000000000000000000000000000000000000000001e24000000000000000000000000000000000000000000000000000000000000000e000000000000000000000000000000000000000000000000000000000000001c0000000000000000000000000000000000000000000000000000000123456789000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002e234dae75c793f67a35089c9d99245e1c58470b000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000a0000000000000000000000000000000000000000000000000000000000000000d5772617070656420546f6b656e00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000377544b00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"; + memory expected = hex"0000000000000000000000000000000000000000000000000000000000030d400000000000000000000000000000000000000000000000000000000000069780000000000000000000000000000000000000000000000000000000000001e240000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000001e000000000000000000000000000000000000000000000000000000000000d2f00000000000000000000000000000000000000000000000000000000123456789000000000000000000000000000000000000000000000000000000023456789010000000000000000000000002e234dae75c793f67a35089c9d99245e1c58470b000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000a0000000000000000000000000000000000000000000000000000000000000000d5772617070656420546f6b656e00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000377544b00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"; assertEq(output, expected, "Incorrect encoded bytes"); } @@ -134,12 +139,13 @@ contract OptimisticTokenVotingPluginSetupTest is Test { votingSettings, tokenSettings, mintSettings, + stdProposalMinDuration, stdProposer, emergencyProposer ); bytes - memory expected = hex"00000000000000000000000000000000000000000000000000000000000186a00000000000000000000000000000000000000000000000000000000000069780000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000e000000000000000000000000000000000000000000000000000000000000001c0000000000000000000000000000000000000000000000000000000123456789000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000005678000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000a0000000000000000000000000000000000000000000000000000000000000001057726170706564204e657720436f696e000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000004774e434e000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"; + memory expected = hex"00000000000000000000000000000000000000000000000000000000000186a000000000000000000000000000000000000000000000000000000000000697800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000001e000000000000000000000000000000000000000000000000000000000000d2f00000000000000000000000000000000000000000000000000000000123456789000000000000000000000000000000000000000000000000000000023456789010000000000000000000000000000000000000000000000000000000000005678000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000a0000000000000000000000000000000000000000000000000000000000000001057726170706564204e657720436f696e000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000004774e434e000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"; assertEq(output, expected, "Incorrect encoded bytes"); } @@ -158,12 +164,13 @@ contract OptimisticTokenVotingPluginSetupTest is Test { votingSettings, tokenSettings, mintSettings, + stdProposalMinDuration, stdProposer, emergencyProposer ); bytes - memory expected = hex"00000000000000000000000000000000000000000000000000000000000186a00000000000000000000000000000000000000000000000000000000000069780000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000e000000000000000000000000000000000000000000000000000000000000001c0000000000000000000000000000000000000000000000000000000123456789000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002e234dae75c793f67a35089c9d99245e1c58470b000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000a0000000000000000000000000000000000000000000000000000000000000000d5772617070656420546f6b656e00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000377544b00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000006789000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000499602d2"; + memory expected = hex"00000000000000000000000000000000000000000000000000000000000186a000000000000000000000000000000000000000000000000000000000000697800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000001e000000000000000000000000000000000000000000000000000000000000d2f00000000000000000000000000000000000000000000000000000000123456789000000000000000000000000000000000000000000000000000000023456789010000000000000000000000002e234dae75c793f67a35089c9d99245e1c58470b000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000a0000000000000000000000000000000000000000000000000000000000000000d5772617070656420546f6b656e00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000377544b00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000006789000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000499602d2"; assertEq(output, expected, "Incorrect encoded bytes"); } @@ -175,12 +182,13 @@ contract OptimisticTokenVotingPluginSetupTest is Test { votingSettings, tokenSettings, mintSettings, + stdProposalMinDuration, stdProposer, emergencyProposer ); bytes - memory expected = hex"00000000000000000000000000000000000000000000000000000000000186a00000000000000000000000000000000000000000000000000000000000069780000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000e000000000000000000000000000000000000000000000000000000000000001c0000000000000000000000000000000000000000000000000000000000056789000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002e234dae75c793f67a35089c9d99245e1c58470b000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000a0000000000000000000000000000000000000000000000000000000000000000d5772617070656420546f6b656e00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000377544b00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"; + memory expected = hex"00000000000000000000000000000000000000000000000000000000000186a000000000000000000000000000000000000000000000000000000000000697800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000001e000000000000000000000000000000000000000000000000000000000000d2f00000000000000000000000000000000000000000000000000000000000056789000000000000000000000000000000000000000000000000000000023456789010000000000000000000000002e234dae75c793f67a35089c9d99245e1c58470b000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000a0000000000000000000000000000000000000000000000000000000000000000d5772617070656420546f6b656e00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000377544b00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"; assertEq(output, expected, "Incorrect encoded bytes"); } @@ -206,6 +214,7 @@ contract OptimisticTokenVotingPluginSetupTest is Test { receivers: receivers, amounts: amounts }); + stdProposalMinDuration = 6 days; stdProposer = address(0x3456); emergencyProposer = address(0x7890); @@ -214,6 +223,7 @@ contract OptimisticTokenVotingPluginSetupTest is Test { tokenSettings, // only used for GovernanceERC20 (when a token is not passed) mintSettings, + stdProposalMinDuration, stdProposer, emergencyProposer ); @@ -225,6 +235,7 @@ contract OptimisticTokenVotingPluginSetupTest is Test { OptimisticTokenVotingPluginSetup.TokenSettings memory _tokenSettings, GovernanceERC20.MintSettings memory _mintSettings, + uint64 _stdMinDuration, address _stdProposer, address _emergencyProposer ) = pluginSetup.decodeInstallationParams(_installationParams); @@ -279,7 +290,12 @@ contract OptimisticTokenVotingPluginSetupTest is Test { assertEq(_mintSettings.amounts[0], 2000, "Incorrect amounts[0]"); assertEq(_mintSettings.amounts[1], 5000, "Incorrect amounts[1]"); - // Proposers + // Proposals + assertEq( + _stdMinDuration, + stdProposalMinDuration, + "Incorrect stdMinDuration" + ); assertEq(_stdProposer, stdProposer, "Incorrect standard proposer"); assertEq( _emergencyProposer, @@ -287,7 +303,7 @@ contract OptimisticTokenVotingPluginSetupTest is Test { "Incorrect emergency proposer" ); - // Proposers + assertEq(_stdMinDuration, 6 days, "Incorrect stdMinDuration"); assertEq(stdProposer, address(0x3456), "Incorrect stdProposer"); assertEq( emergencyProposer, @@ -322,6 +338,7 @@ contract OptimisticTokenVotingPluginSetupTest is Test { receivers: receivers, amounts: amounts }); + stdProposalMinDuration = 9 days; stdProposer = address(0x6666); emergencyProposer = address(0x8888); @@ -330,6 +347,7 @@ contract OptimisticTokenVotingPluginSetupTest is Test { tokenSettings, // only used for GovernanceERC20 (when a token is not passed) mintSettings, + stdProposalMinDuration, stdProposer, emergencyProposer ); @@ -341,6 +359,7 @@ contract OptimisticTokenVotingPluginSetupTest is Test { OptimisticTokenVotingPluginSetup.TokenSettings memory _tokenSettings, GovernanceERC20.MintSettings memory _mintSettings, + uint64 _stdMinDuration, address _stdProposer, address _emergencyProposer ) = pluginSetup.decodeInstallationParams(_installationParams); @@ -403,7 +422,12 @@ contract OptimisticTokenVotingPluginSetupTest is Test { assertEq(_mintSettings.amounts[2], 3000, "Incorrect amounts[2]"); assertEq(_mintSettings.amounts[3], 4000, "Incorrect amounts[3]"); - // Proposers + // Proposal + assertEq( + _stdMinDuration, + stdProposalMinDuration, + "Incorrect stdMinDuration" + ); assertEq(_stdProposer, stdProposer, "Incorrect standard proposer"); assertEq( _emergencyProposer, @@ -411,7 +435,7 @@ contract OptimisticTokenVotingPluginSetupTest is Test { "Incorrect emergency proposer" ); - // Proposers + assertEq(_stdMinDuration, 9 days, "Incorrect stdMinDuration"); assertEq(stdProposer, address(0x6666), "Incorrect stdProposer"); assertEq( emergencyProposer, @@ -428,6 +452,7 @@ contract OptimisticTokenVotingPluginSetupTest is Test { tokenSettings, // only used for GovernanceERC20 (when a token is not passed) mintSettings, + stdProposalMinDuration, stdProposer, emergencyProposer ); @@ -542,6 +567,7 @@ contract OptimisticTokenVotingPluginSetupTest is Test { tokenSettings, // only used for GovernanceERC20 (when a token is not passed) mintSettings, + stdProposalMinDuration, stdProposer, emergencyProposer ); @@ -666,6 +692,7 @@ contract OptimisticTokenVotingPluginSetupTest is Test { tokenSettings, // only used for GovernanceERC20 (when a token is not passed) mintSettings, + stdProposalMinDuration, stdProposer, emergencyProposer ); @@ -779,6 +806,7 @@ contract OptimisticTokenVotingPluginSetupTest is Test { votingSettings, tokenSettings, mintSettings, + stdProposalMinDuration, stdProposer, emergencyProposer ); @@ -880,6 +908,7 @@ contract OptimisticTokenVotingPluginSetupTest is Test { votingSettings, tokenSettings, mintSettings, + stdProposalMinDuration, stdProposer, emergencyProposer ); @@ -991,6 +1020,7 @@ contract OptimisticTokenVotingPluginSetupTest is Test { tokenSettings, // only used for GovernanceERC20 (when a token is not passed) mintSettings, + stdProposalMinDuration, stdProposer, emergencyProposer ); @@ -1025,6 +1055,7 @@ contract OptimisticTokenVotingPluginSetupTest is Test { votingSettings, tokenSettings, mintSettings, + stdProposalMinDuration, stdProposer, emergencyProposer ); @@ -1090,6 +1121,7 @@ contract OptimisticTokenVotingPluginSetupTest is Test { votingSettings, tokenSettings, mintSettings, + stdProposalMinDuration, stdProposer, emergencyProposer ); diff --git a/test/base/AragonTest.sol b/test/base/AragonTest.sol new file mode 100644 index 0000000..234c59a --- /dev/null +++ b/test/base/AragonTest.sol @@ -0,0 +1,217 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity 0.8.17; + +import {IPluginSetup, PluginSetup} from "@aragon/osx/framework/plugin/setup/PluginSetup.sol"; +import {DAO} from "@aragon/osx/core/dao/DAO.sol"; +import {Multisig} from "../../src/Multisig.sol"; +import {OptimisticTokenVotingPlugin} from "../../src/OptimisticTokenVotingPlugin.sol"; +import {ERC20VotesMock} from "../mocks/ERC20VotesMock.sol"; +import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import {createProxyAndCall} from "../helpers.sol"; +import {RATIO_BASE} from "@aragon/osx/plugins/utils/Ratio.sol"; + +import {Test} from "forge-std/Test.sol"; + +contract AragonTest is Test { + address immutable alice = address(0xa11ce); + address immutable bob = address(0xB0B); + address immutable carol = address(0xc4601); + address immutable david = address(0xd471d); + address immutable randomWallet = vm.addr(1234567890); + + address immutable DAO_BASE = address(new DAO()); + address immutable MULTISIG_BASE = address(new Multisig()); + address immutable OPTIMISTIC_BASE = + address(new OptimisticTokenVotingPlugin()); + address immutable VOTING_TOKEN_BASE = address(new ERC20VotesMock()); + + bytes internal constant EMPTY_BYTES = ""; + + function makeDaoWithOptimisticTokenVoting( + address owner + ) + internal + returns ( + DAO dao, + OptimisticTokenVotingPlugin plugin, + ERC20VotesMock votingToken + ) + { + // Deploy a DAO with owner as root + dao = DAO( + payable( + createProxyAndCall( + address(DAO_BASE), + abi.encodeCall( + DAO.initialize, + ("", owner, address(0x0), "") + ) + ) + ) + ); + + // Deploy ERC20 token + votingToken = ERC20VotesMock( + createProxyAndCall( + address(VOTING_TOKEN_BASE), + abi.encodeCall(ERC20VotesMock.initialize, ()) + ) + ); + votingToken.mint(alice, 10 ether); + votingToken.delegate(alice); + vm.roll(block.number + 1); + + // Deploy a new plugin instance + OptimisticTokenVotingPlugin.OptimisticGovernanceSettings + memory settings = OptimisticTokenVotingPlugin + .OptimisticGovernanceSettings({ + minVetoRatio: uint32(RATIO_BASE / 10), + minDuration: 10 days, + minProposerVotingPower: 0 + }); + + plugin = OptimisticTokenVotingPlugin( + createProxyAndCall( + address(OPTIMISTIC_BASE), + abi.encodeCall( + OptimisticTokenVotingPlugin.initialize, + (dao, settings, votingToken) + ) + ) + ); + } + + /// @notice Creates a mock DAO with a plugin. + /// @param owner The address that will be set as root on the DAO. + /// @return A tuple containing the DAO and the address of the plugin. + function makeDaoWithMultisigAndOptimistic( + address owner + ) internal returns (DAO, Multisig, OptimisticTokenVotingPlugin) { + // Deploy a DAO with owner as root + DAO dao = DAO( + payable( + createProxyAndCall( + address(DAO_BASE), + abi.encodeCall( + DAO.initialize, + (EMPTY_BYTES, owner, address(0x0), "") + ) + ) + ) + ); + Multisig multisig; + OptimisticTokenVotingPlugin optimisticPlugin; + + { + // Deploy ERC20 token + ERC20VotesMock votingToken = ERC20VotesMock( + createProxyAndCall( + address(VOTING_TOKEN_BASE), + abi.encodeCall(ERC20VotesMock.initialize, ()) + ) + ); + votingToken.mint(); + + // Deploy a target contract for passed proposals to be created in + OptimisticTokenVotingPlugin.OptimisticGovernanceSettings + memory targetContractSettings = OptimisticTokenVotingPlugin + .OptimisticGovernanceSettings({ + minVetoRatio: uint32(RATIO_BASE / 10), + minDuration: 4 days, + minProposerVotingPower: 0 + }); + + optimisticPlugin = OptimisticTokenVotingPlugin( + createProxyAndCall( + address(OPTIMISTIC_BASE), + abi.encodeCall( + OptimisticTokenVotingPlugin.initialize, + (dao, targetContractSettings, votingToken) + ) + ) + ); + } + + { + // Deploy a new multisig instance + Multisig.MultisigSettings memory settings = Multisig + .MultisigSettings({ + onlyListed: true, + minApprovals: 3, + destinationMinDuration: 4 days + }); + address[] memory signers = new address[](4); + signers[0] = alice; + signers[1] = bob; + signers[2] = carol; + signers[3] = david; + + multisig = Multisig( + createProxyAndCall( + address(MULTISIG_BASE), + abi.encodeCall( + Multisig.initialize, + (dao, signers, settings) + ) + ) + ); + } + + return (dao, multisig, optimisticPlugin); + } + + /// @notice Returns the address associated with a given label. + /// @param label The label to get the address for. + /// @return addr The address associated with the label. + function account(string memory label) internal returns (address addr) { + (addr, ) = accountAndKey(label); + } + + /// @notice Returns the address and private key associated with a given label. + /// @param label The label to get the address and private key for. + /// @return addr The address associated with the label. + /// @return pk The private key associated with the label. + function accountAndKey( + string memory label + ) internal returns (address addr, uint256 pk) { + pk = uint256(keccak256(abi.encodePacked(label))); + addr = vm.addr(pk); + vm.label(addr, label); + } + + /// @notice Moves the EVM time forward by a given amount. + /// @param time The amount of seconds to advance. + function timeForward(uint256 time) internal { + vm.warp(block.timestamp + time); + } + + /// @notice Moves the EVM time back by a given amount. + /// @param time The amount of seconds to subtract. + function timeBack(uint256 time) internal { + vm.warp(block.timestamp - time); + } + + /// @notice Sets the EVM timestamp. + /// @param timestamp The timestamp in seconds. + function setTime(uint256 timestamp) internal { + vm.warp(timestamp); + } + + /// @notice Moves the EVM block number forward by a given amount. + /// @param blocks The number of blocks to advance. + function blockForward(uint256 blocks) internal { + vm.roll(block.number + blocks); + } + + /// @notice Moves the EVM block number back by a given amount. + /// @param blocks The number of blocks to subtract. + function blockBack(uint256 blocks) internal { + vm.roll(block.number - blocks); + } + + /// @notice Set the EVM block number to the given value. + /// @param blockNumber The new block number + function setBlock(uint256 blockNumber) internal { + vm.roll(blockNumber); + } +} diff --git a/test/common.sol b/test/constants.sol similarity index 93% rename from test/common.sol rename to test/constants.sol index 5318fae..461e841 100644 --- a/test/common.sol +++ b/test/constants.sol @@ -14,4 +14,3 @@ bytes32 constant ROOT_PERMISSION_ID = keccak256("ROOT_PERMISSION"); uint64 constant MAX_UINT64 = uint64(2 ** 64 - 1); address constant ADDRESS_ZERO = address(0x0); address constant NO_CONDITION = ADDRESS_ZERO; -uint32 constant ONE_WEEK = 60 * 60 * 24 * 7; diff --git a/test/helpers.sol b/test/helpers.sol new file mode 100644 index 0000000..a2f5530 --- /dev/null +++ b/test/helpers.sol @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.17; +import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; + +function createProxyAndCall( + address _logic, + bytes memory _data +) returns (address) { + return address(new ERC1967Proxy(_logic, _data)); +}