diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..3f38b02 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,42 @@ +name: test + +on: + workflow_dispatch: + push: + paths: + - 'lib/**' + - 'script/**' + - 'src/**' + - 'tests/**' + - '.github/workflows/*.yml' + +env: + FOUNDRY_PROFILE: ci + +jobs: + check: + strategy: + fail-fast: true + + name: Foundry project + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Install Foundry + uses: foundry-rs/foundry-toolchain@v1 + with: + version: nightly + + - name: Run Forge build + run: | + forge --version + forge build --sizes + id: build + + - name: Run Forge tests + run: | + forge test -vvv + id: test diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..85198aa --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +# Compiler files +cache/ +out/ + +# Ignores development broadcast logs +!/broadcast +/broadcast/*/31337/ +/broadcast/**/dry-run/ + +# Docs +docs/ + +# Dotenv file +.env diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..aae9ad6 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,12 @@ +[submodule "lib/forge-std"] + path = lib/forge-std + url = https://github.com/foundry-rs/forge-std +[submodule "lib/osx"] + path = lib/osx + url = https://github.com/aragon/osx +[submodule "lib/openzeppelin-contracts"] + path = lib/openzeppelin-contracts + url = https://github.com/openzeppelin/openzeppelin-contracts +[submodule "lib/openzeppelin-contracts-upgradeable"] + path = lib/openzeppelin-contracts-upgradeable + url = https://github.com/OpenZeppelin/openzeppelin-contracts-upgradeable diff --git a/README.md b/README.md new file mode 100644 index 0000000..2a37c94 --- /dev/null +++ b/README.md @@ -0,0 +1,269 @@ +# Taiko DAO contracts + +This OSx plugin is an instance of the Optimistic Dual Governance model, where selected groups or members can submit proposals and token holders can veto them. Proposals that have not been vetoed after a period of time can be eventually executed by anyone. + +OSx plugins are designed to encapsulate custom behaviour and permissions so that they can be installed on any Aragon DAO. + +[Learn more about Aragon OSx](#protocol-overview). + + +## Optimistic Token Voting plugin + +This plugin is an adapted version of Aragon's [TokenVoting plugin](https://github.com/aragon/osx/blob/develop/packages/contracts/src/plugins/governance/majority-voting/token/TokenVoting.sol). + +Only addresses that have been granted `PROPOSER_PERMISSION_ID` on the plugin can create proposals. These adresses could belong to another plugin, an external multisig or even a plain wallet. + +Proposals can only be executed when a certain amount of vetoes hasn't emerged after a given period of time. + +The governance settings need to be defined when the plugin is installed but the DAO can update them at any time. + +#### Methods + +- `function initialize(IDAO dao, governanceSettings, IVotesUpgradeable token)` +- `function createProposal(bytes metadata, IDAO.Action[] actions, uint256 allowFailureMap, uint64 startDate, uint64 endDate) returns (uint256 proposalId)` +- `function veto(uint256 proposalId)` +- `function execute(uint256 proposalId)` +- `function updateOptimisticGovernanceSettings(OptimisticGovernanceSettings governanceSettings)` + +Inherited: + +- `function upgradeTo(address newImplementation)` +- `function upgradeToAndCall(address newImplementation, bytes data)` + +#### Getters + +- `function getVotingToken() returns (IVotesUpgradeable)` +- `function totalVotingPower(uint256 blockNumber) returns (uint256)` +- `function isMember(address account) returns (bool)` +- `function hasVetoed(uint256 proposalId, address voter) returns (bool)` +- `function canVeto(uint256 proposalId, address voter) returns (bool)` +- `function canExecute(uint256 proposalId) returns (bool)` +- `function isMinVetoRatioReached(uint256 proposalId) returns (bool)` +- `function minVetoRatio() returns (uint32)` +- `function minDuration() returns (uint64)` +- `function minProposerVotingPower() returns (uint256)` +- `function getProposal(uint256 proposalId) returns (bool open, bool executed, ProposalParameters memory parameters, uint256 vetoTally, IDAO.Action[] memory actions, uint256 allowFailureMap)` +- `function supportsInterface(bytes4 interfaceId) returns (bool)` + +Inherited: + +- `function implementation() returns (address)` + +#### Events + +- `event VetoCast(uint256 proposalId, address voter, uint256 votingPower)` +- `event OptimisticGovernanceSettingsUpdated(uint32 minVetoRatio, uint64 minDuration, uint256 minProposerVotingPower)` + +Inherited: + +- `event ProposalCreated(uint256 proposalId, address creator, uint64 startDate, uint64 endDate, bytes metadata, IDAO.Action[] actions, uint256 allowFailureMap)` +- `event ProposalExecuted(uint256 proposalId)` + +#### Permissions + +- Only proposers can create proposals on the plugin +- The plugin can execute actions on the DAO +- The DAO can update the plugin settings +- The DAO can upgrade the plugin + +## Plugin Setup contract + +Getting a plugin installed on a DAO requires two steps: + +1. An unprivileged step to prepare the plugin and request any privileged changes +2. An approval step after which, the DAO executes an action that applies the requested installation, upgrade or uninstallation + +This requires that there is a contract that acts as the install script. It receives the parameters that the deployer wants the new plugin to have, it deploys the new instances and requests the permissions that the new plugin will need to be fully operational. + +As soon as the installation is applied by the DAO, the plugin can be considered as installed. + +[Learn more](https://devs.aragon.org/docs/osx/how-to-guides/plugin-development/upgradeable-plugin/setup) + +### Installing plugins when deploying the DAO + +This is taken care by the `DAOFactory`. The DAO creator calls `daoFactory.createDao()`: + +- The call contains: + - The DAO settings + - An array with the details and the settings of the desired plugins +- The method will deploy a new DAO and set itself as ROOT +- It will then call `prepareInstallation()` on all plugins and `applyInstallation()` right away +- It will finally drop `ROOT_PERMISSION` on itself + +[See a JS example of installing plugins during a DAO's deployment](https://devs.aragon.org/docs/sdk/examples/client/create-dao#create-a-dao) + +### Installing plugins afterwards + +Plugin changes need a proposal to be passed when the DAO already exists. + +1. Calling `pluginSetup.prepareInstallation()` + - A new plugin instance is deployed with the desired settings + - The call requests a set of permissions to be applied by the DAO +2. Editors pass a proposal to make the DAO call `applyInstallation()` on the [PluginSetupProcessor](https://devs.aragon.org/docs/osx/how-it-works/framework/plugin-management/plugin-setup/) + - This applies the requested permissions and the plugin becomes installed + +See `OptimisticTokenVotingPluginSetup`. + +[Learn more about plugin setup's](https://devs.aragon.org/docs/osx/how-it-works/framework/plugin-management/plugin-setup/) and [preparing installations](https://devs.aragon.org/docs/sdk/examples/client/prepare-installation). + +## OSx protocol overview + +OSx [DAO's](https://github.com/aragon/osx/blob/develop/packages/contracts/src/core/dao/DAO.sol) are designed to hold all the assets and rights by themselves, while plugins are custom, opt-in pieces of logic that can perform any type of actions governed by the DAO's permission database. + +The DAO contract can be deployed by using Aragon's `DAOFactory` contract. This will deploy a new DAO with the desired plugins and settings. + +### How permissions work + +An Aragon DAO is a set of permissions that are used to restrict who can do what and where. + +A permission looks like: + +- An address `who` holds `MY_PERMISSION_ID` on a target contract `where` + +Brand new DAO's are deployed with a `ROOT_PERMISSION` assigned to its creator, but the DAO will typically deployed by the DAO factory, which will install all the requested plugins and drop the ROOT permission after the set up is done. + +Managing permissions is made via two functions that are called on the DAO: + +```solidity +function grant(address _where, address _who, bytes32 _permissionId); + +function revoke(address _where, address _who, bytes32 _permissionId); +``` + +### Permission Conditions + +For the cases where an unrestricted permission is not derisable, a [Permission Condition](https://devs.aragon.org/docs/osx/how-it-works/core/permissions/conditions) can be used. + +Conditional permissions look like this: + +- An address `who` holds `MY_PERMISSION_ID` on a target contract `where`, only `when` the condition contract approves it + +Conditional permissions are granted like this: + +```solidity +function grantWithCondition( + address _where, + address _who, + bytes32 _permissionId, + IPermissionCondition _condition +); +``` + +See the condition contract boilerplate. It provides the plumbing to easily restrict what the different multisig plugins can propose on the OptimisticVotingPlugin. + +[Learn more about OSx permissions](https://devs.aragon.org/docs/osx/how-it-works/core/permissions/) + +### Permissions being used + +Below are all the permissions that a [PluginSetup](#plugin-setup-contracts) contract may want to request: + +- `EXECUTE_PERMISSION` is required to make the DAO `execute` a set of actions + - Only governance plugins should have this permission +- `ROOT_PERMISSION` is required to make the DAO `grant` or `revoke` permissions + - The DAO needs to be ROOT on itself (it is by default) + - Nobody else should be ROOT on the DAO +- `UPGRADE_PLUGIN_PERMISSION` is required for an address to be able to upgrade a plugin to a newer version published by the developer + - Typically called by the DAO via proposal + - Optionally granted to an additional address for convenience +- `PROPOSER_PERMISSION_ID` is required to be able to create optimistic proposals on the governance plugin + +Other DAO specific permissions: + +- `UPGRADE_DAO_PERMISSION` +- `SET_METADATA_PERMISSION` +- `SET_TRUSTED_FORWARDER_PERMISSION` +- `SET_SIGNATURE_VALIDATOR_PERMISSION` +- `REGISTER_STANDARD_CALLBACK_PERMISSION` + +### Encoding and decoding actions + +Making calls to the DAO is straightforward, however making execute arbitrary actions requires them to be encoded, stored on chain and be approved before they can be executed. + +To this end, the DAO has a struct called `Action { to, value, data }`, which will make the DAO call the `to` address, with `value` ether and call the given calldata (if any). Such calldata is an ABI encoded array of bytes with the function to call and the parameters it needs. + +### Deploying a DAO + +The recommended way to create a DAO is by using `@aragon/sdk-client`. It uses the `DAOFactory` under the hood and it reduces the amount of low level interactions with the protocol. + +[See an example](https://devs.aragon.org/docs/sdk/examples/client/create-dao) + +In the example, the code is making use of the existing JS client for [Aragon's Token Voting plugin](https://github.com/aragon/sdk/tree/develop/modules/client/src/tokenVoting). They encapsulate all the Typechain and Subgraph calls and provide a high level library. + +#### Installation parameters + +In order for the PluginSetup contract to receive an arbitrary set of parameters, `prepareInstallation(address dao, bytes memory installationParameters)` needs to receive an ABI encoded byte array as the second argument. + +To this end, the plugin provides a helper called `encodeInstallationParams()`, which receives the specific parameters for this plugin and returns a standard `bytes memory` that can later be passed around and decoded. + +JS clients also need to be able to handle data related to installations and uninstallations. To this end, every contract has a build metadata file containing the ABI of the parameters that need to be passed. +- The format of these settings is defined in the `src/metadata/*-build.metadata.json` file. +- See `OptimisticTokenVotingPluginSetup::prepareInstallation()` as well. + +The PluginSetup's `prepareInstallation()` will typically create a new instance of the plugin and call the `initialize()` method, which acts as the constructor. This method will also be passed the DAO's address, in adition to its respective `bytes memory data` parameter, with all the initial settings, again ABI-encoded. The parameters for the plugin `initialize` function don't have to be necessarily the same as the ones for the PluginSetup. + +### DO's and DONT's + +- Never grant `ROOT_PERMISSION` unless you are just trying things out +- Never uninstall all plugins, as this would brick your DAO +- Ensure that there is at least always one plugin with `EXECUTE_PERMISSION` on the DAO +- Ensure that the DAO is ROOT on itself +- Use the `_gap[]` variable for upgradeable plugins, as a way to reserve storage slots for future plugin implementations + - Decrement the `_gap` number for every new variable you add in the future + +### Plugin upgradeability + +By default, only the DAO can upgrade plugins to newer versions. This requires passing a proposal. + +[Learn more about plugin upgrades](https://devs.aragon.org/docs/osx/how-to-guides/plugin-development/upgradeable-plugin/updating-versions) + +## Development + +### Build + +```shell +$ forge build +``` + +### Test + +```shell +$ forge test +``` + +### Format + +```shell +$ forge fmt +``` + +### Gas Snapshots + +```shell +$ forge snapshot +``` + +### Anvil + +```shell +$ anvil +``` + +### Deploy + +```shell +$ forge script script/Example.s.sol:ExampleScript --rpc-url --private-key +``` + +### Cast + +```shell +$ cast +``` + +### Help + +```shell +$ forge --help +$ anvil --help +$ cast --help +``` diff --git a/foundry.toml b/foundry.toml new file mode 100644 index 0000000..e0eb508 --- /dev/null +++ b/foundry.toml @@ -0,0 +1,11 @@ +[profile.default] +src = "src" +out = "out" +libs = ["lib"] + +# See more config options https://github.com/foundry-rs/foundry/blob/master/crates/config/README.md#all-options +remappings = [ + '@openzeppelin/contracts/=lib/openzeppelin-contracts/contracts/', + '@openzeppelin/contracts-upgradeable/=lib/openzeppelin-contracts-upgradeable/contracts/', + '@aragon/osx/=lib/osx/packages/contracts/src/' +] diff --git a/script/OptimisticTokenVotingPlugin.s.sol b/script/OptimisticTokenVotingPlugin.s.sol new file mode 100644 index 0000000..b816a3e --- /dev/null +++ b/script/OptimisticTokenVotingPlugin.s.sol @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +import {Script, console2} from "forge-std/Script.sol"; + +contract OptimisticTokenVotingPluginScript is Script { + function setUp() public {} + + function run() public { + vm.broadcast(); + } +} diff --git a/src/IOptimisticTokenVoting.sol b/src/IOptimisticTokenVoting.sol new file mode 100644 index 0000000..01fdd4e --- /dev/null +++ b/src/IOptimisticTokenVoting.sol @@ -0,0 +1,93 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +pragma solidity ^0.8.17; + +import {IVotesUpgradeable} from "@openzeppelin/contracts-upgradeable/governance/utils/IVotesUpgradeable.sol"; +import {IDAO} from "@aragon/osx/core/dao/IDAO.sol"; + +/// @title IOptimisticTokenVoting +/// @author Aragon Association - 2022-2023 +/// @notice The interface of an optimistic governance plugin. +interface IOptimisticTokenVoting { + /// @notice getter function for the voting token. + /// @dev public function also useful for registering interfaceId and for distinguishing from majority voting interface. + /// @return The token used for voting. + function getVotingToken() external view returns (IVotesUpgradeable); + + /// @notice Returns the total voting power checkpointed for a specific block number. + /// @param _blockNumber The block number. + /// @return The total voting power. + function totalVotingPower( + uint256 _blockNumber + ) external view returns (uint256); + + /// @notice Returns the veto ratio parameter stored in the optimistic governance settings. + /// @return The veto ratio parameter. + function minVetoRatio() external view returns (uint32); + + /// @notice Returns the minimum duration parameter stored in the vetoing settings. + /// @return The minimum duration parameter. + function minDuration() external view returns (uint64); + + /// @notice Returns the minimum vetoing power required to create a proposal stored in the vetoing settings. + /// @return The minimum vetoing power required to create a proposal. + function minProposerVotingPower() external view returns (uint256); + + /// @notice Creates a new optimistic proposal. + /// @param _metadata The metadata of the proposal. + /// @param _actions The actions that will be executed after the proposal passes. + /// @param _allowFailureMap Allows proposal to succeed even if an action reverts. Uses bitmap representation. If the bit at index `x` is 1, the tx succeeds even if the action at `x` failed. Passing 0 will be treated as atomic execution. + /// @param _startDate The start date of the proposal vote. If 0, the current timestamp is used and the vote starts immediately. + /// @param _endDate The end date of the proposal vote. If 0, `_startDate + minDuration` is used. + /// @return proposalId The ID of the proposal. + function createProposal( + bytes calldata _metadata, + IDAO.Action[] calldata _actions, + uint256 _allowFailureMap, + uint64 _startDate, + uint64 _endDate + ) external returns (uint256 proposalId); + + /// @notice Checks if an account can participate on an optimistic proposal. This can be because the proposal + /// - has not started, + /// - has ended, + /// - was executed, or + /// - the voter doesn't have voting power. + /// @param _proposalId The proposal Id. + /// @param _account The account address to be checked. + /// @return Returns true if the account is allowed to veto. + /// @dev The function assumes that the queried proposal exists. + function canVeto( + uint256 _proposalId, + address _account + ) external view returns (bool); + + /// @notice Registers the veto for the given proposal. + /// @param _proposalId The ID of the proposal. + function veto(uint256 _proposalId) external; + + /// @notice Returns whether the account has voted for the proposal. Note, that this does not check if the account has vetoing power. + /// @param _proposalId The ID of the proposal. + /// @param _account The account address to be checked. + /// @return The whether the given account has vetoed the given proposal. + function hasVetoed( + uint256 _proposalId, + address _account + ) external view returns (bool); + + /// @notice Checks if the total votes against a proposal is greater than the veto threshold. + /// @param _proposalId The ID of the proposal. + /// @return Returns `true` if the total veto power against the proposal is greater or equal than the threshold and `false` otherwise. + function isMinVetoRatioReached( + uint256 _proposalId + ) 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 Executes a proposal. + /// @param _proposalId The ID of the proposal to be executed. + function execute(uint256 _proposalId) external; +} diff --git a/src/OptimisticTokenVotingPlugin.sol b/src/OptimisticTokenVotingPlugin.sol new file mode 100644 index 0000000..3b3d5f7 --- /dev/null +++ b/src/OptimisticTokenVotingPlugin.sol @@ -0,0 +1,558 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +pragma solidity ^0.8.17; + +import {ERC165Upgradeable} from "@openzeppelin/contracts-upgradeable/utils/introspection/ERC165Upgradeable.sol"; +import {IERC20Upgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC20/IERC20Upgradeable.sol"; +import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; +import {SafeCastUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/math/SafeCastUpgradeable.sol"; +import {IVotesUpgradeable} from "@openzeppelin/contracts-upgradeable/governance/utils/IVotesUpgradeable.sol"; +import {IMembership} from "@aragon/osx/core/plugin/membership/IMembership.sol"; +import {IOptimisticTokenVoting} from "./IOptimisticTokenVoting.sol"; + +import {ProposalUpgradeable} from "@aragon/osx/core/plugin/proposal/ProposalUpgradeable.sol"; +import {PluginUUPSUpgradeable} from "@aragon/osx/core/plugin/PluginUUPSUpgradeable.sol"; +import {RATIO_BASE, _applyRatioCeiled, RatioOutOfBounds} from "@aragon/osx/plugins/utils/Ratio.sol"; +import {IDAO} from "@aragon/osx/core/dao/IDAO.sol"; + +/// @title OptimisticTokenVotingPlugin +/// @author Aragon Association - 2023 +/// @notice The abstract implementation of optimistic majority plugins. +/// +/// @dev This contract implements the `IOptimisticTokenVoting` interface. +contract OptimisticTokenVotingPlugin is + IOptimisticTokenVoting, + IMembership, + Initializable, + ERC165Upgradeable, + PluginUUPSUpgradeable, + ProposalUpgradeable +{ + using SafeCastUpgradeable for uint256; + + /// @notice A container for the optimistic majority settings that will be applied as parameters on proposal creation. + /// @param minVetoRatio The support threshold value. Its value has to be in the interval [0, 10^6] defined by `RATIO_BASE = 10**6`. + /// @param minDuration The minimum duration of the proposal vote in seconds. + /// @param minProposerVotingPower The minimum vetoing power required to create a proposal. + struct OptimisticGovernanceSettings { + uint32 minVetoRatio; + uint64 minDuration; + uint256 minProposerVotingPower; + } + + /// @notice A container for proposal-related information. + /// @param executed Whether the proposal is executed or not. + /// @param parameters The proposal parameters at the time of the proposal creation. + /// @param vetoTally The amount of voting power used to veto the proposal. + /// @param vetoVoters The voters who have vetoed. + /// @param actions The actions to be executed when the proposal passes. + /// @param allowFailureMap A bitmap allowing the proposal to succeed, even if individual actions might revert. If the bit at index `i` is 1, the proposal succeeds even if the `i`th action reverts. A failure map value of 0 requires every action to not revert. + struct Proposal { + bool executed; + ProposalParameters parameters; + uint256 vetoTally; + mapping(address => bool) vetoVoters; + IDAO.Action[] actions; + uint256 allowFailureMap; + } + + /// @notice A container for the proposal parameters at the time of proposal creation. + /// @param startDate The start date of the proposal vote. + /// @param endDate The end date of the proposal vote. + /// @param snapshotBlock The number of the block prior to the proposal creation. + /// @param minVetoVotingPower The minimum voting power needed to defeat the proposal. + struct ProposalParameters { + uint64 startDate; + uint64 endDate; + uint64 snapshotBlock; + uint256 minVetoVotingPower; + } + + /// @notice The ID of the permission required to create a proposal. + bytes32 public constant PROPOSER_PERMISSION_ID = + keccak256("PROPOSER_PERMISSION"); + + /// @notice The ID of the permission required to call the `updateOptimisticGovernanceSettings` function. + bytes32 + public constant UPDATE_OPTIMISTIC_GOVERNANCE_SETTINGS_PERMISSION_ID = + keccak256("UPDATE_OPTIMISTIC_GOVERNANCE_SETTINGS_PERMISSION"); + + /// @notice The [ERC-165](https://eips.ethereum.org/EIPS/eip-165) interface ID of the contract. + bytes4 public constant OPTIMISTIC_GOVERNANCE_INTERFACE_ID = + this.initialize.selector ^ + this.getProposal.selector ^ + 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; + + /// @notice The struct storing the governance settings. + OptimisticGovernanceSettings private governanceSettings; + + /// @notice A mapping between proposal IDs and proposal information. + mapping(uint256 => Proposal) internal proposals; + + /// @notice Emitted when the vetoing settings are updated. + /// @param minVetoRatio The support threshold value. + /// @param minDuration The minimum duration of the proposal vote in seconds. + /// @param minProposerVotingPower The minimum vetoing power required to create a proposal. + event OptimisticGovernanceSettingsUpdated( + uint32 minVetoRatio, + uint64 minDuration, + uint256 minProposerVotingPower + ); + + /// @notice Emitted when a veto is cast by a voter. + /// @param proposalId The ID of the proposal. + /// @param voter The voter casting the veto. + /// @param votingPower The voting power behind this veto. + event VetoCast( + uint256 indexed proposalId, + address indexed voter, + uint256 votingPower + ); + + /// @notice Thrown if a date is out of bounds. + /// @param limit The limit value. + /// @param actual The actual value. + error DateOutOfBounds(uint64 limit, uint64 actual); + + /// @notice Thrown if the minimum duration value is out of bounds (less than four days or greater than 1 year). + /// @param limit The limit value. + /// @param actual The actual value. + error MinDurationOutOfBounds(uint64 limit, uint64 actual); + + /// @notice Thrown if the minimum voting power for creating a proposal is out of bounds (more than the token supply). + /// @param limit The limit value. + /// @param actual The actual value. + error MinProposerVotingPowerOutOfBounds(uint256 limit, uint256 actual); + + /// @notice Thrown when a sender is not allowed to create a proposal. + /// @param sender The sender address. + error ProposalCreationForbidden(address sender); + + /// @notice Thrown if an account is not allowed to cast a veto. This can be because the challenge period + /// - has not started, + /// - has ended, + /// - was executed, or + /// - the account doesn't have vetoing powers. + /// @param proposalId The ID of the proposal. + /// @param account The address of the _account. + error ProposalVetoingForbidden(uint256 proposalId, address account); + + /// @notice Thrown if the proposal execution is forbidden. + /// @param proposalId The ID of the proposal. + error ProposalExecutionForbidden(uint256 proposalId); + + /// @notice Thrown if the voting power is zero + error NoVotingPower(); + + /// @notice Initializes the component to be used by inheriting contracts. + /// @dev This method is required to support [ERC-1822](https://eips.ethereum.org/EIPS/eip-1822). + /// @param _dao The IDAO interface of the associated DAO. + /// @param _governanceSettings The vetoing settings. + /// @param _token The [ERC-20](https://eips.ethereum.org/EIPS/eip-20) token used for voting. + function initialize( + IDAO _dao, + OptimisticGovernanceSettings calldata _governanceSettings, + IVotesUpgradeable _token + ) external initializer { + __PluginUUPSUpgradeable_init(_dao); + + votingToken = _token; + + _updateOptimisticGovernanceSettings(_governanceSettings); + emit MembershipContractAnnounced({definingContract: address(_token)}); + } + + /// @notice Checks if this or the parent contract supports an interface by its ID. + /// @param _interfaceId The ID of the interface. + /// @return Returns `true` if the interface is supported. + function supportsInterface( + bytes4 _interfaceId + ) + public + view + virtual + override(ERC165Upgradeable, PluginUUPSUpgradeable, ProposalUpgradeable) + returns (bool) + { + return + _interfaceId == OPTIMISTIC_GOVERNANCE_INTERFACE_ID || + _interfaceId == type(IOptimisticTokenVoting).interfaceId || + _interfaceId == type(IMembership).interfaceId || + super.supportsInterface(_interfaceId); + } + + /// @inheritdoc IOptimisticTokenVoting + function getVotingToken() public view returns (IVotesUpgradeable) { + return votingToken; + } + + /// @inheritdoc IOptimisticTokenVoting + function totalVotingPower( + uint256 _blockNumber + ) public view returns (uint256) { + return votingToken.getPastTotalSupply(_blockNumber); + } + + /// @inheritdoc IMembership + function isMember(address _account) external view returns (bool) { + // A member must own at least one token or have at least one token delegated to her/him. + return + votingToken.getVotes(_account) > 0 || + IERC20Upgradeable(address(votingToken)).balanceOf(_account) > 0; + } + + /// @inheritdoc IOptimisticTokenVoting + function hasVetoed( + uint256 _proposalId, + address _voter + ) public view returns (bool) { + return proposals[_proposalId].vetoVoters[_voter]; + } + + /// @inheritdoc IOptimisticTokenVoting + function canVeto( + uint256 _proposalId, + address _voter + ) public view virtual returns (bool) { + Proposal storage proposal_ = proposals[_proposalId]; + + // The proposal vote hasn't started or has already ended. + if (!_isProposalOpen(proposal_)) { + return false; + } + + // The voter already vetoed. + if (proposal_.vetoVoters[_voter]) { + return false; + } + + // The voter has no voting power. + if ( + votingToken.getPastVotes( + _voter, + proposal_.parameters.snapshotBlock + ) == 0 + ) { + return false; + } + + return true; + } + + /// @inheritdoc IOptimisticTokenVoting + function canExecute( + uint256 _proposalId + ) public view virtual returns (bool) { + Proposal storage proposal_ = proposals[_proposalId]; + + // Verify that the vote has not been executed already. + if (proposal_.executed) { + return false; + } + // Check that the proposal vetoing time frame already expired + else if (!_isProposalEnded(proposal_)) { + return false; + } + // Check that not enough voters have vetoed the proposal + else if (isMinVetoRatioReached(_proposalId)) { + return false; + } + + return true; + } + + /// @inheritdoc IOptimisticTokenVoting + function isMinVetoRatioReached( + uint256 _proposalId + ) public view virtual returns (bool) { + Proposal storage proposal_ = proposals[_proposalId]; + + return proposal_.vetoTally >= proposal_.parameters.minVetoVotingPower; + } + + /// @inheritdoc IOptimisticTokenVoting + function minVetoRatio() public view virtual returns (uint32) { + return governanceSettings.minVetoRatio; + } + + /// @inheritdoc IOptimisticTokenVoting + function minDuration() public view virtual returns (uint64) { + return governanceSettings.minDuration; + } + + /// @inheritdoc IOptimisticTokenVoting + function minProposerVotingPower() public view virtual returns (uint256) { + return governanceSettings.minProposerVotingPower; + } + + /// @notice Returns all information for a proposal vote by its ID. + /// @param _proposalId The ID of the proposal. + /// @return open Whether the proposal is open or not. + /// @return executed Whether the proposal is executed or not. + /// @return parameters The parameters of the proposal vote. + /// @return vetoTally The current voting power used to veto the proposal. + /// @return actions The actions to be executed in the associated DAO after the proposal has passed. + /// @return allowFailureMap The bit map representations of which actions are allowed to revert so tx still succeeds. + function getProposal( + uint256 _proposalId + ) + public + view + virtual + returns ( + bool open, + bool executed, + ProposalParameters memory parameters, + uint256 vetoTally, + IDAO.Action[] memory actions, + uint256 allowFailureMap + ) + { + Proposal storage proposal_ = proposals[_proposalId]; + + open = _isProposalOpen(proposal_); + executed = proposal_.executed; + parameters = proposal_.parameters; + vetoTally = proposal_.vetoTally; + actions = proposal_.actions; + allowFailureMap = proposal_.allowFailureMap; + } + + /// @inheritdoc IOptimisticTokenVoting + function createProposal( + bytes calldata _metadata, + IDAO.Action[] calldata _actions, + uint256 _allowFailureMap, + uint64 _startDate, + uint64 _endDate + ) external auth(PROPOSER_PERMISSION_ID) returns (uint256 proposalId) { + // Check that either `_msgSender` owns enough tokens or has enough voting power from being a delegatee. + { + uint256 minProposerVotingPower_ = minProposerVotingPower(); + + if (minProposerVotingPower_ != 0) { + // Because of the checks in `OptimisticTokenVotingSetup`, we can assume that `votingToken` is an [ERC-20](https://eips.ethereum.org/EIPS/eip-20) token. + if ( + votingToken.getVotes(_msgSender()) < + minProposerVotingPower_ && + IERC20Upgradeable(address(votingToken)).balanceOf( + _msgSender() + ) < + minProposerVotingPower_ + ) { + revert ProposalCreationForbidden(_msgSender()); + } + } + } + + uint256 snapshotBlock; + unchecked { + snapshotBlock = block.number - 1; // The snapshot block must be mined already to protect the transaction against backrunning transactions causing census changes. + } + + uint256 totalVotingPower_ = totalVotingPower(snapshotBlock); + + if (totalVotingPower_ == 0) { + revert NoVotingPower(); + } + + (_startDate, _endDate) = _validateProposalDates(_startDate, _endDate); + + proposalId = _createProposal({ + _creator: _msgSender(), + _metadata: _metadata, + _startDate: _startDate, + _endDate: _endDate, + _actions: _actions, + _allowFailureMap: _allowFailureMap + }); + + // Store proposal related information + Proposal storage proposal_ = proposals[proposalId]; + + proposal_.parameters.startDate = _startDate; + proposal_.parameters.endDate = _endDate; + proposal_.parameters.snapshotBlock = snapshotBlock.toUint64(); + proposal_.parameters.minVetoVotingPower = _applyRatioCeiled( + totalVotingPower_, + minVetoRatio() + ); + + // Save gas + if (_allowFailureMap != 0) { + proposal_.allowFailureMap = _allowFailureMap; + } + + for (uint256 i; i < _actions.length; ) { + proposal_.actions.push(_actions[i]); + unchecked { + ++i; + } + } + } + + /// @inheritdoc IOptimisticTokenVoting + function veto(uint256 _proposalId) public virtual { + address _voter = _msgSender(); + + if (!canVeto(_proposalId, _voter)) { + revert ProposalVetoingForbidden({ + proposalId: _proposalId, + account: _voter + }); + } + + Proposal storage proposal_ = proposals[_proposalId]; + + // This could re-enter, though we can assume the governance token is not malicious + uint256 votingPower = votingToken.getPastVotes( + _voter, + proposal_.parameters.snapshotBlock + ); + + // Not checking if the voter already voted, since canVeto() above already did + + // Write the updated tally. + proposal_.vetoTally += votingPower; + proposal_.vetoVoters[_voter] = true; + + emit VetoCast({ + proposalId: _proposalId, + voter: _voter, + votingPower: votingPower + }); + } + + /// @inheritdoc IOptimisticTokenVoting + function execute(uint256 _proposalId) public virtual { + if (!canExecute(_proposalId)) { + revert ProposalExecutionForbidden(_proposalId); + } + + proposals[_proposalId].executed = true; + + _executeProposal( + dao(), + _proposalId, + proposals[_proposalId].actions, + proposals[_proposalId].allowFailureMap + ); + } + + /// @notice Updates the governance settings. + /// @param _governanceSettings The new governance settings. + function updateOptimisticGovernanceSettings( + OptimisticGovernanceSettings calldata _governanceSettings + ) public virtual auth(UPDATE_OPTIMISTIC_GOVERNANCE_SETTINGS_PERMISSION_ID) { + _updateOptimisticGovernanceSettings(_governanceSettings); + } + + /// @notice Internal implementation + function _updateOptimisticGovernanceSettings( + OptimisticGovernanceSettings calldata _governanceSettings + ) internal { + // Require the minimum veto ratio value to be in the interval [0, 10^6], because `>=` comparision is used. + if (_governanceSettings.minVetoRatio == 0) { + revert RatioOutOfBounds({ + limit: 1, + actual: _governanceSettings.minVetoRatio + }); + } else if (_governanceSettings.minVetoRatio > RATIO_BASE) { + revert RatioOutOfBounds({ + limit: RATIO_BASE, + actual: _governanceSettings.minVetoRatio + }); + } + + if (_governanceSettings.minDuration < 4 days) { + revert MinDurationOutOfBounds({ + limit: 4 days, + actual: _governanceSettings.minDuration + }); + } else if (_governanceSettings.minDuration > 365 days) { + revert MinDurationOutOfBounds({ + limit: 365 days, + actual: _governanceSettings.minDuration + }); + } + + governanceSettings = _governanceSettings; + + emit OptimisticGovernanceSettingsUpdated({ + minVetoRatio: _governanceSettings.minVetoRatio, + minDuration: _governanceSettings.minDuration, + minProposerVotingPower: _governanceSettings.minProposerVotingPower + }); + } + + /// @notice Internal function to check if a proposal vote is open. + /// @param proposal_ The proposal struct. + /// @return True if the proposal vote is open, false otherwise. + function _isProposalOpen( + Proposal storage proposal_ + ) internal view virtual returns (bool) { + uint64 currentTime = block.timestamp.toUint64(); + + return + proposal_.parameters.startDate <= currentTime && + currentTime < proposal_.parameters.endDate && + !proposal_.executed; + } + + /// @notice Internal function to check if a proposal already ended. + /// @param proposal_ The proposal struct. + /// @return True if the end date of the proposal is already in the past, false otherwise. + function _isProposalEnded( + Proposal storage proposal_ + ) internal view virtual returns (bool) { + uint64 currentTime = block.timestamp.toUint64(); + + return currentTime >= proposal_.parameters.endDate; + } + + /// @notice Validates and returns the proposal vote dates. + /// @param _start The start date of the proposal vote. If 0, the current timestamp is used and the vote starts immediately. + /// @param _end The end date of the proposal vote. If 0, `_start + minDuration` is used. + /// @return startDate The validated start date of the proposal vote. + /// @return endDate The validated end date of the proposal vote. + function _validateProposalDates( + uint64 _start, + uint64 _end + ) internal view virtual returns (uint64 startDate, uint64 endDate) { + uint64 currentTimestamp = block.timestamp.toUint64(); + + if (_start == 0) { + startDate = currentTimestamp; + } else { + startDate = _start; + + if (startDate < currentTimestamp) { + revert DateOutOfBounds({ + limit: currentTimestamp, + actual: startDate + }); + } + } + + uint64 earliestEndDate = startDate + governanceSettings.minDuration; // Since `minDuration` will be less than 1 year, `startDate + minDuration` can only overflow if the `startDate` is after `type(uint64).max - minDuration`. In this case, the proposal creation will revert and another date can be picked. + + if (_end == 0) { + endDate = earliestEndDate; + } else { + endDate = _end; + + if (endDate < earliestEndDate) { + revert DateOutOfBounds({ + limit: earliestEndDate, + actual: endDate + }); + } + } + } + + /// @notice This empty reserved space is put in place to allow future versions to add new variables without shifting down storage in the inheritance chain (see [OpenZeppelin's guide about storage gaps](https://docs.openzeppelin.com/contracts/4.x/upgradeable#storage_gaps)). + uint256[50] private __gap; +} diff --git a/src/OptimisticTokenVotingPluginSetup.sol b/src/OptimisticTokenVotingPluginSetup.sol new file mode 100644 index 0000000..07fd6a3 --- /dev/null +++ b/src/OptimisticTokenVotingPluginSetup.sol @@ -0,0 +1,374 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +pragma solidity ^0.8.17; + +import {Clones} from "@openzeppelin/contracts/proxy/Clones.sol"; +import {Address} from "@openzeppelin/contracts/utils/Address.sol"; +import {ERC165Checker} from "@openzeppelin/contracts/utils/introspection/ERC165Checker.sol"; +import {IERC20Upgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC20/IERC20Upgradeable.sol"; +import {IVotesUpgradeable} from "@openzeppelin/contracts-upgradeable/governance/utils/IVotesUpgradeable.sol"; + +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 {GovernanceERC20} from "@aragon/osx/token/ERC20/governance/GovernanceERC20.sol"; +import {GovernanceWrappedERC20} from "@aragon/osx/token/ERC20/governance/GovernanceWrappedERC20.sol"; +import {IGovernanceWrappedERC20} from "@aragon/osx/token/ERC20/governance/IGovernanceWrappedERC20.sol"; +import {OptimisticTokenVotingPlugin} from "./OptimisticTokenVotingPlugin.sol"; + +/// @title OptimisticTokenVotingPluginSetup +/// @author Aragon Association - 2022-2023 +/// @notice The setup contract of the `OptimisticTokenVoting` plugin. +/// @custom:security-contact sirt@aragon.org +contract OptimisticTokenVotingPluginSetup is PluginSetup { + using Address for address; + using Clones for address; + using ERC165Checker for address; + + /// @notice The address of the `OptimisticTokenVotingPlugin` base contract. + OptimisticTokenVotingPlugin + private immutable optimisticTokenVotingPluginBase; + + /// @notice The address of the `GovernanceERC20` base contract. + address public immutable governanceERC20Base; + + /// @notice The address of the `GovernanceWrappedERC20` base contract. + address public immutable governanceWrappedERC20Base; + + /// @notice The token settings struct. + /// @param addr The token address. If this is `address(0)`, a new `GovernanceERC20` token is deployed. If not, the existing token is wrapped as an `GovernanceWrappedERC20`. + /// @param name The token name. This parameter is only relevant if the token address is `address(0)`. + /// @param symbol The token symbol. This parameter is only relevant if the token address is `address(0)`. + struct TokenSettings { + address addr; + string name; + string symbol; + } + + /// @notice Thrown if token address is passed which is not a token. + /// @param token The token address + error TokenNotContract(address token); + + /// @notice Thrown if token address is not ERC20. + /// @param token The token address + error TokenNotERC20(address token); + + /// @notice Thrown if passed helpers array is of wrong length. + /// @param length The array length of passed helpers. + error WrongHelpersArrayLength(uint256 length); + + /// @notice Thrown when trying to prepare an installation with no proposers. + error NoProposers(); + + /// @notice The contract constructor deploying the plugin implementation contract and receiving the governance token base contracts to clone from. + /// @param _governanceERC20Base The base `GovernanceERC20` contract to create clones from. + /// @param _governanceWrappedERC20Base The base `GovernanceWrappedERC20` contract to create clones from. + constructor( + GovernanceERC20 _governanceERC20Base, + GovernanceWrappedERC20 _governanceWrappedERC20Base + ) { + optimisticTokenVotingPluginBase = new OptimisticTokenVotingPlugin(); + governanceERC20Base = address(_governanceERC20Base); + governanceWrappedERC20Base = address(_governanceWrappedERC20Base); + } + + /// @inheritdoc IPluginSetup + function prepareInstallation( + address _dao, + bytes calldata _installParameters + ) + external + returns (address plugin, PreparedSetupData memory preparedSetupData) + { + // Decode `_installParameters` to extract the params needed for deploying and initializing `OptimisticTokenVoting` plugin, + // and the required helpers + ( + OptimisticTokenVotingPlugin.OptimisticGovernanceSettings + memory votingSettings, + TokenSettings memory tokenSettings, + // only used for GovernanceERC20 (when token is not passed) + GovernanceERC20.MintSettings memory mintSettings, + address[] memory proposers + ) = decodeInstallationParams(_installParameters); + + if (proposers.length == 0) { + revert NoProposers(); + } + + address token = tokenSettings.addr; + + // Prepare helpers. + address[] memory helpers = new address[](1); + + if (token != address(0)) { + if (!token.isContract()) { + revert TokenNotContract(token); + } + + if (!_isERC20(token)) { + revert TokenNotERC20(token); + } + + // [0] = IERC20Upgradeable, [1] = IVotesUpgradeable, [2] = IGovernanceWrappedERC20 + bool[] memory supportedIds = _getTokenInterfaceIds(token); + + if ( + // If token supports none of them + // it's simply ERC20 which gets checked by _isERC20 + // Currently, not a satisfiable check. + (!supportedIds[0] && !supportedIds[1] && !supportedIds[2]) || + // If token supports IERC20, but neither + // IVotes nor IGovernanceWrappedERC20, it needs wrapping. + (supportedIds[0] && !supportedIds[1] && !supportedIds[2]) + ) { + token = governanceWrappedERC20Base.clone(); + // User already has a token. We need to wrap it in + // GovernanceWrappedERC20 in order to make the token + // include governance functionality. + GovernanceWrappedERC20(token).initialize( + IERC20Upgradeable(tokenSettings.addr), + tokenSettings.name, + tokenSettings.symbol + ); + } + } else { + // Clone a `GovernanceERC20`. + token = governanceERC20Base.clone(); + GovernanceERC20(token).initialize( + IDAO(_dao), + tokenSettings.name, + tokenSettings.symbol, + mintSettings + ); + } + + helpers[0] = token; + + // Prepare and deploy plugin proxy. + plugin = createERC1967Proxy( + address(optimisticTokenVotingPluginBase), + abi.encodeCall( + OptimisticTokenVotingPlugin.initialize, + (IDAO(_dao), votingSettings, IVotesUpgradeable(token)) + ) + ); + + // Prepare permissions + PermissionLib.MultiTargetPermission[] + memory permissions = new PermissionLib.MultiTargetPermission[]( + tokenSettings.addr != address(0) + ? 3 + proposers.length + : 4 + proposers.length + ); + + // Request the permissions to be granted + + // The DAO can update the plugin settings + permissions[0] = PermissionLib.MultiTargetPermission({ + operation: PermissionLib.Operation.Grant, + where: plugin, + who: _dao, + condition: PermissionLib.NO_CONDITION, + permissionId: optimisticTokenVotingPluginBase + .UPDATE_OPTIMISTIC_GOVERNANCE_SETTINGS_PERMISSION_ID() + }); + + // The DAO can upgrade the plugin implementation + permissions[1] = PermissionLib.MultiTargetPermission({ + operation: PermissionLib.Operation.Grant, + where: plugin, + who: _dao, + condition: PermissionLib.NO_CONDITION, + permissionId: optimisticTokenVotingPluginBase + .UPGRADE_PLUGIN_PERMISSION_ID() + }); + + // The plugin can make the DAO execute actions + permissions[2] = PermissionLib.MultiTargetPermission({ + operation: PermissionLib.Operation.Grant, + where: _dao, + who: plugin, + condition: PermissionLib.NO_CONDITION, + permissionId: DAO(payable(_dao)).EXECUTE_PERMISSION_ID() + }); + + // Proposers can create proposals + for (uint256 i = 0; i < proposers.length; ) { + permissions[3 + i] = PermissionLib.MultiTargetPermission({ + operation: PermissionLib.Operation.Grant, + where: plugin, + who: proposers[i], + condition: PermissionLib.NO_CONDITION, + permissionId: optimisticTokenVotingPluginBase + .PROPOSER_PERMISSION_ID() + }); + + unchecked { + i++; + } + } + + if (tokenSettings.addr == address(0)) { + bytes32 tokenMintPermission = GovernanceERC20(token) + .MINT_PERMISSION_ID(); + + // The DAO can mint ERC20 tokens + permissions[3 + proposers.length] = PermissionLib + .MultiTargetPermission({ + operation: PermissionLib.Operation.Grant, + where: token, + who: _dao, + condition: PermissionLib.NO_CONDITION, + permissionId: tokenMintPermission + }); + } + + preparedSetupData.helpers = helpers; + preparedSetupData.permissions = permissions; + } + + /// @inheritdoc IPluginSetup + function prepareUninstallation( + address _dao, + SetupPayload calldata _payload + ) + external + view + returns (PermissionLib.MultiTargetPermission[] memory permissions) + { + // Prepare permissions. + uint256 helperLength = _payload.currentHelpers.length; + if (helperLength != 1) { + revert WrongHelpersArrayLength({length: helperLength}); + } + + // token can be either GovernanceERC20, GovernanceWrappedERC20, or IVotesUpgradeable, which + // does not follow the GovernanceERC20 and GovernanceWrappedERC20 standard. + address token = _payload.currentHelpers[0]; + + bool[] memory supportedIds = _getTokenInterfaceIds(token); + + bool isGovernanceERC20 = supportedIds[0] && + supportedIds[1] && + !supportedIds[2]; + + permissions = new PermissionLib.MultiTargetPermission[]( + isGovernanceERC20 ? 4 : 3 + ); + + // Set permissions to be Revoked. + permissions[0] = PermissionLib.MultiTargetPermission({ + operation: PermissionLib.Operation.Revoke, + where: _payload.plugin, + who: _dao, + condition: PermissionLib.NO_CONDITION, + permissionId: optimisticTokenVotingPluginBase + .UPDATE_OPTIMISTIC_GOVERNANCE_SETTINGS_PERMISSION_ID() + }); + + permissions[1] = PermissionLib.MultiTargetPermission({ + operation: PermissionLib.Operation.Revoke, + where: _payload.plugin, + who: _dao, + condition: PermissionLib.NO_CONDITION, + permissionId: optimisticTokenVotingPluginBase + .UPGRADE_PLUGIN_PERMISSION_ID() + }); + + permissions[2] = PermissionLib.MultiTargetPermission({ + operation: PermissionLib.Operation.Revoke, + where: _dao, + who: _payload.plugin, + condition: PermissionLib.NO_CONDITION, + permissionId: DAO(payable(_dao)).EXECUTE_PERMISSION_ID() + }); + + // Note: It no longer matters if proposers can still create proposals + + // Revocation of permission is necessary only if the deployed token is GovernanceERC20, + // as GovernanceWrapped does not possess this permission. Only return the following + // if it's type of GovernanceERC20, otherwise revoking this permission wouldn't have any effect. + if (isGovernanceERC20) { + permissions[3] = PermissionLib.MultiTargetPermission({ + operation: PermissionLib.Operation.Revoke, + where: token, + who: _dao, + condition: PermissionLib.NO_CONDITION, + permissionId: GovernanceERC20(token).MINT_PERMISSION_ID() + }); + } + } + + /// @inheritdoc IPluginSetup + function implementation() external view virtual override returns (address) { + return address(optimisticTokenVotingPluginBase); + } + + /// @notice Encodes the given installation parameters into a byte array + function encodeInstallationParams( + OptimisticTokenVotingPlugin.OptimisticGovernanceSettings + calldata _votingSettings, + TokenSettings calldata _tokenSettings, + // only used for GovernanceERC20 (when a token is not passed) + GovernanceERC20.MintSettings calldata _mintSettings, + address[] calldata _proposers + ) external pure returns (bytes memory) { + return + abi.encode( + _votingSettings, + _tokenSettings, + _mintSettings, + _proposers + ); + } + + /// @notice Decodes the given byte array into the original installation parameters + function decodeInstallationParams( + bytes memory _data + ) + public + pure + returns ( + OptimisticTokenVotingPlugin.OptimisticGovernanceSettings + memory votingSettings, + TokenSettings memory tokenSettings, + // only used for GovernanceERC20 (when token is not passed) + GovernanceERC20.MintSettings memory mintSettings, + address[] memory proposers + ) + { + (votingSettings, tokenSettings, mintSettings, proposers) = abi.decode( + _data, + ( + OptimisticTokenVotingPlugin.OptimisticGovernanceSettings, + TokenSettings, + GovernanceERC20.MintSettings, + address[] + ) + ); + } + + /// @notice Retrieves the interface identifiers supported by the token contract. + /// @dev It is crucial to verify if the provided token address represents a valid contract before using the below. + /// @param token The token address + function _getTokenInterfaceIds( + address token + ) private view returns (bool[] memory) { + bytes4[] memory interfaceIds = new bytes4[](3); + interfaceIds[0] = type(IERC20Upgradeable).interfaceId; + interfaceIds[1] = type(IVotesUpgradeable).interfaceId; + interfaceIds[2] = type(IGovernanceWrappedERC20).interfaceId; + return token.getSupportedInterfaces(interfaceIds); + } + + /// @notice Unsatisfiably determines if the contract is an ERC20 token. + /// @dev It's important to first check whether token is a contract prior to this call. + /// @param token The token address + function _isERC20(address token) private view returns (bool) { + (bool success, bytes memory data) = token.staticcall( + abi.encodeCall(IERC20Upgradeable.balanceOf, (address(this))) + ); + return success && data.length == 0x20; + } +} diff --git a/src/metadata/optimistic-token-voting-build-metadata.json b/src/metadata/optimistic-token-voting-build-metadata.json new file mode 100644 index 0000000..e80e96a --- /dev/null +++ b/src/metadata/optimistic-token-voting-build-metadata.json @@ -0,0 +1,93 @@ +{ + "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 new file mode 100644 index 0000000..72cd94f --- /dev/null +++ b/src/metadata/optimistic-token-voting-release-metadata.json @@ -0,0 +1,5 @@ +{ + "name": "Optimistic Token Voting Plugin", + "description": "", + "images": {} +} diff --git a/test/OptimisticTokenVotingPlugin.t.sol b/test/OptimisticTokenVotingPlugin.t.sol new file mode 100644 index 0000000..04fb6e9 --- /dev/null +++ b/test/OptimisticTokenVotingPlugin.t.sol @@ -0,0 +1,2094 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.17; + +import {Test, console2} from "forge-std/Test.sol"; +import {OptimisticTokenVotingPlugin} from "../src/OptimisticTokenVotingPlugin.sol"; +import {IOptimisticTokenVoting} from "../src/IOptimisticTokenVoting.sol"; +import {DAO} from "@aragon/osx/core/dao/DAO.sol"; +import {IDAO} from "@aragon/osx/core/dao/IDAO.sol"; +import {IProposal} from "@aragon/osx/core/plugin/proposal/IProposal.sol"; +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"; + +contract OptimisticTokenVotingPluginTest is Test { + address immutable daoBase = address(new DAO()); + address immutable pluginBase = address(new OptimisticTokenVotingPlugin()); + address immutable votingTokenBase = address(new ERC20VotesMock()); + + 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( + uint256 indexed proposalId, + address indexed creator, + uint64 startDate, + uint64 endDate, + bytes metadata, + IDAO.Action[] actions, + uint256 allowFailureMap + ); + event VetoCast( + uint256 indexed proposalId, + address indexed voter, + uint256 votingPower + ); + event ProposalExecuted(uint256 indexed proposalId); + event OptimisticGovernanceSettingsUpdated( + uint32 minVetoRatio, + uint64 minDuration, + uint256 minProposerVotingPower + ); + event Upgraded(address indexed implementation); + + function setUp() public { + vm.startPrank(alice); + + // Deploy a DAO with Alice as root + dao = DAO( + payable( + createProxyAndCall( + address(daoBase), + abi.encodeWithSelector( + DAO.initialize.selector, + "", + alice, + address(0x0), + "" + ) + ) + ) + ); + + // Deploy ERC20 token + votingToken = ERC20VotesMock( + createProxyAndCall( + address(votingTokenBase), + abi.encodeWithSelector(ERC20VotesMock.initialize.selector) + ) + ); + 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.encodeWithSelector( + OptimisticTokenVotingPlugin.initialize.selector, + dao, + settings, + votingToken + ) + ) + ); + + // The plugin can execute on the DAO + dao.grant(address(dao), address(plugin), dao.EXECUTE_PERMISSION_ID()); + + // Alice can create proposals on the plugin + dao.grant(address(plugin), alice, plugin.PROPOSER_PERMISSION_ID()); + } + + // Initialize + function test_InitializeRevertsIfInitialized() public { + OptimisticTokenVotingPlugin.OptimisticGovernanceSettings + memory settings = OptimisticTokenVotingPlugin + .OptimisticGovernanceSettings({ + minVetoRatio: uint32(RATIO_BASE / 10), + minDuration: 10 days, + minProposerVotingPower: 0 + }); + + vm.expectRevert( + bytes("Initializable: contract is already initialized") + ); + plugin.initialize(dao, settings, votingToken); + } + + function test_InitializeSetsTheProperValues() public { + // Initial settings + OptimisticTokenVotingPlugin.OptimisticGovernanceSettings + memory settings = OptimisticTokenVotingPlugin + .OptimisticGovernanceSettings({ + minVetoRatio: uint32(RATIO_BASE / 10), + minDuration: 10 days, + minProposerVotingPower: 0 + }); + plugin = OptimisticTokenVotingPlugin( + createProxyAndCall( + address(pluginBase), + abi.encodeWithSelector( + OptimisticTokenVotingPlugin.initialize.selector, + dao, + settings, + votingToken + ) + ) + ); + assertEq( + plugin.totalVotingPower(block.number - 1), + 10 ether, + "Incorrect token supply" + ); + assertEq( + plugin.minVetoRatio(), + uint32(RATIO_BASE / 10), + "Incorrect minVetoRatio" + ); + assertEq(plugin.minDuration(), 10 days, "Incorrect minDuration"); + assertEq( + plugin.minProposerVotingPower(), + 0, + "Incorrect minProposerVotingPower" + ); + + // Different minVetoRatio + settings.minVetoRatio = uint32(RATIO_BASE / 5); + plugin = OptimisticTokenVotingPlugin( + createProxyAndCall( + address(pluginBase), + abi.encodeWithSelector( + OptimisticTokenVotingPlugin.initialize.selector, + dao, + settings, + votingToken + ) + ) + ); + assertEq( + plugin.minVetoRatio(), + uint32(RATIO_BASE / 5), + "Incorrect minVetoRatio" + ); + + // Different minDuration + settings.minDuration = 25 days; + plugin = OptimisticTokenVotingPlugin( + createProxyAndCall( + address(pluginBase), + abi.encodeWithSelector( + OptimisticTokenVotingPlugin.initialize.selector, + dao, + settings, + votingToken + ) + ) + ); + assertEq(plugin.minDuration(), 25 days, "Incorrect minDuration"); + + // A token with 10 eth supply + votingToken = ERC20VotesMock( + createProxyAndCall( + address(votingTokenBase), + abi.encodeWithSelector(ERC20VotesMock.initialize.selector) + ) + ); + votingToken.mint(alice, 10 ether); + vm.roll(block.number + 5); + + plugin = OptimisticTokenVotingPlugin( + createProxyAndCall( + address(pluginBase), + abi.encodeWithSelector( + OptimisticTokenVotingPlugin.initialize.selector, + dao, + settings, + votingToken + ) + ) + ); + assertEq( + plugin.totalVotingPower(block.number - 1), + 10 ether, + "Incorrect token supply" + ); + + // Different minProposerVotingPower + settings.minProposerVotingPower = 1 ether; + plugin = OptimisticTokenVotingPlugin( + createProxyAndCall( + address(pluginBase), + abi.encodeWithSelector( + OptimisticTokenVotingPlugin.initialize.selector, + dao, + settings, + votingToken + ) + ) + ); + assertEq( + plugin.minProposerVotingPower(), + 1 ether, + "Incorrect minProposerVotingPower" + ); + } + + function test_InitializeEmitsEvent() public { + OptimisticTokenVotingPlugin.OptimisticGovernanceSettings + memory settings = OptimisticTokenVotingPlugin + .OptimisticGovernanceSettings({ + minVetoRatio: uint32(RATIO_BASE / 10), + minDuration: 10 days, + minProposerVotingPower: 0 + }); + + vm.expectEmit(); + emit Initialized(uint8(1)); + + plugin = OptimisticTokenVotingPlugin( + createProxyAndCall( + address(pluginBase), + abi.encodeWithSelector( + OptimisticTokenVotingPlugin.initialize.selector, + dao, + settings, + votingToken + ) + ) + ); + } + + // Getters + function test_SupportsOptimisticGovernanceInterface() public { + bool supported = plugin.supportsInterface( + plugin.OPTIMISTIC_GOVERNANCE_INTERFACE_ID() + ); + assertEq( + supported, + true, + "Should support OPTIMISTIC_GOVERNANCE_INTERFACE_ID" + ); + } + + function test_SupportsIOptimisticTokenVotingInterface() public { + bool supported = plugin.supportsInterface( + type(IOptimisticTokenVoting).interfaceId + ); + assertEq(supported, true, "Should support IOptimisticTokenVoting"); + } + + function test_SupportsIMembershipInterface() public { + bool supported = plugin.supportsInterface( + type(IMembership).interfaceId + ); + assertEq(supported, true, "Should support IMembership"); + } + + function test_SupportsIProposalInterface() public { + bool supported = plugin.supportsInterface(type(IProposal).interfaceId); + assertEq(supported, true, "Should support IProposal"); + } + + function test_SupportsIERC165UpgradeableInterface() public { + bool supported = plugin.supportsInterface( + type(IERC165Upgradeable).interfaceId + ); + assertEq(supported, true, "Should support IERC165Upgradeable"); + } + + function testFuzz_SupportsInterfaceReturnsFalseOtherwise( + bytes4 _randomInterfaceId + ) public { + bool supported = plugin.supportsInterface(bytes4(0x000000)); + assertEq(supported, false, "Should not support any other interface"); + + supported = plugin.supportsInterface(bytes4(0xffffffff)); + assertEq(supported, false, "Should not support any other interface"); + + supported = plugin.supportsInterface(_randomInterfaceId); + assertEq(supported, false, "Should not support any other interface"); + } + + function test_GetVotingTokenReturnsTheRightAddress() public { + assertEq( + address(plugin.getVotingToken()), + address(votingToken), + "Incorrect voting token" + ); + + address oldToken = address(plugin.getVotingToken()); + + // New token + votingToken = ERC20VotesMock( + createProxyAndCall( + address(votingTokenBase), + abi.encodeWithSelector(ERC20VotesMock.initialize.selector) + ) + ); + + // 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.encodeWithSelector( + OptimisticTokenVotingPlugin.initialize.selector, + dao, + settings, + votingToken + ) + ) + ); + + assertEq( + address(plugin.getVotingToken()), + address(votingToken), + "Incorrect voting token" + ); + assertEq( + address(votingToken) != oldToken, + true, + "The token address sould have changed" + ); + } + + function test_TotalVotingPowerReturnsTheRightSupply() public { + assertEq( + plugin.totalVotingPower(block.number - 1), + votingToken.getPastTotalSupply(block.number - 1), + "Incorrect total voting power" + ); + assertEq( + plugin.totalVotingPower(block.number - 1), + 10 ether, + "Incorrect total voting power" + ); + + // New token + votingToken = ERC20VotesMock( + createProxyAndCall( + address(votingTokenBase), + abi.encodeWithSelector(ERC20VotesMock.initialize.selector) + ) + ); + votingToken.mint(alice, 15 ether); + 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.encodeWithSelector( + OptimisticTokenVotingPlugin.initialize.selector, + dao, + settings, + votingToken + ) + ) + ); + + assertEq( + plugin.totalVotingPower(block.number - 1), + votingToken.getPastTotalSupply(block.number - 1), + "Incorrect total voting power" + ); + assertEq( + plugin.totalVotingPower(block.number - 1), + 15 ether, + "Incorrect total voting power" + ); + } + + function test_MinVetoRatioReturnsTheRightValue() public { + assertEq( + plugin.minVetoRatio(), + uint32(RATIO_BASE / 10), + "Incorrect minVetoRatio" + ); + + // New plugin instance + OptimisticTokenVotingPlugin.OptimisticGovernanceSettings + memory settings = OptimisticTokenVotingPlugin + .OptimisticGovernanceSettings({ + minVetoRatio: uint32(RATIO_BASE / 5), + minDuration: 10 days, + minProposerVotingPower: 0 + }); + + plugin = OptimisticTokenVotingPlugin( + createProxyAndCall( + address(pluginBase), + abi.encodeWithSelector( + OptimisticTokenVotingPlugin.initialize.selector, + dao, + settings, + votingToken + ) + ) + ); + + assertEq( + plugin.minVetoRatio(), + uint32(RATIO_BASE / 5), + "Incorrect minVetoRatio" + ); + } + + function test_MinDurationReturnsTheRightValue() public { + assertEq(plugin.minDuration(), 10 days, "Incorrect minDuration"); + + // New plugin instance + OptimisticTokenVotingPlugin.OptimisticGovernanceSettings + memory settings = OptimisticTokenVotingPlugin + .OptimisticGovernanceSettings({ + minVetoRatio: uint32(RATIO_BASE / 5), + minDuration: 25 days, + minProposerVotingPower: 0 + }); + + plugin = OptimisticTokenVotingPlugin( + createProxyAndCall( + address(pluginBase), + abi.encodeWithSelector( + OptimisticTokenVotingPlugin.initialize.selector, + dao, + settings, + votingToken + ) + ) + ); + + assertEq(plugin.minDuration(), 25 days, "Incorrect minDuration"); + } + + function test_MinProposerVotingPowerReturnsTheRightValue() public { + assertEq( + plugin.minProposerVotingPower(), + 0, + "Incorrect minProposerVotingPower" + ); + + // New token + votingToken = ERC20VotesMock( + createProxyAndCall( + address(votingTokenBase), + abi.encodeWithSelector(ERC20VotesMock.initialize.selector) + ) + ); + votingToken.mint(alice, 10 ether); + 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: 1 ether + }); + + plugin = OptimisticTokenVotingPlugin( + createProxyAndCall( + address(pluginBase), + abi.encodeWithSelector( + OptimisticTokenVotingPlugin.initialize.selector, + dao, + settings, + votingToken + ) + ) + ); + + assertEq( + plugin.minProposerVotingPower(), + 1 ether, + "Incorrect minProposerVotingPower" + ); + } + + function test_TokenHoldersAreMembers() public { + assertEq(plugin.isMember(alice), true, "Alice should not be a member"); + assertEq(plugin.isMember(bob), false, "Bob should not be a member"); + assertEq( + plugin.isMember(randomWallet), + false, + "Random wallet should not be a member" + ); + + // New token + votingToken = ERC20VotesMock( + createProxyAndCall( + address(votingTokenBase), + abi.encodeWithSelector(ERC20VotesMock.initialize.selector) + ) + ); + votingToken.mint(alice, 10 ether); + votingToken.mint(bob, 5 ether); + 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: 1 ether + }); + + plugin = OptimisticTokenVotingPlugin( + createProxyAndCall( + address(pluginBase), + abi.encodeWithSelector( + OptimisticTokenVotingPlugin.initialize.selector, + dao, + settings, + votingToken + ) + ) + ); + + assertEq(plugin.isMember(alice), true, "Alice should be a member"); + assertEq(plugin.isMember(bob), true, "Bob should be a member"); + assertEq( + plugin.isMember(randomWallet), + false, + "Random wallet should not be a member" + ); + } + + // Create proposal + function test_CreateProposalRevertsWhenCalledByANonProposer() public { + vm.stopPrank(); + vm.startPrank(bob); + IDAO.Action[] memory actions = new IDAO.Action[](0); + vm.expectRevert( + abi.encodeWithSelector( + DaoUnauthorized.selector, + address(dao), + address(plugin), + bob, + plugin.PROPOSER_PERMISSION_ID() + ) + ); + plugin.createProposal("", actions, 0, 0, 0); + + vm.stopPrank(); + vm.startPrank(alice); + + plugin.createProposal("", actions, 0, 0, 0); + } + + function test_CreateProposalSucceedsWhenMinimumVotingPowerIsZero() public { + // Bob can create proposals on the plugin now + dao.grant(address(plugin), bob, plugin.PROPOSER_PERMISSION_ID()); + + vm.stopPrank(); + vm.startPrank(bob); + + IDAO.Action[] memory actions = new IDAO.Action[](0); + uint256 proposalId = plugin.createProposal("", actions, 0, 0, 0); + assertEq(proposalId, 0); + proposalId = plugin.createProposal("", actions, 0, 0, 0); + assertEq(proposalId, 1); + } + + function test_CreateProposalRevertsWhenTheCallerOwnsLessThanTheMinimumVotingPower() + public + { + vm.stopPrank(); + vm.startPrank(alice); + + IDAO.Action[] memory actions = new IDAO.Action[](0); + OptimisticTokenVotingPlugin.OptimisticGovernanceSettings + memory newSettings = OptimisticTokenVotingPlugin + .OptimisticGovernanceSettings({ + minVetoRatio: uint32(RATIO_BASE / 10), + minDuration: 10 days, + minProposerVotingPower: 5 ether + }); + dao.grant(address(plugin), bob, plugin.PROPOSER_PERMISSION_ID()); + dao.grant( + address(plugin), + alice, + plugin.UPDATE_OPTIMISTIC_GOVERNANCE_SETTINGS_PERMISSION_ID() + ); + plugin.updateOptimisticGovernanceSettings(newSettings); + + vm.stopPrank(); + vm.startPrank(bob); + + // Bob holds no tokens + vm.expectRevert( + abi.encodeWithSelector( + OptimisticTokenVotingPlugin.ProposalCreationForbidden.selector, + bob + ) + ); + plugin.createProposal("", actions, 0, 0, 0); + } + + function test_CreateProposalRevertsIfThereIsNoVotingPower() public { + vm.stopPrank(); + vm.startPrank(alice); + + // Deploy ERC20 token (0 supply) + votingToken = ERC20VotesMock( + createProxyAndCall( + address(votingTokenBase), + abi.encodeWithSelector(ERC20VotesMock.initialize.selector) + ) + ); + + // Deploy a new plugin instance + IDAO.Action[] memory actions = new IDAO.Action[](0); + OptimisticTokenVotingPlugin.OptimisticGovernanceSettings + memory settings = OptimisticTokenVotingPlugin + .OptimisticGovernanceSettings({ + minVetoRatio: uint32(RATIO_BASE / 10), + minDuration: 10 days, + minProposerVotingPower: 0 + }); + plugin = OptimisticTokenVotingPlugin( + createProxyAndCall( + address(pluginBase), + abi.encodeWithSelector( + OptimisticTokenVotingPlugin.initialize.selector, + dao, + settings, + votingToken + ) + ) + ); + dao.grant(address(plugin), alice, plugin.PROPOSER_PERMISSION_ID()); + + // Try to create + vm.expectRevert( + abi.encodeWithSelector( + OptimisticTokenVotingPlugin.NoVotingPower.selector + ) + ); + plugin.createProposal("", actions, 0, 0, 0); + } + + function test_CreateProposalRevertsIfTheStartDateIsAfterTheEndDate() + public + { + IDAO.Action[] memory actions = new IDAO.Action[](0); + uint32 startDate = 200000; + uint32 endDate = 10; + vm.expectRevert( + abi.encodeWithSelector( + OptimisticTokenVotingPlugin.DateOutOfBounds.selector, + startDate + 10 days, + endDate + ) + ); + plugin.createProposal("", actions, 0, startDate, endDate); + } + + function test_CreateProposalRevertsIfStartDateIsInThePast() public { + vm.warp(10); // timestamp = 10 + + IDAO.Action[] memory actions = new IDAO.Action[](0); + vm.expectRevert( + abi.encodeWithSelector( + OptimisticTokenVotingPlugin.DateOutOfBounds.selector, + block.timestamp, + 1 + ) + ); + uint32 startDate = 1; + plugin.createProposal("", actions, 0, startDate, startDate + 10 days); + } + + function test_CreateProposalRevertsIfEndDateIsEarlierThanMinDuration() + public + { + vm.warp(500); // timestamp = 500 + + IDAO.Action[] memory actions = new IDAO.Action[](0); + uint32 startDate = 1000; + vm.expectRevert( + abi.encodeWithSelector( + OptimisticTokenVotingPlugin.DateOutOfBounds.selector, + startDate + 10 days, + startDate + 10 minutes + ) + ); + plugin.createProposal( + "", + actions, + 0, + startDate, + startDate + 10 minutes + ); + } + + function test_CreateProposalStartsNowWhenStartDateIsZero() public { + vm.warp(500); + + IDAO.Action[] memory actions = new IDAO.Action[](0); + uint32 startDate = 0; + uint256 proposalId = plugin.createProposal( + "", + actions, + 0, + startDate, + 0 + ); + + ( + , + , + OptimisticTokenVotingPlugin.ProposalParameters memory parameters, + , + , + + ) = plugin.getProposal(proposalId); + assertEq(500, parameters.startDate, "Incorrect startDate"); + } + + function test_CreateProposalEndsAfterMinDurationWhenEndDateIsZero() public { + vm.warp(500); + + IDAO.Action[] memory actions = new IDAO.Action[](0); + uint32 startDate = 0; + uint32 endDate = 0; + uint256 proposalId = plugin.createProposal( + "", + actions, + 0, + startDate, + endDate + ); + + ( + , + , + OptimisticTokenVotingPlugin.ProposalParameters memory parameters, + , + , + + ) = plugin.getProposal(proposalId); + assertEq(500 + 10 days, parameters.endDate, "Incorrect endDate"); + } + + function test_CreateProposalUsesTheCurrentMinVetoRatio() public { + IDAO.Action[] memory actions = new IDAO.Action[](0); + uint256 proposalId = plugin.createProposal("", actions, 0, 0, 0); + + ( + , + , + OptimisticTokenVotingPlugin.ProposalParameters memory parameters, + , + , + + ) = plugin.getProposal(proposalId); + assertEq( + parameters.minVetoVotingPower, + 1 ether, + "Incorrect minVetoVotingPower" + ); + + // Now with a different value + OptimisticTokenVotingPlugin.OptimisticGovernanceSettings + memory settings = OptimisticTokenVotingPlugin + .OptimisticGovernanceSettings({ + minVetoRatio: uint32(RATIO_BASE / 5), + minDuration: 10 days, + minProposerVotingPower: 0 + }); + plugin = OptimisticTokenVotingPlugin( + createProxyAndCall( + address(pluginBase), + abi.encodeWithSelector( + OptimisticTokenVotingPlugin.initialize.selector, + dao, + settings, + votingToken + ) + ) + ); + + dao.grant(address(plugin), alice, plugin.PROPOSER_PERMISSION_ID()); + proposalId = plugin.createProposal("", actions, 0, 0, 0); + (, , parameters, , , ) = plugin.getProposal(proposalId); + assertEq( + parameters.minVetoVotingPower, + 2 ether, + "Incorrect minVetoVotingPower" + ); + } + + function test_CreateProposalReturnsTheProposalId() public { + IDAO.Action[] memory actions = new IDAO.Action[](0); + uint256 proposalId = plugin.createProposal("", actions, 0, 0, 0); + assertEq(proposalId == 0, true, "Should have created proposal 0"); + + proposalId = plugin.createProposal("", actions, 0, 0, 0); + assertEq(proposalId == 1, true, "Should have created proposal 1"); + } + + function test_CreateProposalEmitsAnEvent() public { + IDAO.Action[] memory actions = new IDAO.Action[](0); + vm.expectEmit(); + emit ProposalCreated( + 0, + alice, + uint64(block.timestamp), + uint64(block.timestamp + 10 days), + "", + actions, + 0 + ); + plugin.createProposal("", actions, 0, 0, 0); + } + + function test_GetProposalReturnsTheRightValues() public { + vm.warp(500); + uint32 startDate = 600; + uint32 endDate = startDate + 15 days; + + IDAO.Action[] memory actions = new IDAO.Action[](1); + actions[0].to = address(plugin); + actions[0].value = 1 wei; + actions[0].data = abi.encodeWithSelector( + OptimisticTokenVotingPlugin.totalVotingPower.selector, + 0 + ); + uint256 failSafeBitmap = 1; + + uint256 proposalId = plugin.createProposal( + "ipfs://", + actions, + failSafeBitmap, + startDate, + endDate + ); + + (bool open0, , , , , ) = plugin.getProposal(proposalId); + assertEq(open0, false, "The proposal should not be open"); + + // Move on + vm.warp(startDate); + + ( + bool open, + bool executed, + OptimisticTokenVotingPlugin.ProposalParameters memory parameters, + uint256 vetoTally, + IDAO.Action[] memory actualActions, + uint256 actualFailSafeBitmap + ) = plugin.getProposal(proposalId); + + assertEq(open, true, "The proposal should be open"); + assertEq(executed, false, "The proposal should not be executed"); + assertEq(parameters.startDate, startDate, "Incorrect startDate"); + assertEq(parameters.endDate, endDate, "Incorrect endDate"); + assertEq(parameters.snapshotBlock, 1, "Incorrect snapshotBlock"); + assertEq( + parameters.minVetoVotingPower, + plugin.totalVotingPower(block.number - 1) / 10, + "Incorrect minVetoVotingPower" + ); + assertEq(vetoTally, 0, "The tally should be zero"); + assertEq(actualActions.length, 1, "Actions should have one item"); + assertEq( + actualFailSafeBitmap, + failSafeBitmap, + "Incorrect failsafe bitmap" + ); + + // Move on + vm.warp(endDate); + + (bool open1, , , , , ) = plugin.getProposal(proposalId); + assertEq(open1, false, "The proposal should not be open anymore"); + } + + // Can Veto + function test_CanVetoReturnsFalseWhenAProposalDoesntExist() public { + vm.roll(10); + + IDAO.Action[] memory actions = new IDAO.Action[](0); + uint256 proposalId = plugin.createProposal("ipfs://", actions, 0, 0, 0); + vm.roll(20); + + assertEq( + plugin.canVeto(proposalId, alice), + true, + "Alice should be able to veto" + ); + + // non existing + assertEq( + plugin.canVeto(proposalId + 200, alice), + false, + "Alice should not be able to veto on non existing proposals" + ); + } + + function test_CanVetoReturnsFalseWhenAProposalHasNotStarted() public { + uint64 startDate = 50; + vm.warp(startDate - 1); + + IDAO.Action[] memory actions = new IDAO.Action[](0); + uint256 proposalId = plugin.createProposal( + "ipfs://", + actions, + 0, + startDate, + 0 + ); + + // Unstarted + assertEq( + plugin.canVeto(proposalId, alice), + false, + "Alice should not be able to veto" + ); + + // Started + vm.warp(startDate + 1); + assertEq( + plugin.canVeto(proposalId, alice), + true, + "Alice should be able to veto" + ); + } + + function test_CanVetoReturnsFalseWhenAVoterAlreadyVetoed() public { + uint64 startDate = 50; + vm.warp(startDate - 1); + + IDAO.Action[] memory actions = new IDAO.Action[](0); + uint256 proposalId = plugin.createProposal( + "ipfs://", + actions, + 0, + startDate, + 0 + ); + vm.warp(startDate + 1); + + plugin.veto(proposalId); + + assertEq( + plugin.canVeto(proposalId, alice), + false, + "Alice should not be able to veto" + ); + } + + function test_CanVetoReturnsFalseWhenAVoterAlreadyEnded() public { + uint64 startDate = 50; + uint64 endDate = startDate + 10 days; + vm.warp(startDate - 1); + + IDAO.Action[] memory actions = new IDAO.Action[](0); + uint256 proposalId = plugin.createProposal( + "ipfs://", + actions, + 0, + startDate, + endDate + ); + vm.warp(endDate + 1); + + assertEq( + plugin.canVeto(proposalId, alice), + false, + "Alice should not be able to veto" + ); + } + + function test_CanVetoReturnsFalseWhenNoVotingPower() public { + uint64 startDate = 50; + vm.warp(startDate - 1); + + IDAO.Action[] memory actions = new IDAO.Action[](0); + uint256 proposalId = plugin.createProposal( + "ipfs://", + actions, + 0, + startDate, + 0 + ); + + vm.warp(startDate + 1); + + // Alice owns tokens + assertEq( + plugin.canVeto(proposalId, alice), + true, + "Alice should be able to veto" + ); + + // Bob owns no tokens + assertEq( + plugin.canVeto(proposalId, bob), + false, + "Bob should not be able to veto" + ); + } + + function test_CanVetoReturnsTrueOtherwise() public { + uint64 startDate = 50; + vm.warp(startDate - 1); + + IDAO.Action[] memory actions = new IDAO.Action[](0); + uint256 proposalId = plugin.createProposal( + "ipfs://", + actions, + 0, + startDate, + 0 + ); + vm.warp(startDate + 1); + + assertEq( + plugin.canVeto(proposalId, alice), + true, + "Alice should be able to veto" + ); + } + + // Veto + function test_VetoRevertsWhenAProposalDoesntExist() public { + vm.roll(10); + uint64 startDate = 50; + vm.warp(startDate - 1); + + IDAO.Action[] memory actions = new IDAO.Action[](0); + uint256 proposalId = plugin.createProposal( + "ipfs://", + actions, + 0, + startDate, + 0 + ); + vm.roll(20); + + // non existing + vm.expectRevert( + abi.encodeWithSelector( + OptimisticTokenVotingPlugin.ProposalVetoingForbidden.selector, + proposalId + 200, + alice + ) + ); + plugin.veto(proposalId + 200); + } + + function test_VetoRevertsWhenAProposalHasNotStarted() public { + uint64 startDate = 50; + vm.warp(startDate - 1); + + IDAO.Action[] memory actions = new IDAO.Action[](0); + uint256 proposalId = plugin.createProposal( + "ipfs://", + actions, + 0, + startDate, + 0 + ); + + // Unstarted + vm.expectRevert( + abi.encodeWithSelector( + OptimisticTokenVotingPlugin.ProposalVetoingForbidden.selector, + proposalId, + alice + ) + ); + plugin.veto(proposalId); + assertEq( + plugin.hasVetoed(proposalId, alice), + false, + "Alice should not have vetoed" + ); + + // Started + vm.warp(startDate + 1); + plugin.veto(proposalId); + assertEq( + plugin.hasVetoed(proposalId, alice), + true, + "Alice should have vetoed" + ); + } + + function test_VetoRevertsWhenAVoterAlreadyVetoed() public { + uint64 startDate = 50; + vm.warp(startDate - 1); + + IDAO.Action[] memory actions = new IDAO.Action[](0); + uint256 proposalId = plugin.createProposal( + "ipfs://", + actions, + 0, + startDate, + 0 + ); + vm.warp(startDate + 1); + + assertEq( + plugin.hasVetoed(proposalId, alice), + false, + "Alice should not have vetoed" + ); + plugin.veto(proposalId); + assertEq( + plugin.hasVetoed(proposalId, alice), + true, + "Alice should have vetoed" + ); + + vm.expectRevert( + abi.encodeWithSelector( + OptimisticTokenVotingPlugin.ProposalVetoingForbidden.selector, + proposalId, + alice + ) + ); + plugin.veto(proposalId); + assertEq( + plugin.hasVetoed(proposalId, alice), + true, + "Alice should have vetoed" + ); + } + + function test_VetoRevertsWhenAVoterAlreadyEnded() public { + uint64 startDate = 50; + uint64 endDate = startDate + 10 days; + vm.warp(startDate - 1); + + IDAO.Action[] memory actions = new IDAO.Action[](0); + uint256 proposalId = plugin.createProposal( + "ipfs://", + actions, + 0, + startDate, + endDate + ); + vm.warp(endDate + 1); + + vm.expectRevert( + abi.encodeWithSelector( + OptimisticTokenVotingPlugin.ProposalVetoingForbidden.selector, + proposalId, + alice + ) + ); + plugin.veto(proposalId); + assertEq( + plugin.hasVetoed(proposalId, alice), + false, + "Alice should not have vetoed" + ); + } + + function test_VetoRevertsWhenNoVotingPower() public { + uint64 startDate = 50; + vm.warp(startDate - 1); + + IDAO.Action[] memory actions = new IDAO.Action[](0); + uint256 proposalId = plugin.createProposal( + "ipfs://", + actions, + 0, + startDate, + 0 + ); + + vm.warp(startDate + 1); + + vm.stopPrank(); + vm.startPrank(bob); + + // Bob owns no tokens + vm.expectRevert( + abi.encodeWithSelector( + OptimisticTokenVotingPlugin.ProposalVetoingForbidden.selector, + proposalId, + bob + ) + ); + plugin.veto(proposalId); + assertEq( + plugin.hasVetoed(proposalId, bob), + false, + "Bob should not have vetoed" + ); + + vm.stopPrank(); + vm.startPrank(alice); + + // Alice owns tokens + plugin.veto(proposalId); + assertEq( + plugin.hasVetoed(proposalId, alice), + true, + "Alice should have vetoed" + ); + } + + function test_VetoRegistersAVetoForTheTokenHolderAndIncreasesTheTally() + public + { + uint64 startDate = 50; + vm.warp(startDate - 1); + + IDAO.Action[] memory actions = new IDAO.Action[](0); + uint256 proposalId = plugin.createProposal( + "ipfs://", + actions, + 0, + startDate, + 0 + ); + vm.warp(startDate + 1); + + (, , , uint256 tally1, , ) = plugin.getProposal(proposalId); + assertEq(tally1, 0, "Tally should be zero"); + + plugin.veto(proposalId); + assertEq( + plugin.hasVetoed(proposalId, alice), + true, + "Alice should have vetoed" + ); + + (, , , uint256 tally2, , ) = plugin.getProposal(proposalId); + assertEq(tally2, 10 ether, "Tally should be 10 eth"); + } + + function test_VetoEmitsAnEvent() public { + uint64 startDate = 50; + vm.warp(startDate - 1); + + IDAO.Action[] memory actions = new IDAO.Action[](0); + uint256 proposalId = plugin.createProposal( + "ipfs://", + actions, + 0, + startDate, + 0 + ); + vm.warp(startDate + 1); + + vm.expectEmit(); + emit VetoCast(proposalId, alice, 10 ether); + plugin.veto(proposalId); + } + + // Has vetoed + function test_HasVetoedReturnsTheRightValues() public { + uint64 startDate = 50; + vm.warp(startDate - 1); + + IDAO.Action[] memory actions = new IDAO.Action[](0); + uint256 proposalId = plugin.createProposal( + "ipfs://", + actions, + 0, + startDate, + 0 + ); + vm.warp(startDate + 1); + + assertEq( + plugin.hasVetoed(proposalId, alice), + false, + "Alice should not have vetoed" + ); + plugin.veto(proposalId); + assertEq( + plugin.hasVetoed(proposalId, alice), + true, + "Alice should have vetoed" + ); + } + + // Can execute + function test_CanExecuteReturnsFalseWhenNotEnded() public { + uint64 startDate = 50; + uint64 endDate = startDate + 10 days; + vm.warp(startDate - 1); + + IDAO.Action[] memory actions = new IDAO.Action[](0); + uint256 proposalId = plugin.createProposal( + "ipfs://", + actions, + 0, + startDate, + endDate + ); + + assertEq( + plugin.canExecute(proposalId), + false, + "The proposal shouldn't be executable" + ); + + vm.warp(startDate + 1); + + assertEq( + plugin.canExecute(proposalId), + false, + "The proposal shouldn't be executable" + ); + plugin.veto(proposalId); + + assertEq( + plugin.canExecute(proposalId), + false, + "The proposal shouldn't be executable yet" + ); + } + + function test_CanExecuteReturnsFalseWhenDefeated() public { + uint64 startDate = 50; + uint64 endDate = startDate + 10 days; + vm.warp(startDate - 1); + + IDAO.Action[] memory actions = new IDAO.Action[](0); + uint256 proposalId = plugin.createProposal( + "ipfs://", + actions, + 0, + startDate, + endDate + ); + + assertEq( + plugin.canExecute(proposalId), + false, + "The proposal shouldn't be executable" + ); + + vm.warp(startDate + 1); + + plugin.veto(proposalId); + assertEq( + plugin.canExecute(proposalId), + false, + "The proposal shouldn't be executable yet" + ); + + vm.warp(endDate + 1); + + assertEq( + plugin.canExecute(proposalId), + false, + "The proposal shouldn't be executable" + ); + } + + function test_CanExecuteReturnsFalseWhenAlreadyExecuted() public { + uint64 startDate = 50; + uint64 endDate = startDate + 10 days; + vm.warp(startDate - 1); + + IDAO.Action[] memory actions = new IDAO.Action[](0); + uint256 proposalId = plugin.createProposal( + "ipfs://", + actions, + 0, + startDate, + endDate + ); + + vm.warp(endDate + 1); + assertEq( + plugin.canExecute(proposalId), + true, + "The proposal should be executable" + ); + + plugin.execute(proposalId); + + assertEq( + plugin.canExecute(proposalId), + false, + "The proposal shouldn't be executable" + ); + } + + function test_CanExecuteReturnsTrueOtherwise() public { + uint64 startDate = 50; + uint64 endDate = startDate + 10 days; + vm.warp(startDate - 1); + + IDAO.Action[] memory actions = new IDAO.Action[](0); + uint256 proposalId = plugin.createProposal( + "ipfs://", + actions, + 0, + startDate, + endDate + ); + + assertEq( + plugin.canExecute(proposalId), + false, + "The proposal shouldn't be executable" + ); + + vm.warp(startDate + 1); + + assertEq( + plugin.canExecute(proposalId), + false, + "The proposal shouldn't be executable yet" + ); + + vm.warp(endDate + 1); + + assertEq( + plugin.canExecute(proposalId), + true, + "The proposal should be executable" + ); + } + + // Veto threshold reached + function test_IsMinVetoRatioReachedReturnsTheAppropriateValues() public { + // Deploy ERC20 token + votingToken = ERC20VotesMock( + createProxyAndCall( + address(votingTokenBase), + abi.encodeWithSelector(ERC20VotesMock.initialize.selector) + ) + ); + votingToken.mint(alice, 24 ether); + votingToken.mint(bob, 1 ether); + votingToken.mint(randomWallet, 75 ether); + + votingToken.delegate(alice); + + vm.stopPrank(); + vm.startPrank(bob); + votingToken.delegate(bob); + + vm.stopPrank(); + vm.startPrank(randomWallet); + votingToken.delegate(randomWallet); + + vm.roll(block.number + 1); + + // Deploy a new plugin instance + OptimisticTokenVotingPlugin.OptimisticGovernanceSettings + memory settings = OptimisticTokenVotingPlugin + .OptimisticGovernanceSettings({ + minVetoRatio: uint32((RATIO_BASE * 25) / 100), + minDuration: 10 days, + minProposerVotingPower: 0 + }); + + plugin = OptimisticTokenVotingPlugin( + createProxyAndCall( + address(pluginBase), + abi.encodeWithSelector( + OptimisticTokenVotingPlugin.initialize.selector, + dao, + settings, + votingToken + ) + ) + ); + + vm.stopPrank(); + vm.startPrank(alice); + + // Permissions + dao.grant(address(dao), address(plugin), dao.EXECUTE_PERMISSION_ID()); + dao.grant(address(plugin), alice, plugin.PROPOSER_PERMISSION_ID()); + + uint64 startDate = 50; + uint64 endDate = startDate + 10 days; + vm.warp(startDate - 1); + + IDAO.Action[] memory actions = new IDAO.Action[](0); + uint256 proposalId = plugin.createProposal( + "ipfs://", + actions, + 0, + startDate, + endDate + ); + + vm.warp(startDate + 1); + + assertEq( + plugin.isMinVetoRatioReached(proposalId), + false, + "The veto threshold shouldn't be met" + ); + // Alice vetoes 24% + plugin.veto(proposalId); + + assertEq( + plugin.isMinVetoRatioReached(proposalId), + false, + "The veto threshold shouldn't be met" + ); + + vm.stopPrank(); + vm.startPrank(bob); + + // Bob vetoes +1% => met + plugin.veto(proposalId); + + assertEq( + plugin.isMinVetoRatioReached(proposalId), + true, + "The veto threshold should be met" + ); + + vm.stopPrank(); + vm.startPrank(randomWallet); + + // Random wallet vetoes +75% => still met + plugin.veto(proposalId); + + assertEq( + plugin.isMinVetoRatioReached(proposalId), + true, + "The veto threshold should still be met" + ); + } + + // Execute + function test_ExecuteRevertsWhenNotEnded() public { + uint64 startDate = 50; + uint64 endDate = startDate + 10 days; + vm.warp(startDate - 1); + + IDAO.Action[] memory actions = new IDAO.Action[](0); + uint256 proposalId = plugin.createProposal( + "ipfs://", + actions, + 0, + startDate, + endDate + ); + + vm.expectRevert( + abi.encodeWithSelector( + OptimisticTokenVotingPlugin.ProposalExecutionForbidden.selector, + proposalId + ) + ); + plugin.execute(proposalId); + + vm.warp(startDate + 1); + + vm.expectRevert( + abi.encodeWithSelector( + OptimisticTokenVotingPlugin.ProposalExecutionForbidden.selector, + proposalId + ) + ); + plugin.execute(proposalId); + + vm.warp(endDate); + plugin.execute(proposalId); + + (, bool executed, , , , ) = plugin.getProposal(proposalId); + assertEq(executed, true, "The proposal should be executed"); + } + + function test_ExecuteRevertsWhenDefeated() public { + uint64 startDate = 50; + uint64 endDate = startDate + 10 days; + vm.warp(startDate - 1); + + IDAO.Action[] memory actions = new IDAO.Action[](0); + uint256 proposalId = plugin.createProposal( + "ipfs://", + actions, + 0, + startDate, + endDate + ); + + vm.warp(startDate + 1); + + plugin.veto(proposalId); + + vm.expectRevert( + abi.encodeWithSelector( + OptimisticTokenVotingPlugin.ProposalExecutionForbidden.selector, + proposalId + ) + ); + plugin.execute(proposalId); + + vm.warp(endDate + 1); + + vm.expectRevert( + abi.encodeWithSelector( + OptimisticTokenVotingPlugin.ProposalExecutionForbidden.selector, + proposalId + ) + ); + plugin.execute(proposalId); + + (, bool executed, , , , ) = plugin.getProposal(proposalId); + assertEq(executed, false, "The proposal should not be executed"); + } + + function test_ExecuteRevertsWhenAlreadyExecuted() public { + uint64 startDate = 50; + uint64 endDate = startDate + 10 days; + vm.warp(startDate - 1); + + IDAO.Action[] memory actions = new IDAO.Action[](0); + uint256 proposalId = plugin.createProposal( + "ipfs://", + actions, + 0, + startDate, + endDate + ); + + vm.warp(endDate + 1); + + plugin.execute(proposalId); + + (, bool executed1, , , , ) = plugin.getProposal(proposalId); + assertEq(executed1, true, "The proposal should be executed"); + + vm.expectRevert( + abi.encodeWithSelector( + OptimisticTokenVotingPlugin.ProposalExecutionForbidden.selector, + proposalId + ) + ); + plugin.execute(proposalId); + + (, bool executed2, , , , ) = plugin.getProposal(proposalId); + assertEq(executed2, true, "The proposal should be executed"); + } + + function test_ExecuteSucceedsOtherwise() public { + uint64 startDate = 50; + uint64 endDate = startDate + 10 days; + vm.warp(startDate - 1); + + IDAO.Action[] memory actions = new IDAO.Action[](0); + uint256 proposalId = plugin.createProposal( + "ipfs://", + actions, + 0, + startDate, + endDate + ); + + vm.warp(endDate + 1); + + plugin.execute(proposalId); + } + + function test_ExecuteMarksTheProposalAsExecuted() public { + uint64 startDate = 50; + uint64 endDate = startDate + 10 days; + vm.warp(startDate - 1); + + IDAO.Action[] memory actions = new IDAO.Action[](0); + uint256 proposalId = plugin.createProposal( + "ipfs://", + actions, + 0, + startDate, + endDate + ); + + vm.warp(endDate + 1); + + plugin.execute(proposalId); + + (, bool executed2, , , , ) = plugin.getProposal(proposalId); + assertEq(executed2, true, "The proposal should be executed"); + } + + function test_ExecuteEmitsAnEvent() public { + uint64 startDate = 50; + uint64 endDate = startDate + 10 days; + vm.warp(startDate - 1); + + IDAO.Action[] memory actions = new IDAO.Action[](0); + uint256 proposalId = plugin.createProposal( + "ipfs://", + actions, + 0, + startDate, + endDate + ); + + vm.warp(endDate + 1); + + vm.expectEmit(); + emit ProposalExecuted(proposalId); + plugin.execute(proposalId); + } + + // Update settings + function test_UpdateOptimisticGovernanceSettingsRevertsWhenNoPermission() + public + { + OptimisticTokenVotingPlugin.OptimisticGovernanceSettings + memory newSettings = OptimisticTokenVotingPlugin + .OptimisticGovernanceSettings({ + minVetoRatio: uint32(RATIO_BASE / 5), + minDuration: 15 days, + minProposerVotingPower: 1 ether + }); + vm.expectRevert( + abi.encodeWithSelector( + DaoUnauthorized.selector, + address(dao), + address(plugin), + alice, + plugin.UPDATE_OPTIMISTIC_GOVERNANCE_SETTINGS_PERMISSION_ID() + ) + ); + plugin.updateOptimisticGovernanceSettings(newSettings); + + dao.grant( + address(plugin), + alice, + plugin.UPDATE_OPTIMISTIC_GOVERNANCE_SETTINGS_PERMISSION_ID() + ); + + plugin.updateOptimisticGovernanceSettings(newSettings); + } + + function test_UpdateOptimisticGovernanceSettingsRevertsWhenTheMinVetoRatioIsZero() + public + { + dao.grant( + address(plugin), + alice, + plugin.UPDATE_OPTIMISTIC_GOVERNANCE_SETTINGS_PERMISSION_ID() + ); + + OptimisticTokenVotingPlugin.OptimisticGovernanceSettings + memory newSettings = OptimisticTokenVotingPlugin + .OptimisticGovernanceSettings({ + minVetoRatio: 0, + minDuration: 10 days, + minProposerVotingPower: 0 ether + }); + vm.expectRevert( + abi.encodeWithSelector(RatioOutOfBounds.selector, 1, 0) + ); + plugin.updateOptimisticGovernanceSettings(newSettings); + } + + function test_UpdateOptimisticGovernanceSettingsRevertsWhenTheMinVetoRatioIsAboveTheMaximum() + public + { + dao.grant( + address(plugin), + alice, + plugin.UPDATE_OPTIMISTIC_GOVERNANCE_SETTINGS_PERMISSION_ID() + ); + + OptimisticTokenVotingPlugin.OptimisticGovernanceSettings + memory newSettings = OptimisticTokenVotingPlugin + .OptimisticGovernanceSettings({ + minVetoRatio: uint32(RATIO_BASE + 1), + minDuration: 10 days, + minProposerVotingPower: 0 ether + }); + vm.expectRevert( + abi.encodeWithSelector( + RatioOutOfBounds.selector, + RATIO_BASE, + uint32(RATIO_BASE + 1) + ) + ); + plugin.updateOptimisticGovernanceSettings(newSettings); + } + + function test_UpdateOptimisticGovernanceSettingsRevertsWhenTheMinDurationIsLessThanFourDays() + public + { + dao.grant( + address(plugin), + alice, + plugin.UPDATE_OPTIMISTIC_GOVERNANCE_SETTINGS_PERMISSION_ID() + ); + + OptimisticTokenVotingPlugin.OptimisticGovernanceSettings + memory newSettings = OptimisticTokenVotingPlugin + .OptimisticGovernanceSettings({ + minVetoRatio: uint32(RATIO_BASE / 10), + minDuration: 4 days - 1, + minProposerVotingPower: 0 ether + }); + vm.expectRevert( + abi.encodeWithSelector( + OptimisticTokenVotingPlugin.MinDurationOutOfBounds.selector, + 4 days, + 4 days - 1 + ) + ); + plugin.updateOptimisticGovernanceSettings(newSettings); + + // 2 + newSettings = OptimisticTokenVotingPlugin.OptimisticGovernanceSettings({ + minVetoRatio: uint32(RATIO_BASE / 10), + minDuration: 10 hours, + minProposerVotingPower: 0 ether + }); + vm.expectRevert( + abi.encodeWithSelector( + OptimisticTokenVotingPlugin.MinDurationOutOfBounds.selector, + 4 days, + 10 hours + ) + ); + plugin.updateOptimisticGovernanceSettings(newSettings); + + // 3 + newSettings = OptimisticTokenVotingPlugin.OptimisticGovernanceSettings({ + minVetoRatio: uint32(RATIO_BASE / 10), + minDuration: 0, + minProposerVotingPower: 0 ether + }); + vm.expectRevert( + abi.encodeWithSelector( + OptimisticTokenVotingPlugin.MinDurationOutOfBounds.selector, + 4 days, + 0 + ) + ); + plugin.updateOptimisticGovernanceSettings(newSettings); + } + + function test_UpdateOptimisticGovernanceSettingsRevertsWhenTheMinDurationIsMoreThanOneYear() + public + { + dao.grant( + address(plugin), + alice, + plugin.UPDATE_OPTIMISTIC_GOVERNANCE_SETTINGS_PERMISSION_ID() + ); + + OptimisticTokenVotingPlugin.OptimisticGovernanceSettings + memory newSettings = OptimisticTokenVotingPlugin + .OptimisticGovernanceSettings({ + minVetoRatio: uint32(RATIO_BASE / 10), + minDuration: 365 days + 1, + minProposerVotingPower: 0 ether + }); + vm.expectRevert( + abi.encodeWithSelector( + OptimisticTokenVotingPlugin.MinDurationOutOfBounds.selector, + 365 days, + 365 days + 1 + ) + ); + plugin.updateOptimisticGovernanceSettings(newSettings); + + // 2 + newSettings = OptimisticTokenVotingPlugin.OptimisticGovernanceSettings({ + minVetoRatio: uint32(RATIO_BASE / 10), + minDuration: 500 days, + minProposerVotingPower: 0 ether + }); + vm.expectRevert( + abi.encodeWithSelector( + OptimisticTokenVotingPlugin.MinDurationOutOfBounds.selector, + 365 days, + 500 days + ) + ); + plugin.updateOptimisticGovernanceSettings(newSettings); + + // 3 + newSettings = OptimisticTokenVotingPlugin.OptimisticGovernanceSettings({ + minVetoRatio: uint32(RATIO_BASE / 10), + minDuration: 1000 days, + minProposerVotingPower: 0 ether + }); + vm.expectRevert( + abi.encodeWithSelector( + OptimisticTokenVotingPlugin.MinDurationOutOfBounds.selector, + 365 days, + 1000 days + ) + ); + plugin.updateOptimisticGovernanceSettings(newSettings); + } + + function test_UpdateOptimisticGovernanceSettingsEmitsAnEventWhenSuccessful() + public + { + dao.grant( + address(plugin), + alice, + plugin.UPDATE_OPTIMISTIC_GOVERNANCE_SETTINGS_PERMISSION_ID() + ); + + OptimisticTokenVotingPlugin.OptimisticGovernanceSettings + memory newSettings = OptimisticTokenVotingPlugin + .OptimisticGovernanceSettings({ + minVetoRatio: uint32(RATIO_BASE / 5), + minDuration: 15 days, + minProposerVotingPower: 1 ether + }); + + vm.expectEmit(); + emit OptimisticGovernanceSettingsUpdated({ + minVetoRatio: uint32(RATIO_BASE / 5), + minDuration: 15 days, + minProposerVotingPower: 1 ether + }); + + plugin.updateOptimisticGovernanceSettings(newSettings); + } + + // Upgrade plugin + function test_UpgradeToRevertsWhenCalledFromNonUpgrader() public { + address _pluginBase = address(new OptimisticTokenVotingPlugin()); + + vm.expectRevert( + abi.encodeWithSelector( + DaoUnauthorized.selector, + address(dao), + address(plugin), + alice, + plugin.UPGRADE_PLUGIN_PERMISSION_ID() + ) + ); + + plugin.upgradeTo(_pluginBase); + } + + function test_UpgradeToAndCallRevertsWhenCalledFromNonUpgrader() public { + dao.grant( + address(plugin), + alice, + plugin.UPDATE_OPTIMISTIC_GOVERNANCE_SETTINGS_PERMISSION_ID() + ); + + address _pluginBase = address(new OptimisticTokenVotingPlugin()); + + OptimisticTokenVotingPlugin.OptimisticGovernanceSettings + memory settings = OptimisticTokenVotingPlugin + .OptimisticGovernanceSettings({ + minVetoRatio: uint32(RATIO_BASE / 5), + minDuration: 15 days, + minProposerVotingPower: 1 ether + }); + + vm.expectRevert( + abi.encodeWithSelector( + DaoUnauthorized.selector, + address(dao), + address(plugin), + alice, + plugin.UPGRADE_PLUGIN_PERMISSION_ID() + ) + ); + + plugin.upgradeToAndCall( + _pluginBase, + abi.encodeWithSelector( + OptimisticTokenVotingPlugin + .updateOptimisticGovernanceSettings + .selector, + settings + ) + ); + } + + function test_UpgradeToSucceedsWhenCalledFromUpgrader() public { + dao.grant( + address(plugin), + alice, + plugin.UPGRADE_PLUGIN_PERMISSION_ID() + ); + + address _pluginBase = address(new OptimisticTokenVotingPlugin()); + + vm.expectEmit(); + emit Upgraded(_pluginBase); + + plugin.upgradeTo(_pluginBase); + } + + function test_UpgradeToAndCallSucceedsWhenCalledFromUpgrader() public { + dao.grant( + address(plugin), + alice, + plugin.UPGRADE_PLUGIN_PERMISSION_ID() + ); + dao.grant( + address(plugin), + alice, + plugin.UPDATE_OPTIMISTIC_GOVERNANCE_SETTINGS_PERMISSION_ID() + ); + + address _pluginBase = address(new OptimisticTokenVotingPlugin()); + + vm.expectEmit(); + emit Upgraded(_pluginBase); + + OptimisticTokenVotingPlugin.OptimisticGovernanceSettings + memory settings = OptimisticTokenVotingPlugin + .OptimisticGovernanceSettings({ + minVetoRatio: uint32(RATIO_BASE / 5), + minDuration: 15 days, + minProposerVotingPower: 1 ether + }); + plugin.upgradeToAndCall( + _pluginBase, + abi.encodeWithSelector( + OptimisticTokenVotingPlugin + .updateOptimisticGovernanceSettings + .selector, + settings + ) + ); + } + + // 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 new file mode 100644 index 0000000..baf5238 --- /dev/null +++ b/test/OptimisticTokenVotingPluginSetup.t.sol @@ -0,0 +1,957 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.17; + +import {Test, console2} from "forge-std/Test.sol"; +import {OptimisticTokenVotingPlugin} from "../src/OptimisticTokenVotingPlugin.sol"; +import {OptimisticTokenVotingPluginSetup} from "../src/OptimisticTokenVotingPluginSetup.sol"; +import {GovernanceERC20} from "@aragon/osx/token/ERC20/governance/GovernanceERC20.sol"; +import {GovernanceWrappedERC20} from "@aragon/osx/token/ERC20/governance/GovernanceWrappedERC20.sol"; +import {IERC20Upgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC20/IERC20Upgradeable.sol"; +import {IDAO} from "@aragon/osx/core/dao/IDAO.sol"; +import {RATIO_BASE} from "@aragon/osx/plugins/utils/Ratio.sol"; +import {DAO} from "@aragon/osx/core/dao/DAO.sol"; +import {IPluginSetup} from "@aragon/osx/framework/plugin/setup/PluginSetup.sol"; +import {PermissionLib} from "@aragon/osx/core/permission/PermissionLib.sol"; +import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import {ERC20Mock} from "./mocks/ERC20Mock.sol"; + +contract OptimisticTokenVotingPluginSetupTest is Test { + OptimisticTokenVotingPluginSetup public pluginSetup; + GovernanceERC20 governanceERC20Base; + GovernanceWrappedERC20 governanceWrappedERC20Base; + address immutable daoBase = address(new DAO()); + DAO dao; + + // Recycled installation parameters + OptimisticTokenVotingPlugin.OptimisticGovernanceSettings votingSettings; + OptimisticTokenVotingPluginSetup.TokenSettings tokenSettings; + GovernanceERC20.MintSettings mintSettings; + address[] proposers; + + address alice = address(0xa11ce); + + error Unimplemented(); + + function setUp() public { + if (address(governanceERC20Base) == address(0x0)) { + // Base + GovernanceERC20.MintSettings memory _mintSettings = GovernanceERC20 + .MintSettings(new address[](0), new uint256[](0)); + governanceERC20Base = new GovernanceERC20( + IDAO(address(0x0)), + "", + "", + _mintSettings + ); + // Base + governanceWrappedERC20Base = new GovernanceWrappedERC20( + IERC20Upgradeable(address(0x0)), + "", + "" + ); + dao = DAO( + payable( + createProxyAndCall( + address(daoBase), + abi.encodeWithSelector( + DAO.initialize.selector, + "", + alice, + address(0x0), + "" + ) + ) + ) + ); + } + + pluginSetup = new OptimisticTokenVotingPluginSetup( + governanceERC20Base, + governanceWrappedERC20Base + ); + + // Default params + votingSettings = OptimisticTokenVotingPlugin + .OptimisticGovernanceSettings({ + minVetoRatio: uint32(RATIO_BASE / 10), + minDuration: 5 days, + minProposerVotingPower: 0 + }); + tokenSettings = OptimisticTokenVotingPluginSetup.TokenSettings({ + addr: address(governanceERC20Base), + name: "Wrapped Token", + symbol: "wTK" + }); + mintSettings = GovernanceERC20.MintSettings({ + receivers: new address[](0), + amounts: new uint256[](0) + }); + proposers = new address[](1); + proposers[0] = address(0x1234567890); + } + + function test_ShouldEncodeInstallationParams_Default() public { + // Default + bytes memory output = pluginSetup.encodeInstallationParams( + votingSettings, + tokenSettings, + mintSettings, + proposers + ); + + bytes + memory expected = hex"00000000000000000000000000000000000000000000000000000000000186a00000000000000000000000000000000000000000000000000000000000069780000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c000000000000000000000000000000000000000000000000000000000000001a000000000000000000000000000000000000000000000000000000000000002200000000000000000000000002e234dae75c793f67a35089c9d99245e1c58470b000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000a0000000000000000000000000000000000000000000000000000000000000000d5772617070656420546f6b656e00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000377544b0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000001234567890"; + assertEq(output, expected, "Incorrect encoded bytes"); + } + + function test_ShouldEncodeInstallationParams_1() public { + // Custom 1 + votingSettings = OptimisticTokenVotingPlugin + .OptimisticGovernanceSettings({ + minVetoRatio: uint32(RATIO_BASE / 5), + minDuration: 60 * 60 * 24 * 5, + minProposerVotingPower: 123456 + }); + bytes memory output = pluginSetup.encodeInstallationParams( + votingSettings, + tokenSettings, + mintSettings, + proposers + ); + + bytes + memory expected = hex"0000000000000000000000000000000000000000000000000000000000030d400000000000000000000000000000000000000000000000000000000000069780000000000000000000000000000000000000000000000000000000000001e24000000000000000000000000000000000000000000000000000000000000000c000000000000000000000000000000000000000000000000000000000000001a000000000000000000000000000000000000000000000000000000000000002200000000000000000000000002e234dae75c793f67a35089c9d99245e1c58470b000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000a0000000000000000000000000000000000000000000000000000000000000000d5772617070656420546f6b656e00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000377544b0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000001234567890"; + assertEq(output, expected, "Incorrect encoded bytes"); + } + + function test_ShouldEncodeInstallationParams_2() public { + // Custom 2 + tokenSettings = OptimisticTokenVotingPluginSetup.TokenSettings({ + addr: address(0x5678), + name: "Wrapped New Coin", + symbol: "wNCN" + }); + bytes memory output = pluginSetup.encodeInstallationParams( + votingSettings, + tokenSettings, + mintSettings, + proposers + ); + + bytes + memory expected = hex"00000000000000000000000000000000000000000000000000000000000186a00000000000000000000000000000000000000000000000000000000000069780000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c000000000000000000000000000000000000000000000000000000000000001a000000000000000000000000000000000000000000000000000000000000002200000000000000000000000000000000000000000000000000000000000005678000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000a0000000000000000000000000000000000000000000000000000000000000001057726170706564204e657720436f696e000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000004774e434e00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000001234567890"; + assertEq(output, expected, "Incorrect encoded bytes"); + } + + function test_ShouldEncodeInstallationParams_3() public { + // Custom 3 + address[] memory receivers = new address[](1); + receivers[0] = address(0x6789); + uint256[] memory amounts = new uint256[](1); + amounts[0] = 1234567890; + + mintSettings = GovernanceERC20.MintSettings({ + receivers: receivers, + amounts: amounts + }); + bytes memory output = pluginSetup.encodeInstallationParams( + votingSettings, + tokenSettings, + mintSettings, + proposers + ); + + bytes + memory expected = hex"00000000000000000000000000000000000000000000000000000000000186a00000000000000000000000000000000000000000000000000000000000069780000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c000000000000000000000000000000000000000000000000000000000000001a000000000000000000000000000000000000000000000000000000000000002600000000000000000000000002e234dae75c793f67a35089c9d99245e1c58470b000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000a0000000000000000000000000000000000000000000000000000000000000000d5772617070656420546f6b656e00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000377544b00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000006789000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000499602d200000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000001234567890"; + assertEq(output, expected, "Incorrect encoded bytes"); + } + + function test_ShouldEncodeInstallationParams_4() public { + // Custom 4 + proposers = new address[](1); + proposers[0] = address(0x567890); + + bytes memory output = pluginSetup.encodeInstallationParams( + votingSettings, + tokenSettings, + mintSettings, + proposers + ); + + bytes + memory expected = hex"00000000000000000000000000000000000000000000000000000000000186a00000000000000000000000000000000000000000000000000000000000069780000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c000000000000000000000000000000000000000000000000000000000000001a000000000000000000000000000000000000000000000000000000000000002200000000000000000000000002e234dae75c793f67a35089c9d99245e1c58470b000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000a0000000000000000000000000000000000000000000000000000000000000000d5772617070656420546f6b656e00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000377544b0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000567890"; + assertEq(output, expected, "Incorrect encoded bytes"); + } + + function test_ShouldDecodeInstallationParams() public { + votingSettings = OptimisticTokenVotingPlugin + .OptimisticGovernanceSettings({ + minVetoRatio: uint32(RATIO_BASE / 4), + minDuration: 10 days, + minProposerVotingPower: 55555555 + }); + tokenSettings = OptimisticTokenVotingPluginSetup.TokenSettings({ + addr: address(governanceWrappedERC20Base), + name: "Super wToken", + symbol: "SwTK" + }); + address[] memory receivers = new address[](2); + receivers[0] = address(0x1234); + receivers[1] = address(0x5678); + uint256[] memory amounts = new uint256[](2); + amounts[0] = 2000; + amounts[1] = 5000; + mintSettings = GovernanceERC20.MintSettings({ + receivers: receivers, + amounts: amounts + }); + proposers = new address[](2); + proposers[0] = address(0x3456); + proposers[1] = address(0x7890); + + bytes memory _installationParams = pluginSetup.encodeInstallationParams( + votingSettings, + tokenSettings, + // only used for GovernanceERC20 (when a token is not passed) + mintSettings, + proposers + ); + + // Decode + ( + OptimisticTokenVotingPlugin.OptimisticGovernanceSettings + memory _votingSettings, + OptimisticTokenVotingPluginSetup.TokenSettings + memory _tokenSettings, + GovernanceERC20.MintSettings memory _mintSettings, + address[] memory _proposers + ) = pluginSetup.decodeInstallationParams(_installationParams); + + // Voting + assertEq( + _votingSettings.minVetoRatio, + uint32(RATIO_BASE / 4), + "Incorrect ratio" + ); + assertEq( + _votingSettings.minDuration, + 10 days, + "Incorrect min duration" + ); + assertEq( + _votingSettings.minProposerVotingPower, + 55555555, + "Incorrect min voting power" + ); + + // Token + assertEq( + _tokenSettings.addr, + address(governanceWrappedERC20Base), + "Incorrect token address" + ); + assertEq( + _tokenSettings.name, + "Super wToken", + "Incorrect token address" + ); + assertEq(_tokenSettings.symbol, "SwTK", "Incorrect token address"); + + // Mint + assertEq( + _mintSettings.receivers.length, + 2, + "Incorrect receivers.length" + ); + assertEq( + _mintSettings.receivers[0], + address(0x1234), + "Incorrect receivers[0]" + ); + assertEq( + _mintSettings.receivers[1], + address(0x5678), + "Incorrect receivers[1]" + ); + assertEq(_mintSettings.amounts.length, 2, "Incorrect amounts.length"); + assertEq(_mintSettings.amounts[0], 2000, "Incorrect amounts[0]"); + assertEq(_mintSettings.amounts[1], 5000, "Incorrect amounts[1]"); + + // Proposers + assertEq(_proposers.length, 2, "Incorrect proposers.length"); + assertEq(_proposers[0], address(0x3456), "Incorrect proposers[0]"); + assertEq(_proposers[1], address(0x7890), "Incorrect proposers[1]"); + } + + function test_PrepareInstallationReturnsTheProperPermissions_Default() + public + { + bytes memory installationParams = pluginSetup.encodeInstallationParams( + votingSettings, + tokenSettings, + // only used for GovernanceERC20 (when a token is not passed) + mintSettings, + proposers + ); + + ( + address _plugin, + IPluginSetup.PreparedSetupData memory _preparedSetupData + ) = pluginSetup.prepareInstallation(address(dao), installationParams); + + assertEq( + _plugin != address(0), + true, + "Plugin address should not be zero" + ); + assertEq(_preparedSetupData.helpers.length, 1, "One helper expected"); + assertEq( + _preparedSetupData.permissions.length, + 3 + 1, // base + proposers + "Incorrect permission length" + ); + // 1 + assertEq( + uint256(_preparedSetupData.permissions[0].operation), + uint256(PermissionLib.Operation.Grant), + "Incorrect operation" + ); + assertEq(_preparedSetupData.permissions[0].where, _plugin); + assertEq(_preparedSetupData.permissions[0].who, address(dao)); + assertEq(_preparedSetupData.permissions[0].condition, address(0)); + assertEq( + _preparedSetupData.permissions[0].permissionId, + keccak256("UPDATE_OPTIMISTIC_GOVERNANCE_SETTINGS_PERMISSION") + ); + // 2 + assertEq( + uint256(_preparedSetupData.permissions[1].operation), + uint256(PermissionLib.Operation.Grant), + "Incorrect operation" + ); + assertEq(_preparedSetupData.permissions[1].where, _plugin); + assertEq(_preparedSetupData.permissions[1].who, address(dao)); + assertEq(_preparedSetupData.permissions[1].condition, address(0)); + assertEq( + _preparedSetupData.permissions[1].permissionId, + keccak256("UPGRADE_PLUGIN_PERMISSION") + ); + // 3 + assertEq( + uint256(_preparedSetupData.permissions[2].operation), + uint256(PermissionLib.Operation.Grant), + "Incorrect operation" + ); + assertEq(_preparedSetupData.permissions[2].where, address(dao)); + assertEq(_preparedSetupData.permissions[2].who, _plugin); + assertEq(_preparedSetupData.permissions[2].condition, address(0)); + assertEq( + _preparedSetupData.permissions[2].permissionId, + keccak256("EXECUTE_PERMISSION") + ); + // proposer 1 + assertEq( + uint256(_preparedSetupData.permissions[3].operation), + uint256(PermissionLib.Operation.Grant), + "Incorrect operation" + ); + assertEq(_preparedSetupData.permissions[3].where, _plugin); + assertEq(_preparedSetupData.permissions[3].who, address(proposers[0])); + assertEq(_preparedSetupData.permissions[3].condition, address(0)); + assertEq( + _preparedSetupData.permissions[3].permissionId, + keccak256("PROPOSER_PERMISSION") + ); + + // no more: no minted token + } + + function test_PrepareInstallationReturnsTheProperPermissions_UseToken() + public + { + votingSettings = OptimisticTokenVotingPlugin + .OptimisticGovernanceSettings({ + minVetoRatio: uint32(RATIO_BASE / 4), + minDuration: 10 days, + minProposerVotingPower: 0 + }); + tokenSettings = OptimisticTokenVotingPluginSetup.TokenSettings({ + addr: address(governanceWrappedERC20Base), + name: "", + symbol: "" + }); + proposers = new address[](2); + proposers[0] = address(0x3456); + proposers[1] = address(0x7890); + + bytes memory installationParams = pluginSetup.encodeInstallationParams( + votingSettings, + tokenSettings, + // only used for GovernanceERC20 (when a token is not passed) + mintSettings, + proposers + ); + + ( + address _plugin, + IPluginSetup.PreparedSetupData memory _preparedSetupData + ) = pluginSetup.prepareInstallation(address(dao), installationParams); + + assertEq( + _plugin != address(0), + true, + "Plugin address should not be zero" + ); + assertEq(_preparedSetupData.helpers.length, 1, "One helper expected"); + assertEq( + _preparedSetupData.permissions.length, + 3 + 2, // base + proposers + "Incorrect permission length" + ); + // 1 + assertEq( + uint256(_preparedSetupData.permissions[0].operation), + uint256(PermissionLib.Operation.Grant), + "Incorrect operation" + ); + assertEq(_preparedSetupData.permissions[0].where, _plugin); + assertEq(_preparedSetupData.permissions[0].who, address(dao)); + assertEq(_preparedSetupData.permissions[0].condition, address(0)); + assertEq( + _preparedSetupData.permissions[0].permissionId, + keccak256("UPDATE_OPTIMISTIC_GOVERNANCE_SETTINGS_PERMISSION") + ); + // 2 + assertEq( + uint256(_preparedSetupData.permissions[1].operation), + uint256(PermissionLib.Operation.Grant), + "Incorrect operation" + ); + assertEq(_preparedSetupData.permissions[1].where, _plugin); + assertEq(_preparedSetupData.permissions[1].who, address(dao)); + assertEq(_preparedSetupData.permissions[1].condition, address(0)); + assertEq( + _preparedSetupData.permissions[1].permissionId, + keccak256("UPGRADE_PLUGIN_PERMISSION") + ); + // 3 + assertEq( + uint256(_preparedSetupData.permissions[2].operation), + uint256(PermissionLib.Operation.Grant), + "Incorrect operation" + ); + assertEq(_preparedSetupData.permissions[2].where, address(dao)); + assertEq(_preparedSetupData.permissions[2].who, _plugin); + assertEq(_preparedSetupData.permissions[2].condition, address(0)); + assertEq( + _preparedSetupData.permissions[2].permissionId, + keccak256("EXECUTE_PERMISSION") + ); + // proposer 1 + assertEq( + uint256(_preparedSetupData.permissions[3].operation), + uint256(PermissionLib.Operation.Grant), + "Incorrect operation" + ); + assertEq(_preparedSetupData.permissions[3].where, _plugin); + assertEq(_preparedSetupData.permissions[3].who, address(proposers[0])); + assertEq(_preparedSetupData.permissions[3].condition, address(0)); + assertEq( + _preparedSetupData.permissions[3].permissionId, + keccak256("PROPOSER_PERMISSION") + ); + // proposer 2 + assertEq( + uint256(_preparedSetupData.permissions[4].operation), + uint256(PermissionLib.Operation.Grant), + "Incorrect operation" + ); + assertEq(_preparedSetupData.permissions[4].where, _plugin); + assertEq(_preparedSetupData.permissions[4].who, address(proposers[1])); + assertEq(_preparedSetupData.permissions[4].condition, address(0)); + assertEq( + _preparedSetupData.permissions[4].permissionId, + keccak256("PROPOSER_PERMISSION") + ); + + // no more: no minted token + } + + function test_PrepareInstallationReturnsTheProperPermissions_MintToken() + public + { + votingSettings = OptimisticTokenVotingPlugin + .OptimisticGovernanceSettings({ + minVetoRatio: uint32(RATIO_BASE / 4), + minDuration: 10 days, + minProposerVotingPower: 4000 + }); + tokenSettings = OptimisticTokenVotingPluginSetup.TokenSettings({ + addr: address(0x0), + name: "Wrapped Super New Token", + symbol: "wSNTK" + }); + address[] memory receivers = new address[](2); + receivers[0] = address(0x1234); + receivers[1] = address(0x5678); + uint256[] memory amounts = new uint256[](2); + amounts[0] = 2000; + amounts[1] = 5000; + mintSettings = GovernanceERC20.MintSettings({ + receivers: receivers, + amounts: amounts + }); + proposers = new address[](2); + proposers[0] = address(0x3456); + proposers[1] = address(0x7890); + + bytes memory installationParams = pluginSetup.encodeInstallationParams( + votingSettings, + tokenSettings, + // only used for GovernanceERC20 (when a token is not passed) + mintSettings, + proposers + ); + + ( + address _plugin, + IPluginSetup.PreparedSetupData memory _preparedSetupData + ) = pluginSetup.prepareInstallation(address(dao), installationParams); + + assertEq( + _plugin != address(0), + true, + "Plugin address should not be zero" + ); + assertEq(_preparedSetupData.helpers.length, 1, "One helper expected"); + assertEq( + _preparedSetupData.permissions.length, + 3 + 2 + 1, // base + proposers + "Incorrect permission length" + ); + // 1 + assertEq( + uint256(_preparedSetupData.permissions[0].operation), + uint256(PermissionLib.Operation.Grant), + "Incorrect operation" + ); + assertEq(_preparedSetupData.permissions[0].where, _plugin); + assertEq(_preparedSetupData.permissions[0].who, address(dao)); + assertEq(_preparedSetupData.permissions[0].condition, address(0)); + assertEq( + _preparedSetupData.permissions[0].permissionId, + keccak256("UPDATE_OPTIMISTIC_GOVERNANCE_SETTINGS_PERMISSION") + ); + // 2 + assertEq( + uint256(_preparedSetupData.permissions[1].operation), + uint256(PermissionLib.Operation.Grant), + "Incorrect operation" + ); + assertEq(_preparedSetupData.permissions[1].where, _plugin); + assertEq(_preparedSetupData.permissions[1].who, address(dao)); + assertEq(_preparedSetupData.permissions[1].condition, address(0)); + assertEq( + _preparedSetupData.permissions[1].permissionId, + keccak256("UPGRADE_PLUGIN_PERMISSION") + ); + // 3 + assertEq( + uint256(_preparedSetupData.permissions[2].operation), + uint256(PermissionLib.Operation.Grant), + "Incorrect operation" + ); + assertEq(_preparedSetupData.permissions[2].where, address(dao)); + assertEq(_preparedSetupData.permissions[2].who, _plugin); + assertEq(_preparedSetupData.permissions[2].condition, address(0)); + assertEq( + _preparedSetupData.permissions[2].permissionId, + keccak256("EXECUTE_PERMISSION") + ); + // proposer 1 + assertEq( + uint256(_preparedSetupData.permissions[3].operation), + uint256(PermissionLib.Operation.Grant), + "Incorrect operation" + ); + assertEq(_preparedSetupData.permissions[3].where, _plugin); + assertEq(_preparedSetupData.permissions[3].who, address(proposers[0])); + assertEq(_preparedSetupData.permissions[3].condition, address(0)); + assertEq( + _preparedSetupData.permissions[3].permissionId, + keccak256("PROPOSER_PERMISSION") + ); + // proposer 2 + assertEq( + uint256(_preparedSetupData.permissions[4].operation), + uint256(PermissionLib.Operation.Grant), + "Incorrect operation" + ); + assertEq(_preparedSetupData.permissions[4].where, _plugin); + assertEq(_preparedSetupData.permissions[4].who, address(proposers[1])); + assertEq(_preparedSetupData.permissions[4].condition, address(0)); + assertEq( + _preparedSetupData.permissions[4].permissionId, + keccak256("PROPOSER_PERMISSION") + ); + + // minting + assertEq( + uint256(_preparedSetupData.permissions[5].operation), + uint256(PermissionLib.Operation.Grant), + "Incorrect operation" + ); + assertEq( + _preparedSetupData.permissions[5].where, + _preparedSetupData.helpers[0] + ); + assertEq(_preparedSetupData.permissions[5].who, address(dao)); + assertEq(_preparedSetupData.permissions[5].condition, address(0)); + assertEq( + _preparedSetupData.permissions[5].permissionId, + keccak256("MINT_PERMISSION") + ); + } + + function test_PrepareUninstallationReturnsTheProperPermissions_1() public { + // Prepare a dummy install + bytes memory installationParams = pluginSetup.encodeInstallationParams( + votingSettings, + tokenSettings, + mintSettings, + proposers + ); + ( + address _dummyPlugin, + IPluginSetup.PreparedSetupData memory _preparedSetupData + ) = pluginSetup.prepareInstallation(address(dao), installationParams); + + OptimisticTokenVotingPluginSetup.SetupPayload + memory _payload = IPluginSetup.SetupPayload({ + plugin: _dummyPlugin, + currentHelpers: _preparedSetupData.helpers, + data: hex"" + }); + + // Check uninstall + PermissionLib.MultiTargetPermission[] + memory _permissionChanges = pluginSetup.prepareUninstallation( + address(dao), + _payload + ); + + assertEq( + _permissionChanges.length, + 4, + "Incorrect permission changes length" + ); + // 1 + assertEq( + uint256(_permissionChanges[0].operation), + uint256(PermissionLib.Operation.Revoke), + "Incorrect operation" + ); + assertEq(_permissionChanges[0].where, _dummyPlugin); + assertEq(_permissionChanges[0].who, address(dao)); + assertEq(_permissionChanges[0].condition, address(0)); + assertEq( + _permissionChanges[0].permissionId, + keccak256("UPDATE_OPTIMISTIC_GOVERNANCE_SETTINGS_PERMISSION") + ); + // 2 + assertEq( + uint256(_permissionChanges[1].operation), + uint256(PermissionLib.Operation.Revoke), + "Incorrect operation" + ); + assertEq(_permissionChanges[1].where, _dummyPlugin); + assertEq(_permissionChanges[1].who, address(dao)); + assertEq(_permissionChanges[1].condition, address(0)); + assertEq( + _permissionChanges[1].permissionId, + keccak256("UPGRADE_PLUGIN_PERMISSION") + ); + // 3 + assertEq( + uint256(_permissionChanges[2].operation), + uint256(PermissionLib.Operation.Revoke), + "Incorrect operation" + ); + assertEq(_permissionChanges[2].where, address(dao)); + assertEq(_permissionChanges[2].who, _dummyPlugin); + assertEq(_permissionChanges[2].condition, address(0)); + assertEq( + _permissionChanges[2].permissionId, + keccak256("EXECUTE_PERMISSION") + ); + // minting 1 + assertEq( + uint256(_permissionChanges[3].operation), + uint256(PermissionLib.Operation.Revoke), + "Incorrect operation" + ); + assertEq( + _permissionChanges[3].where, + address(governanceERC20Base), + "Incorrect where" + ); + assertEq(_permissionChanges[3].who, address(dao), "Incorrect who"); + assertEq( + _permissionChanges[3].condition, + address(0), + "Incorrect condition" + ); + assertEq( + _permissionChanges[3].permissionId, + keccak256("MINT_PERMISSION"), + "Incorrect permission" + ); + } + + function test_PrepareUninstallationReturnsTheProperPermissions_2() public { + // Prepare a dummy install + tokenSettings = OptimisticTokenVotingPluginSetup.TokenSettings({ + addr: address(0x0), + name: "Dummy Token", + symbol: "DTK" + }); + bytes memory installationParams = pluginSetup.encodeInstallationParams( + votingSettings, + tokenSettings, + mintSettings, + proposers + ); + ( + address _dummyPlugin, + IPluginSetup.PreparedSetupData memory _preparedSetupData + ) = pluginSetup.prepareInstallation(address(dao), installationParams); + + OptimisticTokenVotingPluginSetup.SetupPayload + memory _payload = IPluginSetup.SetupPayload({ + plugin: _dummyPlugin, + currentHelpers: _preparedSetupData.helpers, + data: hex"" + }); + + // Check uninstall + PermissionLib.MultiTargetPermission[] + memory _permissionChanges = pluginSetup.prepareUninstallation( + address(dao), + _payload + ); + + assertEq( + _permissionChanges.length, + 4, + "Incorrect permission changes length" + ); + // 1 + assertEq( + uint256(_permissionChanges[0].operation), + uint256(PermissionLib.Operation.Revoke), + "Incorrect operation" + ); + assertEq(_permissionChanges[0].where, _dummyPlugin); + assertEq(_permissionChanges[0].who, address(dao)); + assertEq(_permissionChanges[0].condition, address(0)); + assertEq( + _permissionChanges[0].permissionId, + keccak256("UPDATE_OPTIMISTIC_GOVERNANCE_SETTINGS_PERMISSION") + ); + // 2 + assertEq( + uint256(_permissionChanges[1].operation), + uint256(PermissionLib.Operation.Revoke), + "Incorrect operation" + ); + assertEq(_permissionChanges[1].where, _dummyPlugin); + assertEq(_permissionChanges[1].who, address(dao)); + assertEq(_permissionChanges[1].condition, address(0)); + assertEq( + _permissionChanges[1].permissionId, + keccak256("UPGRADE_PLUGIN_PERMISSION") + ); + // 3 + assertEq( + uint256(_permissionChanges[2].operation), + uint256(PermissionLib.Operation.Revoke), + "Incorrect operation" + ); + assertEq(_permissionChanges[2].where, address(dao)); + assertEq(_permissionChanges[2].who, _dummyPlugin); + assertEq(_permissionChanges[2].condition, address(0)); + assertEq( + _permissionChanges[2].permissionId, + keccak256("EXECUTE_PERMISSION") + ); + // minting 1 + assertEq( + uint256(_permissionChanges[3].operation), + uint256(PermissionLib.Operation.Revoke), + "Incorrect operation" + ); + assertEq( + _permissionChanges[3].where, + _preparedSetupData.helpers[0], + "Incorrect where" + ); + assertEq(_permissionChanges[3].who, address(dao), "Incorrect who"); + assertEq( + _permissionChanges[3].condition, + address(0), + "Incorrect condition" + ); + assertEq( + _permissionChanges[3].permissionId, + keccak256("MINT_PERMISSION"), + "Incorrect permission" + ); + } + + function test_CreatesANewERC20Token() public { + // new Token + tokenSettings = OptimisticTokenVotingPluginSetup.TokenSettings({ + addr: address(0x0), + name: "New Token", + symbol: "NTK" + }); + + address[] memory receivers = new address[](1); + receivers[0] = address(0x1234); + uint256[] memory amounts = new uint256[](1); + amounts[0] = 100; + mintSettings = GovernanceERC20.MintSettings({ + receivers: receivers, + amounts: amounts + }); + bytes memory installationParams = pluginSetup.encodeInstallationParams( + votingSettings, + tokenSettings, + // only used for GovernanceERC20 (when a token is not passed) + mintSettings, + proposers + ); + ( + , + IPluginSetup.PreparedSetupData memory _preparedSetupData + ) = pluginSetup.prepareInstallation(address(dao), installationParams); + + GovernanceERC20 _token = GovernanceERC20(_preparedSetupData.helpers[0]); + assertEq(_token.balanceOf(address(0x1234)), 100); + assertEq(_token.balanceOf(address(0x5678)), 0); + assertEq(_token.balanceOf(address(0x0)), 0); + + assertEq(_token.name(), "New Token"); + assertEq(_token.symbol(), "NTK"); + } + + function test_WrapsAnExistingToken() public { + // Wrap existing token + ERC20Mock _originalToken = new ERC20Mock(); + _originalToken.mint(address(0x1234), 100); + _originalToken.mint(address(0x5678), 200); + assertEq(_originalToken.balanceOf(address(0x1234)), 100); + assertEq(_originalToken.balanceOf(address(0x5678)), 200); + + tokenSettings = OptimisticTokenVotingPluginSetup.TokenSettings({ + addr: address(_originalToken), + name: "Wrapped Mock Token", + symbol: "wMTK" + }); + bytes memory installationParams = pluginSetup.encodeInstallationParams( + votingSettings, + tokenSettings, + mintSettings, + proposers + ); + ( + , + IPluginSetup.PreparedSetupData memory _preparedSetupData + ) = pluginSetup.prepareInstallation(address(dao), installationParams); + + GovernanceWrappedERC20 _wrappedToken = GovernanceWrappedERC20( + _preparedSetupData.helpers[0] + ); + assertEq(_wrappedToken.balanceOf(address(0x1234)), 0); + assertEq(_wrappedToken.balanceOf(address(0x5678)), 0); + assertEq(_wrappedToken.balanceOf(address(0x0)), 0); + + assertEq(_wrappedToken.name(), "Wrapped Mock Token"); + assertEq(_wrappedToken.symbol(), "wMTK"); + + vm.startPrank(address(0x1234)); + _originalToken.approve(address(_wrappedToken), 100); + _wrappedToken.depositFor(address(0x1234), 100); + + vm.startPrank(address(0x5678)); + _originalToken.approve(address(_wrappedToken), 200); + _wrappedToken.depositFor(address(0x5678), 200); + + assertEq(_wrappedToken.balanceOf(address(0x1234)), 100); + assertEq(_wrappedToken.balanceOf(address(0x5678)), 200); + assertEq(_wrappedToken.balanceOf(address(0x0)), 0); + + vm.stopPrank(); + } + + function test_UsesAnExistingGovernanceERC20Token() public { + // Use existing governance token + address[] memory receivers = new address[](2); + receivers[0] = address(0x1234); + receivers[1] = address(0x5678); + uint256[] memory amounts = new uint256[](2); + amounts[0] = 100; + amounts[1] = 200; + mintSettings = GovernanceERC20.MintSettings({ + receivers: receivers, + amounts: amounts + }); + GovernanceERC20 _token = GovernanceERC20( + payable( + createProxyAndCall( + address(governanceERC20Base), + abi.encodeWithSelector( + GovernanceERC20.initialize.selector, + IDAO(dao), + "My Token", + "MTK", + mintSettings + ) + ) + ) + ); + tokenSettings = OptimisticTokenVotingPluginSetup.TokenSettings({ + addr: address(_token), + name: "", + symbol: "" + }); + bytes memory installationParams = pluginSetup.encodeInstallationParams( + votingSettings, + tokenSettings, + mintSettings, + proposers + ); + ( + , + IPluginSetup.PreparedSetupData memory _preparedSetupData + ) = pluginSetup.prepareInstallation(address(dao), installationParams); + + GovernanceWrappedERC20 _wrappedToken = GovernanceWrappedERC20( + _preparedSetupData.helpers[0] + ); + assertEq(_wrappedToken.name(), "My Token"); + assertEq(_wrappedToken.symbol(), "MTK"); + + assertEq(_wrappedToken.balanceOf(address(0x1234)), 100); + assertEq(_wrappedToken.balanceOf(address(0x5678)), 200); + assertEq(_wrappedToken.balanceOf(address(0x0)), 0); + } + + // HELPERS + function createProxyAndCall( + address _logic, + bytes memory _data + ) private returns (address) { + return address(new ERC1967Proxy(_logic, _data)); + } +} diff --git a/test/common.sol b/test/common.sol new file mode 100644 index 0000000..5318fae --- /dev/null +++ b/test/common.sol @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.17; + +bytes32 constant PROPOSER_PERMISSION_ID = keccak256("PROPOSER_PERMISSION"); +bytes32 constant EXECUTE_PERMISSION_ID = keccak256("EXECUTE_PERMISSION"); +bytes32 constant UPDATE_OPTIMISTIC_GOVERNANCE_SETTINGS_PERMISSION_ID = keccak256( + "UPDATE_OPTIMISTIC_GOVERNANCE_SETTINGS_PERMISSION" +); +bytes32 constant UPGRADE_PLUGIN_PERMISSION_ID = keccak256( + "UPGRADE_PLUGIN_PERMISSION" +); +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/mocks/ERC20Mock.sol b/test/mocks/ERC20Mock.sol new file mode 100644 index 0000000..4bc342a --- /dev/null +++ b/test/mocks/ERC20Mock.sol @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity >=0.8.17 <0.9.0; + +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; + +import {IVotesUpgradeable} from "@openzeppelin/contracts-upgradeable/governance/utils/IVotesUpgradeable.sol"; + +contract ERC20Mock is ERC20 { + constructor() ERC20("Mock token", "MOCK") {} + + function mint() external { + _mint(msg.sender, 10 ether); + } + + function mint(address receiver, uint256 amount) external { + _mint(receiver, amount); + } +} diff --git a/test/mocks/ERC20VotesMock.sol b/test/mocks/ERC20VotesMock.sol new file mode 100644 index 0000000..73eaacf --- /dev/null +++ b/test/mocks/ERC20VotesMock.sol @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity >=0.8.17 <0.9.0; + +import {ERC20Upgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC20/ERC20Upgradeable.sol"; +import {ERC20VotesUpgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC20/extensions/ERC20VotesUpgradeable.sol"; +import {ERC20PermitUpgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC20/extensions/ERC20PermitUpgradeable.sol"; + +import {IVotesUpgradeable} from "@openzeppelin/contracts-upgradeable/governance/utils/IVotesUpgradeable.sol"; + +contract ERC20VotesMock is + ERC20Upgradeable, + ERC20PermitUpgradeable, + ERC20VotesUpgradeable +{ + constructor() { + _disableInitializers(); + } + + function initialize() public initializer { + __ERC20_init("Mock Token", "MOCK"); + __ERC20Permit_init("Mock Token"); + __ERC20Votes_init(); + } + + // The functions below are overrides required by Solidity. + + function _afterTokenTransfer( + address from, + address to, + uint256 amount + ) internal override(ERC20Upgradeable, ERC20VotesUpgradeable) { + super._afterTokenTransfer(from, to, amount); + } + + function _mint( + address to, + uint256 amount + ) internal override(ERC20Upgradeable, ERC20VotesUpgradeable) { + super._mint(to, amount); + } + + function _burn( + address account, + uint256 amount + ) internal override(ERC20Upgradeable, ERC20VotesUpgradeable) { + super._burn(account, amount); + } + + function mint() external { + _mint(msg.sender, 10 ether); + } + + function mint(address receiver, uint256 amount) external { + _mint(receiver, amount); + } +}