diff --git a/.env.example b/.env.example index cfbc9940..1a191cd5 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,9 @@ -ETHERSCAN_API_KEY= -SEPOLIA_URL=https://eth-sepolia.g.alchemy.com/v2/ -MAINNET_URL=https://eth-mainnet.g.alchemy.com/v2/ +IMMUTABLE_NETWORK=0 +BLOCKSCOUT_APIKEY= PRIVATE_KEY= +HD_PATH="m/44'/60'/0'/0/0" +DEPLOYER_ADDRESS= +ROLE_ADMIN= +UPGRADE_ADMIN= +DISTRIBUTE_ADMIN= +ERC20_STAKING_TOKEN= struct AccessControlUpgradeable.RoleData) | 101 | 0 | 32 | OpenZeppelin Contracts v4.9.3: access/AccessControlUpgradeable.sol | +| \_\_gap | uint256[49] | 102 | 0 | 1568 | OpenZeppelin Contracts v4.9.3: access/AccessControlUpgradeable.sol | +| \_roleMembers | mapping(bytes32 => struct EnumerableSetUpgradeable.AddressSet) | 151 | 0 | 32 | OpenZeppelin Contracts v4.9.3: access/AccessControlEnumerableUpgradeable.sol | +| \_\_gap | uint256[49] | 152 | 0 | 1568 | OpenZeppelin Contracts v4.9.3: access/AccessControlEnumerableUpgradeable.sol | +| \_\_gap | uint256[50] | 201 | 0 | 1600 | OpenZeppelin Contracts v4.9.3: proxy/ERC1967/ERC1967Upgrade.sol | +| \_\_gap | uint256[50] | 251 | 0 | 1600 | OpenZeppelin Contracts v4.9.3: proxy/utils/UUPSUpgradeable.sol | +| \_status | uint256 | 301 | 0 | 32 | OpenZeppelin Contracts v4.9.3: security/ReentrancyGuardUpgradeable.sol | +| \_\_gap | uint256[49] | 302 | 0 | 1568 | OpenZeppelin Contracts v4.9.3: security/ReentrancyGuardUpgradeable.sol | +| balances | mapping(address => struct StakeHolder.StakeInfo) | 351 | 0 | 32 | StakeHolderBase.sol | +| stakers | address[] | 352 | 0 | 32 | StakeHolderBase.sol | +| version | uint256 | 353 | 0 | 32 | StakeHolderBase.sol | +| \_\_StakeHolderBaseGap | uint256[50] | 354 | 0 | 1600 | StakeHolderBase.sol | +| \_\_StakeHolderNativeGap | uint256[50] | 404 | 0 | 1600 | StakeHolderNative.sol | +| wIMX | contract IWIMX | 454 | 0 | 20 | StakeHolderWIMX.sol | +| \_\_StakeHolderWIMXGap | uint256[50] | 455 | 0 | 1600 | StakeHolderWIMX.sol | + + +#### Upgrade and Storage Slots for StakeHolderERC20 + +The table was constructed by using the command described below, and analysing the source code. + +``` +forge inspect StakeHolderERC20 storage +``` + +| Name | Type | Slot | Offset | Bytes | Source File | +| --------------------------------- | -------------------------------------------------------------- | ---- | ------ | ----- | ----------- | +| \_initialized | uint8 | 0 | 0 | 1 | OpenZeppelin Contracts v4.9.3: proxy/utils/Initializable.sol | +| \_initializing | bool | 0 | 1 | 1 | OpenZeppelin Contracts v4.9.3: proxy/utils/Initializable.sol | +| \_\_gap | uint256[50] | 1 | 0 | 1600 | OpenZeppelin Contracts v4.9.3: utils/Context.sol | +| \_\_gap | uint256[50] | 51 | 0 | 1600 | OpenZeppelin Contracts v4.9.3: utils/introspection/ERC165.sol | +| \_roles | mapping(bytes32 => struct AccessControlUpgradeable.RoleData) | 101 | 0 | 32 | OpenZeppelin Contracts v4.9.3: access/AccessControlUpgradeable.sol | +| \_\_gap | uint256[49] | 102 | 0 | 1568 | OpenZeppelin Contracts v4.9.3: access/AccessControlUpgradeable.sol | +| \_roleMembers | mapping(bytes32 => struct EnumerableSetUpgradeable.AddressSet) | 151 | 0 | 32 | OpenZeppelin Contracts v4.9.3: access/AccessControlEnumerableUpgradeable.sol | +| \_\_gap | uint256[49] | 152 | 0 | 1568 | OpenZeppelin Contracts v4.9.3: access/AccessControlEnumerableUpgradeable.sol | +| \_\_gap | uint256[50] | 201 | 0 | 1600 | OpenZeppelin Contracts v4.9.3: proxy/ERC1967/ERC1967Upgrade.sol | +| \_\_gap | uint256[50] | 251 | 0 | 1600 | OpenZeppelin Contracts v4.9.3: proxy/utils/UUPSUpgradeable.sol | +| \_status | uint256 | 301 | 0 | 32 | OpenZeppelin Contracts v4.9.3: security/ReentrancyGuardUpgradeable.sol | +| \_\_gap | uint256[49] | 302 | 0 | 1568 | OpenZeppelin Contracts v4.9.3: security/ReentrancyGuardUpgradeable.sol | +| balances | mapping(address => struct StakeHolder.StakeInfo) | 351 | 0 | 32 | StakeHolderBase.sol | +| stakers | address[] | 352 | 0 | 32 | StakeHolderBase.sol | +| version | uint256 | 353 | 0 | 32 | StakeHolderBase.sol | +| \_\_StakeHolderBaseGap | uint256[50] | 354 | 0 | 1600 | StakeHolderBase.sol | +| token | contract IERC20Upgradeable | 404 | 0 | 20 | StakeHolderERC20.sol | +| \_\_StakeHolderERC20Gap | uint256[50] | 405 | 0 | 1600 | StakeHolderERC20.sol | + + +#### Upgrade and Storage Slots for StakeHolderNative + +The table was constructed by using the command described below, and analysing the source code. + +``` +forge inspect StakeHolderNative storage +``` + +| Name | Type | Slot | Offset | Bytes | Source File | +| --------------------------------- | -------------------------------------------------------------- | ---- | ------ | ----- | ----------- | +| \_initialized | uint8 | 0 | 0 | 1 | OpenZeppelin Contracts v4.9.3: proxy/utils/Initializable.sol | +| \_initializing | bool | 0 | 1 | 1 | OpenZeppelin Contracts v4.9.3: proxy/utils/Initializable.sol | +| \_\_gap | uint256[50] | 1 | 0 | 1600 | OpenZeppelin Contracts v4.9.3: utils/Context.sol | +| \_\_gap | uint256[50] | 51 | 0 | 1600 | OpenZeppelin Contracts v4.9.3: utils/introspection/ERC165.sol | +| \_roles | mapping(bytes32 => struct AccessControlUpgradeable.RoleData) | 101 | 0 | 32 | OpenZeppelin Contracts v4.9.3: access/AccessControlUpgradeable.sol | +| \_\_gap | uint256[49] | 102 | 0 | 1568 | OpenZeppelin Contracts v4.9.3: access/AccessControlUpgradeable.sol | +| \_roleMembers | mapping(bytes32 => struct EnumerableSetUpgradeable.AddressSet) | 151 | 0 | 32 | OpenZeppelin Contracts v4.9.3: access/AccessControlEnumerableUpgradeable.sol | +| \_\_gap | uint256[49] | 152 | 0 | 1568 | OpenZeppelin Contracts v4.9.3: access/AccessControlEnumerableUpgradeable.sol | +| \_\_gap | uint256[50] | 201 | 0 | 1600 | OpenZeppelin Contracts v4.9.3: proxy/ERC1967/ERC1967Upgrade.sol | +| \_\_gap | uint256[50] | 251 | 0 | 1600 | OpenZeppelin Contracts v4.9.3: proxy/utils/UUPSUpgradeable.sol | +| \_\status | uint256 | 301 | 0 | 32 | OpenZeppelin Contracts v4.9.3: security/ReentrancyGuardUpgradeable.sol | +| \_\_gap | uint256[49] | 302 | 0 | 1568 | OpenZeppelin Contracts v4.9.3: security/ReentrancyGuardUpgradeable.sol | +| balances | mapping(address => struct StakeHolder.StakeInfo) | 351 | 0 | 32 | StakeHolderBase.sol | +| stakers | address[] | 352 | 0 | 32 | StakeHolderBase.sol | +| version | uint256 | 353 | 0 | 32 | StakeHolderBase.sol | +| \_\_StakeHolderBaseGap | uint256[50] | 354 | 0 | 1600 | StakeHolderBase.sol | +| \_\_StakeHolderNativeGap | uint256[50] | 404 | 0 | 1600 | StakeHolderNative.sol | + + +### Timelock Controller Bypass + +To ensure time delay upgrades are enforced, the `StakeHolderERC20` or `StakeHolderNative` contracts should have the only account with `UPGRADE_ROLE` and `DEFAULT_ADMIN_ROLE` roles should be an instance of Open Zeppelin's [TimelockController](https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/governance/TimelockController.sol). This ensures any upgrade proposals or proposals to add more accounts with `DEFAULT_ADMIN_ROLE`, `UPGRADE_ROLE` or `DISTRIBUTE_ROLE` must go through a time delay before being actioned. The account with `DEFAULT_ADMIN_ROLE` could choose to renounce this role to ensure the `TimelockController` can not be bypassed at a later date by having a compromised account with `DEFAULT_ADMIN_ROLE` adding additional accounts with `UPGRADE_ROLE`. + +Once the `TimelockController` and staking contracts have been installed, the installation should be checked to ensure the configuration of the `TimelockController` is as expected. That is, check: + +* The list of `proposer` accounts is what is expected. +* The list of `executor` accounts is what is expected. +* The time delay is the expected value. + +## Perceived Attackers + +This section lists the attackers that could attack the stake holder contract system. + +It is assumed that all attackers have access to all documentation and source code of all systems related to the Immutable zkEVM, irrespective of whether the information resides in a public or private GitHub repository, email, Slack, Confluence, or any other information system. + +### Spear Phisher + +This attacker compromises accounts of people by using Spear Phishing attacks. For example they send a malicious PDF file to a user, which the user opens, the PDF file then installs malware on the user's computer. At this point, it is assumed that the Spear Phisher Attacker can detect all key strokes, mouse clicks, see all information retrieved, see any file in the user's file system, and execute any program on the user's computer. + +### Immutable zkEVM Block Proposer + +An operator of an Immutable zkEVM Block Proposer could, within narrow limits, alter the block timestamp of the block they produce. + +### Insider + +This attacker works for a company helping operate the Immutable zkEVM. This attacker could be being bribed or blackmailed. They can access the keys that they as an individual employee have access to. For instance, they might be one of the signers of the multi-signer administrative role. + +### General Public + +This attacker targets the public API of the `StakeHolder` contract. + +## Attack Mitigation + +This section outlines possible attacks against the attack surfaces by the attackers, and how those attacks are mitigated. + +### Public API Attack + +**Detection**: Staker funds are stolen. + +An attacker could target the public API in an attempt to steal funds. As shown in the `Externally Visible Functions` section, all functions that update state are protected by access control methods (`grantRole`, `revokeRole`, `upgradeTo`, `upgradeToAndCall`), operate on value owned by msg.sender (`distributeRewards`, `stake`, `unstake`), operate on state related to msg.sender (`renounceRole`), or are protected by state machine logic (`initialize`, `upgradeStorage`). As such, there is no mechanism by which an attacker could attack the contract using the public API. + + +### `DEFAULT_ADMIN` Role Account Compromise + +**Detection**: Monitoring role change events. + +The mitigation is to assume that the role will be operated by multi-signature addresses such that an attacker would need to compromise multiple signers simultaneously. As such, even if some keys are compromised due to the Spear Phishing Attacker or the Insider Attacker, the administrative actions will not be able to be executed as a threshold number of keys will not be available. + +### `UPGRADE` Role Account Compromise + +**Detection**: Monitoring upgrade events. + +The mitigation is to assume that the role will be operated by multi-signature addresses such that an attacker would need to compromise multiple signers simultaneously. As such, even if some keys are compromised due to the Spear Phishing Attacker or the Insider Attacker, the administrative actions will not be able to be executed as a threshold number of keys will not be available. + +### Immutable zkEVM Block Proposer Censoring Transactions + +**Detection**: A staker could attempt to unstake some or all of their IMX. The block proposer could refuse to include this transaction. + +The mitigation for this attack is that Immutable zkEVM Block Proposers software is written such that no transactions are censored unless the transaction has been signed by an account on [OFAC's Sanctions List](https://ofac.treasury.gov/sanctions-list-service). + + +## Conclusion + +This threat model has presented the architecture of the system, determined attack surfaces, and identified possible attackers and their capabilities. It has walked through each attack surface and based on the attackers, determined how the attacks are mitigated. diff --git a/contracts/deployer/README.md b/contracts/deployer/README.md index f6647200..20374a6a 100644 --- a/contracts/deployer/README.md +++ b/contracts/deployer/README.md @@ -1,7 +1,5 @@ # Contract Deployers -At present, use of the Contract Deployers described below is limited to Immutable. - This directory provides two types of contract deployers: CREATE2 and CREATE3. Both deployer types facilitate contract deployment to predictable addresses, independent of the deployer account’s nonce. The deployers offer a more reliable alternative to using a Nonce Reserver Key (a key that is only used for deploying contracts, and has specific nonces reserved for deploying specific contracts), particularly across different chains. These factories can also be utilized for contracts that don't necessarily need predictable addresses. The advantage of this method, compared to using a deployer key in conjunction with a deployment factory contract, is that it can enable better standardisation and simplification of deployment processes and enables the rotation of the deployer key without impacting the consistency of the perceived deployer address for contracts. Deployments via these factories can only be performed by the owner of the factory. diff --git a/contracts/payment-splitter/PaymentSplitter.sol b/contracts/payment-splitter/PaymentSplitter.sol index f07c8800..b322f03a 100644 --- a/contracts/payment-splitter/PaymentSplitter.sol +++ b/contracts/payment-splitter/PaymentSplitter.sol @@ -65,7 +65,7 @@ contract PaymentSplitter is AccessControlEnumerable, IPaymentSplitterErrors, Ree /** * @notice Payable fallback method to receive IMX. The IMX received will be logged with {PaymentReceived} events. * this contract has no other payable method, all IMX receives will be tracked by the events emitted by this event - * ERC20 receives will not be tracked by this contract but tranfers events will be emitted by the erc20 contracts themselves. + * ERC20 receives will not be tracked by this contract but transfers events will be emitted by the erc20 contracts themselves. */ receive() external payable virtual { emit PaymentReceived(_msgSender(), msg.value); diff --git a/contracts/staking/IStakeHolder.sol b/contracts/staking/IStakeHolder.sol new file mode 100644 index 00000000..12c06fa5 --- /dev/null +++ b/contracts/staking/IStakeHolder.sol @@ -0,0 +1,129 @@ +// Copyright (c) Immutable Pty Ltd 2018 - 2025 +// SPDX-License-Identifier: Apache 2 +pragma solidity >=0.8.19 <0.8.29; + +import {IAccessControlEnumerableUpgradeable} from "openzeppelin-contracts-upgradeable-4.9.3/access/IAccessControlEnumerableUpgradeable.sol"; + +/** + * @title StakeHolderBase: allows anyone to stake any amount of an ERC20 token and to then remove all or part of that stake. + * @dev The StakeHolderERC20 contract is designed to be upgradeable. + */ +interface IStakeHolder is IAccessControlEnumerableUpgradeable { + /// @notice implementation does not accept native tokens. + error NonPayable(); + + /// @notice Error: Attempting to upgrade contract storage to version 0. + error CanNotUpgradeToLowerOrSameVersion(uint256 _storageVersion); + + /// @notice Error: Attempting to renounce the last role admin / default admin. + error MustHaveOneRoleAdmin(); + + /// @notice Error: Attempting to stake with zero value. + error MustStakeMoreThanZero(); + + /// @notice Error: Attempting to distribute zero value. + error MustDistributeMoreThanZero(); + + /// @notice Error: Attempting to unstake amount greater than the balance. + error UnstakeAmountExceedsBalance(uint256 _amountToUnstake, uint256 _currentStake); + + /// @notice Error: Distributions can only be made to accounts that have staked. + error AttemptToDistributeToNewAccount(address _account, uint256 _amount); + + /// @notice Error: Call to stake for implementations that accept value require value and parameter to match. + error MismatchMsgValueAmount(uint256 _msgValue, uint256 _amount); + + /// @notice Event when an amount has been staked or when an amount is distributed to an account. + event StakeAdded(address _staker, uint256 _amountAdded, uint256 _newBalance); + + /// @notice Event when an amount has been unstaked. + event StakeRemoved(address _staker, uint256 _amountRemoved, uint256 _newBalance); + + /// @notice Event summarising a distribution. There will also be one StakeAdded event for each recipient. + event Distributed(address _distributor, uint256 _totalDistribution, uint256 _numRecipients); + + /// @notice Struct to combine an account and an amount. + struct AccountAmount { + address account; + uint256 amount; + } + + /** + * @notice Allow any account to stake more value. + * @param _amount The amount of tokens to be staked. + */ + function stake(uint256 _amount) external payable; + + /** + * @notice Allow any account to remove some or all of their own stake. + * @param _amountToUnstake Amount of stake to remove. + */ + function unstake(uint256 _amountToUnstake) external; + + /** + * @notice Accounts with DISTRIBUTE_ROLE can distribute tokens to any set of accounts. + * @param _recipientsAndAmounts An array of recipients to distribute value to and + * amounts to be distributed to each recipient. + */ + function distributeRewards(AccountAmount[] calldata _recipientsAndAmounts) external payable; + + /** + * @notice Get the balance of an account. + * @param _account The account to return the balance for. + * @return _balance The balance of the account. + */ + function getBalance(address _account) external view returns (uint256 _balance); + + /** + * @notice Determine if an account has ever staked. + * @param _account The account to determine if they have staked + * @return _everStaked True if the account has ever staked. + */ + function hasStaked(address _account) external view returns (bool _everStaked); + + /** + * @notice Get the length of the stakers array. + * @dev This will be equal to the number of staker accounts that have ever staked. + * Some of the accounts might have a zero balance, having staked and then + * unstaked. + * @return _len The length of the stakers array. + */ + function getNumStakers() external view returns (uint256 _len); + + /** + * @notice Get the staker accounts from the stakers array. + * @dev Given the stakers list could grow arbitrarily long. To prevent out of memory or out of + * gas situations due to attempting to return a very large array, this function call specifies + * the start offset and number of accounts to be return. + * NOTE: This code will cause a panic if the start offset + number to return is greater than + * the length of the array. Use getNumStakers before calling this function to determine the + * length of the array. + * @param _startOffset First offset in the stakers array to return the account number for. + * @param _numberToReturn The number of accounts to return. + * @return _stakers A subset of the stakers array. + */ + function getStakers( + uint256 _startOffset, + uint256 _numberToReturn + ) external view returns (address[] memory _stakers); + + /** + * @return The address of the staking token. + */ + function getToken() external view returns (address); + + /** + * @notice version number of the storage variable layout. + */ + function version() external view returns (uint256); + + /** + * @notice Only UPGRADE_ROLE can upgrade the contract + */ + function UPGRADE_ROLE() external pure returns (bytes32); + + /** + * @notice Only DISTRIBUTE_ROLE can call the distribute function + */ + function DISTRIBUTE_ROLE() external pure returns (bytes32); +} diff --git a/contracts/staking/IWIMX.sol b/contracts/staking/IWIMX.sol new file mode 100644 index 00000000..f7302bcc --- /dev/null +++ b/contracts/staking/IWIMX.sol @@ -0,0 +1,36 @@ +// Copyright Immutable Pty Ltd 2018 - 2023 +// SPDX-License-Identifier: Apache 2.0 +pragma solidity >=0.8.19 <0.8.29; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +/* + * @notice Interface for the Wrapped IMX (wIMX) contract. + * @dev Based on the interface for the [Wrapped IMX contract](https://etherscan.io/token/0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2#code) + */ +interface IWIMX is IERC20 { + /** + * @notice Emitted when native IMX is deposited to the contract, and a corresponding amount of wIMX are minted + * @param account The address of the account that deposited the tokens. + * @param value The amount of tokens that were deposited. + */ + event Deposit(address indexed account, uint256 value); + + /** + * @notice Emitted when wIMX is withdrawn from the contract, and a corresponding amount of wIMX are burnt. + * @param account The address of the account that withdrew the tokens. + * @param value The amount of tokens that were withdrawn. + */ + event Withdrawal(address indexed account, uint256 value); + + /** + * @notice Deposit native IMX to the contract and mint an equal amount of wrapped IMX to msg.sender. + */ + function deposit() external payable; + + /** + * @notice Withdraw given amount of native IMX to msg.sender after burning an equal amount of wrapped IMX. + * @param value The amount to withdraw. + */ + function withdraw(uint256 value) external; +} diff --git a/contracts/staking/README.md b/contracts/staking/README.md index 1ffb0a31..6047c2e5 100644 --- a/contracts/staking/README.md +++ b/contracts/staking/README.md @@ -1,9 +1,33 @@ # Staking -The Immutable zkEVM staking system consists of the Staking Holder contract. This contract holds staked native IMX. Any account (EOA or contract) can stake any amount at any time. An account can remove all or some of their stake at any time. The contract has the facility to distribute rewards to stakers. +The Immutable zkEVM staking system allows any account (EOA or contract) to stake any amount of a token at any time. An account can remove all or some of their stake at any time. The contract has the facility to distribute rewards to stakers. + +The staking contracts are upgradeable and operate via a proxy contract. They use the [Universal Upgradeable Proxy Standard (UUPS)](https://eips.ethereum.org/EIPS/eip-1822) upgrade pattern, where the access control for upgrade resides within the application contract (the staking contract). + +The system consists of a set of contracts show in the diagram below. + +![Staking Architecture](./staking-architecture.png) + +`IStakeHolder.sol` is the interface that all staking implementations comply with. + +`StakeHolderBase.sol` is the abstract base contract that all staking implementation use. + +`StakeHolderWIMX.sol` allows the native token, IMX, to be used as the staking currency. Stake is held as wrapped IMX, WIMX, an ERC20 token. + +`StakeHolderERC20.sol` allows an ERC20 token to be used as the staking currency. + +`StakeHolderNative.sol` uses the native token, IMX, to be used as the staking currency. Stake is held as native IMX. + +`ERC1967Proxy.sol` is a proxy contract. All calls to StakeHolder contracts go via the ERC1967Proxy contract. + +`TimelockController.sol` can be used with the staking contracts to provide a one week delay between when upgrade or other admin changes are proposed and when they are executed. See below for information on how to configure the time lock controller. + +`OwnableCreate3Deployer.sol` ensures contracts are deployed to the same addresses across chains. The use of this contract is optional. See [deployment scripts](../../script/staking/README.md) for more information. ## Immutable Contract Addresses +StakeHolderERC20.sol configured with IMX as the staking token: + | Environment/Network | Deployment Address | Commit Hash | |--------------------------|--------------------|-------------| | Immutable zkEVM Testnet | Not deployed yet | -| @@ -16,31 +40,24 @@ Contract threat models and audits: | Description | Date |Version Audited | Link to Report | |---------------------------|------------------|-----------------|----------------| | Threat model | Oct 21, 2024 | [`fd982abc49884af41e05f18349b13edc9eefbc1e`](https://github.com/immutable/contracts/blob/fd982abc49884af41e05f18349b13edc9eefbc1e/contracts/staking/README.md) | [202410-threat-model-stake-holder.md](../../audits/staking/202410-threat-model-stake-holder.md) | +| Threat model | April 24, 2025 | [`bf327c7abdadd48fd51ae632500510ac2b07b5f0`](https://github.com/immutable/contracts/blob/bf327c7abdadd48fd51ae632500510ac2b07b5f0/contracts/staking/README.md) | [202504-threat-model-stake-holder.md](../../audits/staking/202504-threat-model-stake-holder.md) | # Deployment -**Deploy and verify using CREATE3 factory contract:** - -This repo includes a script for deploying via a CREATE3 factory contract. The script is defined as a test contract as per the examples [here](https://book.getfoundry.sh/reference/forge/forge-script#examples) and can be found in `./script/staking/DeployStakeHolder.sol`. - -See the `.env.example` for required environment variables. - -```sh -forge script script/staking/DeployStakeHolder.sol --tc DeployStakeHolder --sig "deploy()" -vvv --rpc-url {rpc-url} --broadcast --verifier-url https://explorer.immutable.com/api --verifier blockscout --verify --gas-price 10000000000 -``` - -Optionally, you can also specify `--ledger` or `--trezor` for hardware deployments. See docs [here](https://book.getfoundry.sh/reference/forge/forge-script#wallet-options---hardware-wallet). +See [deployment scripts](../../script/staking/README.md). # Usage -To stake, any account should call `stake()`, passing in the amount to be staked as the msg.value. +For StakeHolderERC20 and StakeHolderWIMX, the ERC20 staking token must be specified when the contract is being initialised. The token can not be changed. + +To stake, any account should call `stake(uint256 _amount)`. For the WIMX and the native IMX variants, the amount to be staked must be passed in as the msg.value. To unstake, the account that previously staked should call, `unstake(uint256 _amountToUnstake)`. -Accounts that have DISTRIBUTE_ROLE that wish to distribute rewards should call, `distributeRewards(AccountAmount[] calldata _recipientsAndAmounts)`. The `AccountAmount` structure consists of recipient address and amount to distribute pairs. Distributions can only be made to accounts that have previously or are currently staking. The amount to be distributed must be passed in as msg.value and must equal to the sum of the amounts specified in the `_recipientsAndAmounts` array. +Accounts that have DISTRIBUTE_ROLE that wish to distribute rewards should call, `distributeRewards(AccountAmount[] calldata _recipientsAndAmounts)`. The `AccountAmount` structure consists of recipient address and amount to distribute pairs. Distributions can only be made to accounts that have previously or are currently staking. For the WIMX and the native IMX variants, the amount to be distributed must be passed in as msg.value and must equal to the sum of the amounts specified in the `_recipientsAndAmounts` array. The `stakers` array needs to be analysed to determine which accounts have staked and how much. The following functions provide access to this data structure: @@ -51,11 +68,7 @@ The `stakers` array needs to be analysed to determine which accounts have staked # Administration Notes -The `StakeHolder` contract is `AccessControlEnumerableUpgradeable`, with the following minor modification: - -* `_revokeRole(bytes32 _role, address _account)` has been overridden to prevent the last DEFAULT_ADMIN_ROLE (the last role admin) from either being revoked or renounced. - -The `StakeHolder` contract is `UUPSUpgradeable`. Only accounts with `UPGRADE_ROLE` are authorised to upgrade the contract. +The `StakeHolderBase` contract is `AccessControlEnumerableUpgradeable`. The `StakeHolderERC20` and `StakeHolderNative` contracts are `UUPSUpgradeable`. Only accounts with `UPGRADE_ROLE` are authorised to upgrade the contract. ## Upgrade Concept @@ -67,3 +80,11 @@ The `upgradeStorage` function should be updated each new contract version. It sh * The value is the same as the newt version: Someone (an attacker) has called the `upgradeStorage` function after the code has been upgraded. The function should revert. * Based on the old code version and storage format indicated by the `version`, update the storage variables. Typically, upgrades only involve code changes, and require no storage variable changes. However, in some circumstances storage variables should also be updated. * Update the `version` storage variable to indicate the new code version. + +## Time Delay Upgrade and Admin + +A staking systems may wish to delay upgrade actions and the granting of additional administrative access. To do this, the only account with UPGRADE_ROLE and DEFAULT_ADMIN_ROLE roles should be an instance of Open Zeppelin's [TimelockController](https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/governance/TimelockController.sol). This ensures any upgrade proposals or proposals to add more accounts with `DEFAULT_ADMIN_ROLE`, `UPGRADE_ROLE` or `DISTRIBUTE_ROLE` must go through a time delay before being actioned. The account with `DEFAULT_ADMIN_ROLE` could choose to renounce this role to ensure the `TimelockController` can not be bypassed at a later date by having a compromised account with `DEFAULT_ADMIN_ROLE` adding addtional accounts with `UPGRADE_ROLE`. + +## Preventing Upgrade + +A staking system could choose to have no account with DEFAULT_ADMIN_ROLE to to prevent additional accounts being granted UPGRADE_ROLE role. The system could have no acccounts with UPGRADE_ROLE, thus preventing upgrade. The system could configure this from start-up by passing in `address(0)` as the `roleAdmin` and `upgradeAdmin` to the constructor. Alternative, the `revokeRole` function can be used to revoke the roles from accounts. diff --git a/contracts/staking/StakeHolder.sol b/contracts/staking/StakeHolder.sol deleted file mode 100644 index 31ced4f1..00000000 --- a/contracts/staking/StakeHolder.sol +++ /dev/null @@ -1,290 +0,0 @@ -// Copyright (c) Immutable Pty Ltd 2018 - 2025 -// SPDX-License-Identifier: Apache 2 -pragma solidity >=0.8.19 <0.8.29; - -import {UUPSUpgradeable} from "openzeppelin-contracts-upgradeable-4.9.3/proxy/utils/UUPSUpgradeable.sol"; -import {AccessControlEnumerableUpgradeable} from "openzeppelin-contracts-upgradeable-4.9.3/access/AccessControlEnumerableUpgradeable.sol"; - -/// @notice Struct to combine an account and an amount. -struct AccountAmount { - address account; - uint256 amount; -} - -/** - * @title StakeHolder: allows anyone to stake any amount of native IMX and to then remove all or part of that stake. - * @dev The StakeHolder contract is designed to be upgradeable. - */ -contract StakeHolder is AccessControlEnumerableUpgradeable, UUPSUpgradeable { - /// @notice Error: Attempting to upgrade contract storage to version 0. - error CanNotUpgradeToLowerOrSameVersion(uint256 _storageVersion); - - /// @notice Error: Attempting to renounce the last role admin / default admin. - error MustHaveOneRoleAdmin(); - - /// @notice Error: Attempting to stake with zero value. - error MustStakeMoreThanZero(); - - /// @notice Error: Attempting to distribute zero value. - error MustDistributeMoreThanZero(); - - /// @notice Error: Attempting to unstake amount greater than the balance. - error UnstakeAmountExceedsBalance(uint256 _amountToUnstake, uint256 _currentStake); - - /// @notice Error: Unstake transfer failed. - error UnstakeTransferFailed(); - - /// @notice Error: The sum of all amounts to distribute did not equal msg.value of the distribute transaction. - error DistributionAmountsDoNotMatchTotal(uint256 _msgValue, uint256 _calculatedTotalDistribution); - - /// @notice Error: Distributions can only be made to accounts that have staked. - error AttemptToDistributeToNewAccount(address _account, uint256 _amount); - - /// @notice Event when an amount has been staked or when an amount is distributed to an account. - event StakeAdded(address _staker, uint256 _amountAdded, uint256 _newBalance); - - /// @notice Event when an amount has been unstaked. - event StakeRemoved(address _staker, uint256 _amountRemoved, uint256 _newBalance); - - /// @notice Event summarising a distribution. There will also be one StakeAdded event for each recipient. - event Distributed(address _distributor, uint256 _totalDistribution, uint256 _numRecipients); - - /// @notice Only UPGRADE_ROLE can upgrade the contract - bytes32 public constant UPGRADE_ROLE = bytes32("UPGRADE_ROLE"); - - /// @notice Only DISTRIBUTE_ROLE can call the distribute function - bytes32 public constant DISTRIBUTE_ROLE = bytes32("DISTRIBUTE_ROLE"); - - /// @notice Version 0 version number - uint256 private constant _VERSION0 = 0; - - /// @notice Holds staking information for a single staker. - struct StakeInfo { - /// @notice Amount of stake. - uint256 stake; - /// @notice True if this account has ever staked. - bool hasStaked; - } - - /// @notice The amount of value owned by each staker - // solhint-disable-next-line private-vars-leading-underscore - mapping(address staker => StakeInfo stakeInfo) private balances; - - /// @notice A list of all stakers who have ever staked. - /// @dev The list make contain stakers who have completely unstaked (that is, have - /// a balance of 0). This array is never re-ordered. As such, off-chain services - /// could cache the results of getStakers(). - // solhint-disable-next-line private-vars-leading-underscore - address[] private stakers; - - /// @notice version number of the storage variable layout. - uint256 public version; - - /** - * @notice Initialises the upgradeable contract, setting up admin accounts. - * @param _roleAdmin the address to grant `DEFAULT_ADMIN_ROLE` to - * @param _upgradeAdmin the address to grant `UPGRADE_ROLE` to - * @param _distributeAdmin the address to grant `DISTRIBUTE_ROLE` to - */ - function initialize(address _roleAdmin, address _upgradeAdmin, address _distributeAdmin) public initializer { - __UUPSUpgradeable_init(); - __AccessControl_init(); - _grantRole(DEFAULT_ADMIN_ROLE, _roleAdmin); - _grantRole(UPGRADE_ROLE, _upgradeAdmin); - _grantRole(DISTRIBUTE_ROLE, _distributeAdmin); - version = _VERSION0; - } - - /** - * @notice Function to be called when upgrading this contract. - * @dev Call this function as part of upgradeToAndCall(). - * This initial version of this function reverts. There is no situation - * in which it makes sense to upgrade to the V0 storage layout. - * Note that this function is permissionless. Future versions must - * compare the code version and the storage version and upgrade - * appropriately. As such, the code will revert if an attacker calls - * this function attempting a malicious upgrade. - * @ param _data ABI encoded data to be used as part of the contract storage upgrade. - */ - function upgradeStorage(bytes memory /* _data */) external virtual { - revert CanNotUpgradeToLowerOrSameVersion(version); - } - - /** - * @notice Allow any account to stake more value. - * @dev The amount being staked is the value of msg.value. - * @dev This function does not need re-entrancy guard as the add stake - * mechanism does not call out to any external function. - */ - function stake() external payable { - if (msg.value == 0) { - revert MustStakeMoreThanZero(); - } - _addStake(msg.sender, msg.value, false); - } - - /** - * @notice Allow any account to remove some or all of their own stake. - * @dev This function does not need re-entrancy guard as the state is updated - * prior to the call to the user's wallet. - * @param _amountToUnstake Amount of stake to remove. - */ - function unstake(uint256 _amountToUnstake) external { - StakeInfo storage stakeInfo = balances[msg.sender]; - uint256 currentStake = stakeInfo.stake; - if (currentStake < _amountToUnstake) { - revert UnstakeAmountExceedsBalance(_amountToUnstake, currentStake); - } - uint256 newBalance = currentStake - _amountToUnstake; - stakeInfo.stake = newBalance; - - emit StakeRemoved(msg.sender, _amountToUnstake, newBalance); - - // slither-disable-next-line low-level-calls - (bool success, bytes memory returndata) = payable(msg.sender).call{value: _amountToUnstake}(""); - if (!success) { - // Look for revert reason and bubble it up if present. - // Revert reasons should contain an error selector, which is four bytes long. - if (returndata.length >= 4) { - // solhint-disable-next-line no-inline-assembly - assembly { - let returndata_size := mload(returndata) - revert(add(32, returndata), returndata_size) - } - } else { - revert UnstakeTransferFailed(); - } - } - } - - /** - * @notice Accounts with DISTRIBUTE_ROLE can distribute tokens to any set of accounts. - * @dev The total amount to distribute must match msg.value. - * This function does not need re-entrancy guard as the distribution mechanism - * does not call out to another contract. - * @param _recipientsAndAmounts An array of recipients to distribute value to and - * amounts to be distributed to each recipient. - */ - function distributeRewards( - AccountAmount[] calldata _recipientsAndAmounts - ) external payable onlyRole(DISTRIBUTE_ROLE) { - // Initial validity checks - if (msg.value == 0) { - revert MustDistributeMoreThanZero(); - } - uint256 len = _recipientsAndAmounts.length; - - // Distribute the value. - uint256 total = 0; - for (uint256 i = 0; i < len; i++) { - AccountAmount calldata accountAmount = _recipientsAndAmounts[i]; - uint256 amount = accountAmount.amount; - // Add stake, but require the acount to either currently be staking or have - // previously staked. - _addStake(accountAmount.account, amount, true); - total += amount; - } - - // Check that the total distributed matches the msg.value. - if (total != msg.value) { - revert DistributionAmountsDoNotMatchTotal(msg.value, total); - } - emit Distributed(msg.sender, msg.value, len); - } - - /** - * @notice Get the balance of an account. - * @param _account The account to return the balance for. - * @return _balance The balance of the account. - */ - function getBalance(address _account) external view returns (uint256 _balance) { - return balances[_account].stake; - } - - /** - * @notice Determine if an account has ever staked. - * @param _account The account to determine if they have staked - * @return _everStaked True if the account has ever staked. - */ - function hasStaked(address _account) external view returns (bool _everStaked) { - return balances[_account].hasStaked; - } - - /** - * @notice Get the length of the stakers array. - * @dev This will be equal to the number of staker accounts that have ever staked. - * Some of the accounts might have a zero balance, having staked and then - * unstaked. - * @return _len The length of the stakers array. - */ - function getNumStakers() external view returns (uint256 _len) { - return stakers.length; - } - - /** - * @notice Get the staker accounts from the stakers array. - * @dev Given the stakers list could grow arbitrarily long. To prevent out of memory or out of - * gas situations due to attempting to return a very large array, this function call specifies - * the start offset and number of accounts to be return. - * NOTE: This code will cause a panic if the start offset + number to return is greater than - * the length of the array. Use getNumStakers before calling this function to determine the - * length of the array. - * @param _startOffset First offset in the stakers array to return the account number for. - * @param _numberToReturn The number of accounts to return. - * @return _stakers A subset of the stakers array. - */ - function getStakers( - uint256 _startOffset, - uint256 _numberToReturn - ) external view returns (address[] memory _stakers) { - address[] memory stakerPartialArray = new address[](_numberToReturn); - for (uint256 i = 0; i < _numberToReturn; i++) { - stakerPartialArray[i] = stakers[_startOffset + i]; - } - return stakerPartialArray; - } - - /** - * @notice Add more stake to an account. - * @dev If the account has a zero balance prior to this call, add the account to the stakers array. - * @param _account Account to add stake to. - * @param _amount The amount of stake to add. - * @param _existingAccountsOnly If true, revert if the account has never been used. - */ - function _addStake(address _account, uint256 _amount, bool _existingAccountsOnly) private { - StakeInfo storage stakeInfo = balances[_account]; - uint256 currentStake = stakeInfo.stake; - if (!stakeInfo.hasStaked) { - if (_existingAccountsOnly) { - revert AttemptToDistributeToNewAccount(_account, _amount); - } - stakers.push(_account); - stakeInfo.hasStaked = true; - } - uint256 newBalance = currentStake + _amount; - stakeInfo.stake = newBalance; - emit StakeAdded(_account, _amount, newBalance); - } - - // Override the _authorizeUpgrade function - // solhint-disable-next-line no-empty-blocks - function _authorizeUpgrade(address newImplementation) internal override onlyRole(UPGRADE_ROLE) {} - - /** - * @notice Prevent revoke or renounce role for the last DEFAULT_ADMIN_ROLE / the last role admin. - * @param _role The role to be renounced. - * @param _account Account to be revoked. - */ - function _revokeRole(bytes32 _role, address _account) internal override { - if (_role == DEFAULT_ADMIN_ROLE && getRoleMemberCount(_role) == 1) { - revert MustHaveOneRoleAdmin(); - } - super._revokeRole(_role, _account); - } - - /// @notice storage gap for additional variables for upgrades - // slither-disable-start unused-state - // solhint-disable-next-line var-name-mixedcase - uint256[50] private __StakeHolderGap; - // slither-disable-end unused-state -} diff --git a/contracts/staking/StakeHolderBase.sol b/contracts/staking/StakeHolderBase.sol new file mode 100644 index 00000000..77c50ef7 --- /dev/null +++ b/contracts/staking/StakeHolderBase.sol @@ -0,0 +1,218 @@ +// Copyright (c) Immutable Pty Ltd 2018 - 2025 +// SPDX-License-Identifier: Apache 2 +pragma solidity >=0.8.19 <0.8.29; + +import {UUPSUpgradeable} from "openzeppelin-contracts-upgradeable-4.9.3/proxy/utils/UUPSUpgradeable.sol"; +import {AccessControlEnumerableUpgradeable} from "openzeppelin-contracts-upgradeable-4.9.3/access/AccessControlEnumerableUpgradeable.sol"; +import {ReentrancyGuardUpgradeable} from "openzeppelin-contracts-upgradeable-4.9.3/security/ReentrancyGuardUpgradeable.sol"; +import {IStakeHolder} from "./IStakeHolder.sol"; + +/** + * @title StakeHolderBase: allows anyone to stake any amount of an ERC20 token and to then remove all or part of that stake. + * @dev The StakeHolderERC20 contract is designed to be upgradeable. + */ +abstract contract StakeHolderBase is + IStakeHolder, + AccessControlEnumerableUpgradeable, + UUPSUpgradeable, + ReentrancyGuardUpgradeable +{ + /// @notice Only UPGRADE_ROLE can upgrade the contract + bytes32 public constant UPGRADE_ROLE = bytes32("UPGRADE_ROLE"); + + /// @notice Only DISTRIBUTE_ROLE can call the distribute function + bytes32 public constant DISTRIBUTE_ROLE = bytes32("DISTRIBUTE_ROLE"); + + /// @notice Version 0 version number + uint256 internal constant _VERSION0 = 0; + + /// @notice Holds staking information for a single staker. + struct StakeInfo { + /// @notice Amount of stake. + uint256 stake; + /// @notice True if this account has ever staked. + bool hasStaked; + } + + /// @notice The amount of value owned by each staker + // solhint-disable-next-line private-vars-leading-underscore + mapping(address staker => StakeInfo stakeInfo) internal balances; + + /// @notice A list of all stakers who have ever staked. + /// @dev The list make contain stakers who have completely unstaked (that is, have + /// a balance of 0). This array is never re-ordered. As such, off-chain services + /// could cache the results of getStakers(). + // solhint-disable-next-line private-vars-leading-underscore + address[] internal stakers; + + /// @notice version number of the storage variable layout. + uint256 public version; + + /** + * @notice Initialises the upgradeable contract, setting up admin accounts. + * @param _roleAdmin the address to grant `DEFAULT_ADMIN_ROLE` to + * @param _upgradeAdmin the address to grant `UPGRADE_ROLE` to + * @param _distributeAdmin the address to grant `DISTRIBUTE_ROLE` to + */ + function __StakeHolderBase_init( + address _roleAdmin, + address _upgradeAdmin, + address _distributeAdmin + ) internal onlyInitializing { + __UUPSUpgradeable_init(); + __AccessControl_init(); + __ReentrancyGuard_init(); + _grantRole(DEFAULT_ADMIN_ROLE, _roleAdmin); + _grantRole(UPGRADE_ROLE, _upgradeAdmin); + _grantRole(DISTRIBUTE_ROLE, _distributeAdmin); + version = _VERSION0; + } + + /** + * @notice Function to be called when upgrading this contract. + * @dev Call this function as part of upgradeToAndCall(). + * This initial version of this function reverts. There is no situation + * in which it makes sense to upgrade to the V0 storage layout. + * Note that this function is permissionless. Future versions must + * compare the code version and the storage version and upgrade + * appropriately. As such, the code will revert if an attacker calls + * this function attempting a malicious upgrade. + * @ param _data ABI encoded data to be used as part of the contract storage upgrade. + */ + function upgradeStorage(bytes memory /* _data */) external virtual { + revert CanNotUpgradeToLowerOrSameVersion(version); + } + + /** + * @notice Allow any account to stake more value. + * @param _amount The amount of tokens to be staked. + */ + function stake(uint256 _amount) external payable nonReentrant { + if (_amount == 0) { + revert MustStakeMoreThanZero(); + } + _checksAndTransfer(_amount); + _addStake(msg.sender, _amount, false); + } + + /** + * @inheritdoc IStakeHolder + */ + function unstake(uint256 _amountToUnstake) external nonReentrant { + StakeInfo storage stakeInfo = balances[msg.sender]; + uint256 currentStake = stakeInfo.stake; + if (currentStake < _amountToUnstake) { + revert UnstakeAmountExceedsBalance(_amountToUnstake, currentStake); + } + uint256 newBalance = currentStake - _amountToUnstake; + stakeInfo.stake = newBalance; + + emit StakeRemoved(msg.sender, _amountToUnstake, newBalance); + + _sendValue(msg.sender, _amountToUnstake); + } + + /** + * @inheritdoc IStakeHolder + */ + function distributeRewards( + AccountAmount[] calldata _recipientsAndAmounts + ) external payable nonReentrant onlyRole(DISTRIBUTE_ROLE) { + // Distribute the value. + uint256 total = 0; + uint256 len = _recipientsAndAmounts.length; + for (uint256 i = 0; i < len; i++) { + AccountAmount calldata accountAmount = _recipientsAndAmounts[i]; + uint256 amount = accountAmount.amount; + // Add stake, but require the account to either currently be staking or have + // previously staked. + _addStake(accountAmount.account, amount, true); + total += amount; + } + if (total == 0) { + revert MustDistributeMoreThanZero(); + } + _checksAndTransfer(total); + emit Distributed(msg.sender, total, len); + } + + /** + * @inheritdoc IStakeHolder + */ + function getBalance(address _account) external view override(IStakeHolder) returns (uint256 _balance) { + return balances[_account].stake; + } + + /** + * @inheritdoc IStakeHolder + */ + function hasStaked(address _account) external view override(IStakeHolder) returns (bool _everStaked) { + return balances[_account].hasStaked; + } + + /** + * @inheritdoc IStakeHolder + */ + function getNumStakers() external view override(IStakeHolder) returns (uint256 _len) { + return stakers.length; + } + + /** + * @inheritdoc IStakeHolder + */ + function getStakers( + uint256 _startOffset, + uint256 _numberToReturn + ) external view override(IStakeHolder) returns (address[] memory _stakers) { + address[] memory stakerPartialArray = new address[](_numberToReturn); + for (uint256 i = 0; i < _numberToReturn; i++) { + stakerPartialArray[i] = stakers[_startOffset + i]; + } + return stakerPartialArray; + } + + /** + * @notice Add more stake to an account. + * @dev If the account has a zero balance prior to this call, add the account to the stakers array. + * @param _account Account to add stake to. + * @param _amount The amount of stake to add. + * @param _existingAccountsOnly If true, revert if the account has never been used. + */ + function _addStake(address _account, uint256 _amount, bool _existingAccountsOnly) internal { + StakeInfo storage stakeInfo = balances[_account]; + uint256 currentStake = stakeInfo.stake; + if (!stakeInfo.hasStaked) { + if (_existingAccountsOnly) { + revert AttemptToDistributeToNewAccount(_account, _amount); + } + stakers.push(_account); + stakeInfo.hasStaked = true; + } + uint256 newBalance = currentStake + _amount; + stakeInfo.stake = newBalance; + emit StakeAdded(_account, _amount, newBalance); + } + + /** + * @notice Send value to an account. + * @param _to The account to sent value to. + * @param _amount The quantity to send. + */ + function _sendValue(address _to, uint256 _amount) internal virtual; + + /** + * @notice Complete validity checks and for ERC20 variant transfer tokens. + * @param _amount Amount to be transferred. + */ + function _checksAndTransfer(uint256 _amount) internal virtual; + + // Override the _authorizeUpgrade function + // solhint-disable-next-line no-empty-blocks + function _authorizeUpgrade(address newImplementation) internal override onlyRole(UPGRADE_ROLE) {} + + /// @notice storage gap for additional variables for upgrades + // slither-disable-start unused-state + // solhint-disable-next-line var-name-mixedcase + uint256[50] private __StakeHolderBaseGap; + // slither-disable-end unused-state +} diff --git a/contracts/staking/StakeHolderERC20.sol b/contracts/staking/StakeHolderERC20.sol new file mode 100644 index 00000000..7329df0e --- /dev/null +++ b/contracts/staking/StakeHolderERC20.sol @@ -0,0 +1,74 @@ +// Copyright (c) Immutable Pty Ltd 2018 - 2025 +// SPDX-License-Identifier: Apache 2 +pragma solidity >=0.8.19 <0.8.29; + +import {IERC20Upgradeable} from "openzeppelin-contracts-upgradeable-4.9.3/token/ERC20/IERC20Upgradeable.sol"; +import {SafeERC20Upgradeable} from "openzeppelin-contracts-upgradeable-4.9.3/token/ERC20/utils/SafeERC20Upgradeable.sol"; +import {IStakeHolder, StakeHolderBase} from "./StakeHolderBase.sol"; + +/** + * @title StakeHolderERC20: allows anyone to stake any amount of an ERC20 token and to then remove all or part of that stake. + * @dev The StakeHolderERC20 contract is designed to be upgradeable. + */ +contract StakeHolderERC20 is StakeHolderBase { + using SafeERC20Upgradeable for IERC20Upgradeable; + + /// @notice The token used for staking. + IERC20Upgradeable internal token; + + /** + * @notice Initialises the upgradeable contract, setting up admin accounts. + * @param _roleAdmin the address to grant `DEFAULT_ADMIN_ROLE` to + * @param _upgradeAdmin the address to grant `UPGRADE_ROLE` to + * @param _distributeAdmin the address to grant `DISTRIBUTE_ROLE` to. + * @param _token the token to use for staking. + */ + function initialize( + address _roleAdmin, + address _upgradeAdmin, + address _distributeAdmin, + address _token + ) public initializer { + __StakeHolderERC20_init(_roleAdmin, _upgradeAdmin, _distributeAdmin, _token); + } + + function __StakeHolderERC20_init( + address _roleAdmin, + address _upgradeAdmin, + address _distributeAdmin, + address _token + ) internal onlyInitializing { + __StakeHolderBase_init(_roleAdmin, _upgradeAdmin, _distributeAdmin); + token = IERC20Upgradeable(_token); + } + + /** + * @inheritdoc IStakeHolder + */ + function getToken() external view returns (address) { + return address(token); + } + + /** + * @inheritdoc StakeHolderBase + */ + function _sendValue(address _to, uint256 _amount) internal override { + token.safeTransfer(_to, _amount); + } + + /** + * @inheritdoc StakeHolderBase + */ + function _checksAndTransfer(uint256 _amount) internal override { + if (msg.value != 0) { + revert NonPayable(); + } + token.safeTransferFrom(msg.sender, address(this), _amount); + } + + /// @notice storage gap for additional variables for upgrades + // slither-disable-start unused-state + // solhint-disable-next-line var-name-mixedcase + uint256[50] private __StakeHolderERC20Gap; + // slither-disable-end unused-state +} diff --git a/contracts/staking/StakeHolderNative.sol b/contracts/staking/StakeHolderNative.sol new file mode 100644 index 00000000..6961c35d --- /dev/null +++ b/contracts/staking/StakeHolderNative.sol @@ -0,0 +1,68 @@ +// Copyright (c) Immutable Pty Ltd 2018 - 2025 +// SPDX-License-Identifier: Apache 2 +pragma solidity >=0.8.19 <0.8.29; + +import {IStakeHolder, StakeHolderBase} from "./StakeHolderBase.sol"; + +/** + * @title StakeHolder: allows anyone to stake any amount of native IMX and to then remove all or part of that stake. + * @dev The StakeHolder contract is designed to be upgradeable. + */ +contract StakeHolderNative is StakeHolderBase { + /// @notice Error: Unstake transfer failed. + error UnstakeTransferFailed(); + + /** + * @notice Initialises the upgradeable contract, setting up admin accounts. + * @param _roleAdmin the address to grant `DEFAULT_ADMIN_ROLE` to + * @param _upgradeAdmin the address to grant `UPGRADE_ROLE` to + * @param _distributeAdmin the address to grant `DISTRIBUTE_ROLE` to + */ + function initialize(address _roleAdmin, address _upgradeAdmin, address _distributeAdmin) public initializer { + __StakeHolderBase_init(_roleAdmin, _upgradeAdmin, _distributeAdmin); + } + + /** + * @inheritdoc IStakeHolder + */ + function getToken() external view virtual returns (address) { + return address(0); + } + + /** + * @inheritdoc StakeHolderBase + */ + function _sendValue(address _to, uint256 _amount) internal virtual override { + // slither-disable-next-line low-level-calls,arbitrary-send-eth + (bool success, bytes memory returndata) = payable(_to).call{value: _amount}(""); + if (!success) { + // Look for revert reason and bubble it up if present. + // Revert reasons should contain an error selector, which is four bytes long. + if (returndata.length >= 4) { + // solhint-disable-next-line no-inline-assembly + assembly { + let returndata_size := mload(returndata) + revert(add(32, returndata), returndata_size) + } + } else { + revert UnstakeTransferFailed(); + } + } + } + + /** + * @inheritdoc StakeHolderBase + */ + function _checksAndTransfer(uint256 _amount) internal virtual override { + // Check that the amount matches the msg.value. + if (_amount != msg.value) { + revert MismatchMsgValueAmount(msg.value, _amount); + } + } + + /// @notice storage gap for additional variables for upgrades + // slither-disable-start unused-state + // solhint-disable-next-line var-name-mixedcase + uint256[50] private __StakeHolderNativeGap; + // slither-disable-end unused-state +} diff --git a/contracts/staking/StakeHolderWIMX.sol b/contracts/staking/StakeHolderWIMX.sol new file mode 100644 index 00000000..25396ecc --- /dev/null +++ b/contracts/staking/StakeHolderWIMX.sol @@ -0,0 +1,71 @@ +// Copyright (c) Immutable Pty Ltd 2018 - 2025 +// SPDX-License-Identifier: Apache 2 +pragma solidity >=0.8.19 <0.8.29; + +import {IStakeHolder, StakeHolderBase, StakeHolderNative} from "./StakeHolderNative.sol"; +import {IWIMX} from "./IWIMX.sol"; + +/** + * @title StakeHolderWIMX: allows anyone to stake any amount of IMX and to then remove all or part of that stake. + * @dev Stake can be added and withdrawn either as native IMX only. + * The StakeHolderWIMX contract is designed to be upgradeable. + */ +contract StakeHolderWIMX is StakeHolderNative { + /// @notice The token used for staking. + IWIMX internal wIMX; + + /** + * @notice Initialises the upgradeable contract, setting up admin accounts. + * @param _roleAdmin the address to grant `DEFAULT_ADMIN_ROLE` to + * @param _upgradeAdmin the address to grant `UPGRADE_ROLE` to + * @param _distributeAdmin the address to grant `DISTRIBUTE_ROLE` to + * @param _wIMXToken The address of the WIMX contract. + */ + function initialize( + address _roleAdmin, + address _upgradeAdmin, + address _distributeAdmin, + address _wIMXToken + ) public initializer { + __StakeHolderBase_init(_roleAdmin, _upgradeAdmin, _distributeAdmin); + wIMX = IWIMX(_wIMXToken); + } + + receive() external payable { + // Receive IMX sent by the WIMX contract when wIMX.withdraw() is called. + } + + /** + * @inheritdoc IStakeHolder + */ + function getToken() external view override returns (address) { + return address(wIMX); + } + + /** + * @inheritdoc StakeHolderBase + */ + function _sendValue(address _to, uint256 _amount) internal override { + // Convert WIMX to native IMX + wIMX.withdraw(_amount); + + super._sendValue(_to, _amount); + } + + /** + * @inheritdoc StakeHolderBase + */ + function _checksAndTransfer(uint256 _amount) internal override { + super._checksAndTransfer(_amount); + + // Convert native IMX to WIMX. + // slither-disable-next-line arbitrary-send-eth + wIMX.deposit{value: _amount}(); + } + + /// @notice storage gap for additional variables for upgrades + // slither-disable-start unused-state + // solhint-disable-next-line var-name-mixedcase + uint256[50] private __StakeHolderWIMXGap; + // slither-disable-end unused-state +} diff --git a/contracts/staking/WIMX.sol b/contracts/staking/WIMX.sol new file mode 100644 index 00000000..c41e22f9 --- /dev/null +++ b/contracts/staking/WIMX.sol @@ -0,0 +1,105 @@ +// Copyright Immutable Pty Ltd 2018 - 2023 +// SPDX-License-Identifier: GPL-3.0 +pragma solidity >=0.8.19 <0.8.29; + +import {IWIMX} from "./IWIMX.sol"; +import {Address} from "@openzeppelin/contracts/utils/Address.sol"; + +/** + * @notice WIMX is a wrapped IMX contract that allows users to wrap their native IMX. + * @dev This contract is adapted from the official Wrapped ETH contract. + * This contract is copied from https://github.com/immutable/zkevm-bridge-contracts/blob/main/src/child/WIMX.sol + */ +// solhint-disable custom-errors, reason-string +contract WIMX is IWIMX { + // slither-disable-start constable-states + string public name = "Wrapped IMX"; + string public symbol = "WIMX"; + uint8 public decimals = 18; + // slither-disable-end constable-states + + mapping(address account => uint256 balance) public balanceOf; + mapping(address account => mapping(address spender => uint256 amount)) public allowance; + + /** + * @notice Fallback function on receiving native IMX. + */ + receive() external payable { + deposit(); + } + + /** + * @notice Deposit native IMX in the function call and mint the equal amount of wrapped IMX to msg.sender. + */ + function deposit() public payable { + balanceOf[msg.sender] += msg.value; + emit Deposit(msg.sender, msg.value); + } + + /** + * @notice Withdraw given amount of native IMX to msg.sender and burn the equal amount of wrapped IMX. + * @param wad The amount to withdraw. + */ + // slither-disable-start reentrancy-events + function withdraw(uint256 wad) public { + require(balanceOf[msg.sender] >= wad, "Wrapped IMX: Insufficient balance"); + balanceOf[msg.sender] -= wad; + + Address.sendValue(payable(msg.sender), wad); + emit Withdrawal(msg.sender, wad); + } + // slither-disable-end reentrancy-events + + /** + * @notice Obtain the current total supply of wrapped IMX. + * @return uint The amount of supplied wrapped IMX. + */ + function totalSupply() public view returns (uint256) { + return address(this).balance; + } + + /** + * @notice Approve given spender the ability to spend a given amount of msg.sender's tokens. + * @param guy Approved spender. + * @param wad Amount of allowance. + * @return bool Returns true if function call is successful. + */ + function approve(address guy, uint256 wad) public returns (bool) { + allowance[msg.sender][guy] = wad; + emit Approval(msg.sender, guy, wad); + return true; + } + + /** + * @notice Transfer given amount of tokens from msg.sender to given destination. + * @param dst Destination of this transfer. + * @param wad Amount of this transfer. + * @return bool Returns true if function call is successful. + */ + function transfer(address dst, uint256 wad) public returns (bool) { + return transferFrom(msg.sender, dst, wad); + } + + /** + * @notice Transfer given amount of tokens from given source to given destination. + * @param src Source of this transfer. + * @param dst Destination of this transfer. + * @param wad Amount of this transfer. + * @return bool Returns true if function call is successful. + */ + function transferFrom(address src, address dst, uint256 wad) public returns (bool) { + require(balanceOf[src] >= wad, "Wrapped IMX: Insufficient balance"); + + if (src != msg.sender && allowance[src][msg.sender] != type(uint256).max) { + require(allowance[src][msg.sender] >= wad, "Wrapped IMX: Insufficient allowance"); + allowance[src][msg.sender] -= wad; + } + + balanceOf[src] -= wad; + balanceOf[dst] += wad; + + emit Transfer(src, dst, wad); + + return true; + } +} diff --git a/contracts/staking/staking-architecture.png b/contracts/staking/staking-architecture.png new file mode 100644 index 00000000..e0c4d48b Binary files /dev/null and b/contracts/staking/staking-architecture.png differ diff --git a/contracts/trading/seaport/zones/immutable-signed-zone/v2/ImmutableSignedZoneV2.sol b/contracts/trading/seaport/zones/immutable-signed-zone/v2/ImmutableSignedZoneV2.sol index 01f03efc..29631173 100644 --- a/contracts/trading/seaport/zones/immutable-signed-zone/v2/ImmutableSignedZoneV2.sol +++ b/contracts/trading/seaport/zones/immutable-signed-zone/v2/ImmutableSignedZoneV2.sol @@ -568,7 +568,7 @@ contract ImmutableSignedZoneV2 is uint256 scalingFactorDenominator ) internal pure returns (bytes32) { uint256 numberOfItems = receivedItems.length; - bytes memory receivedItemsHash; + bytes memory receivedItemsHash = new bytes(0); // Explicitly initialize to empty bytes for (uint256 i; i < numberOfItems; i++) { receivedItemsHash = abi.encodePacked( diff --git a/script/staking/DeployStakeHolder.sol b/script/staking/DeployStakeHolder.sol deleted file mode 100644 index 8dde34b6..00000000 --- a/script/staking/DeployStakeHolder.sol +++ /dev/null @@ -1,132 +0,0 @@ -// Copyright (c) Immutable Pty Ltd 2018 - 2023 -// SPDX-License-Identifier: Apache-2.0 -pragma solidity ^0.8.20; - -import "forge-std/Test.sol"; -import "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; -import {StakeHolder} from "../../contracts/staking/StakeHolder.sol"; - -/** - * @title IDeployer Interface - * @notice This interface defines the contract responsible for deploying and optionally initializing new contracts - * via a specified deployment method. - * @dev Credit to axelarnetwork https://github.com/axelarnetwork/axelar-gmp-sdk-solidity/blob/main/contracts/interfaces/IDeployer.sol - */ -interface IDeployer { - function deploy(bytes memory bytecode, bytes32 salt) external payable returns (address deployedAddress_); - function deployAndInit(bytes memory bytecode, bytes32 salt, bytes calldata init) - external - payable - returns (address deployedAddress_); - function deployedAddress(bytes calldata bytecode, address sender, bytes32 salt) - external - view - returns (address deployedAddress_); -} - -struct DeploymentArgs { - address signer; - address factory; - string salt1; - string salt2; -} - -struct StakeHolderContractArgs { - address roleAdmin; - address upgradeAdmin; - address distributeAdmin; -} - -/** - * @notice Deployment script and test code for the deployment script. - * @dev testDeploy is the test. - * @dev deploy() is the function the script should call. - * For more details on deployment see ../../contracts/staking/README.md - */ -contract DeployStakeHolder is Test { - function testDeploy() external { - /// @dev Fork the Immutable zkEVM testnet for this test - string memory rpcURL = "https://rpc.testnet.immutable.com"; - vm.createSelectFork(rpcURL); - - /// @dev These are Immutable zkEVM testnet values where necessary - DeploymentArgs memory deploymentArgs = DeploymentArgs({ - signer: 0xdDA0d9448Ebe3eA43aFecE5Fa6401F5795c19333, - factory: 0x37a59A845Bb6eD2034098af8738fbFFB9D589610, - salt1: "salt1", - salt2: "salt2" - }); - - StakeHolderContractArgs memory stakeHolderContractArgs = StakeHolderContractArgs({ - roleAdmin: makeAddr("role"), - upgradeAdmin: makeAddr("upgrade"), - distributeAdmin: makeAddr("distribute") - }); - - // Run deployment against forked testnet - StakeHolder deployedContract = _deploy(deploymentArgs, stakeHolderContractArgs); - - assertTrue(deployedContract.hasRole(deployedContract.UPGRADE_ROLE(), stakeHolderContractArgs.upgradeAdmin), "Upgrade admin should have upgrade role"); - assertTrue(deployedContract.hasRole(deployedContract.DEFAULT_ADMIN_ROLE(), stakeHolderContractArgs.roleAdmin), "Role admin should have default admin role"); - // The DEFAULT_ADMIN_ROLE should be revoked from the deployer account - assertFalse(deployedContract.hasRole(deployedContract.DEFAULT_ADMIN_ROLE(), deploymentArgs.signer), "msg.sender should not be an admin"); - } - - function deploy() external { - address signer = vm.envAddress("DEPLOYER_ADDRESS"); - address factory = vm.envAddress("OWNABLE_CREATE3_FACTORY_ADDRESS"); - address roleAdmin = vm.envAddress("ROLE_ADMIN"); - address upgradeAdmin = vm.envAddress("UPGRADE_ADMIN"); - address distributeAdmin = vm.envAddress("DISTRIBUTE_ADMIN"); - string memory salt1 = vm.envString("IMPL_SALT"); - string memory salt2 = vm.envString("PROXY_SALT"); - - DeploymentArgs memory deploymentArgs = DeploymentArgs({signer: signer, factory: factory, salt1: salt1, salt2: salt2}); - - StakeHolderContractArgs memory stakeHolderContractArgs = - StakeHolderContractArgs({roleAdmin: roleAdmin, upgradeAdmin: upgradeAdmin, distributeAdmin: distributeAdmin}); - - _deploy(deploymentArgs, stakeHolderContractArgs); - } - - function _deploy(DeploymentArgs memory deploymentArgs, StakeHolderContractArgs memory stakeHolderContractArgs) - internal - returns (StakeHolder stakeHolderContract) - { - IDeployer ownableCreate3 = IDeployer(deploymentArgs.factory); - - // Deploy StakeHolder via the Ownable Create3 factory. - // That is: StakeHolder impl = new StakeHolder(); - // Create deployment bytecode and encode constructor args - bytes memory deploymentBytecode = abi.encodePacked( - type(StakeHolder).creationCode - ); - bytes32 saltBytes = keccak256(abi.encode(deploymentArgs.salt1)); - - /// @dev Deploy the contract via the Ownable CREATE3 factory - vm.startBroadcast(deploymentArgs.signer); - address stakeHolderImplAddress = ownableCreate3.deploy(deploymentBytecode, saltBytes); - vm.stopBroadcast(); - - // Create init data for teh ERC1967 Proxy - bytes memory initData = abi.encodeWithSelector( - StakeHolder.initialize.selector, stakeHolderContractArgs.roleAdmin, - stakeHolderContractArgs.upgradeAdmin, stakeHolderContractArgs.distributeAdmin - ); - - // Deploy ERC1967Proxy via the Ownable Create3 factory. - // That is: ERC1967Proxy proxy = new ERC1967Proxy(address(impl), initData); - // Create deployment bytecode and encode constructor args - deploymentBytecode = abi.encodePacked( - type(ERC1967Proxy).creationCode, - abi.encode(stakeHolderImplAddress, initData) - ); - saltBytes = keccak256(abi.encode(deploymentArgs.salt2)); - - /// @dev Deploy the contract via the Ownable CREATE3 factory - vm.startBroadcast(deploymentArgs.signer); - address stakeHolderContractAddress = ownableCreate3.deploy(deploymentBytecode, saltBytes); - vm.stopBroadcast(); - stakeHolderContract = StakeHolder(stakeHolderContractAddress); - } -} diff --git a/script/staking/README.md b/script/staking/README.md new file mode 100644 index 00000000..c0549d42 --- /dev/null +++ b/script/staking/README.md @@ -0,0 +1,53 @@ +# Staking Scripts + +This directory contains a range of scripts for interacting with staking contracts. + +## Common Environment Variables + +The following variables must be specified for all scripts. They can be supplied via the environment or a `.env` file. + +* `IMMUTABLE_NETWORK`: Must be 1 for Immutable zkEVM Mainnet, 0 for Testnet. +* `BLOCKSCOUT_APIKEY`: API key for verifying contracts on Blockscout. The key for use with Immutable zkEVM Mainnet will be different to the one used for Testnet. API keys for Immtuable zkEVM Mainnet can be obtained in the [block explorer](https://explorer.immutable.com/account/api-key). +* `HARDWARE_WALLET`: Set to `ledger` for a Ledger hardware wallet, `trezor` for a Trezor hardware wallet, and not set when using a private key. See [Forge's documentation](https://book.getfoundry.sh/reference/forge/forge-script#wallet-options---hardware-wallet) for more information on hardware wallet configuration. +* `HD_PATH`: Hierarchical Deterministic path. Must be set if using a Ledger or Trezor hardware wallet. Should be of the form: `HD_PATH="m/44'/60'/0'/0/0"`. +* PRIVATE_KEY: A private key must be specified if HARDWARE_WALLET is not specified. The value should not be prefixed with `0x`. + +## Simple Deployment + +To deploy the `StakeHolderERC20.sol` contract with a `ERC1967Proxy.sol`, use the `deploySimple.sh` script. + +The following variables must be specified via the environment or a `.env` file for the `deploySimple.sh` script: + +* `DEPLOYER_ADDRESS`: Address that corresponds to the hardware wallet or private key. This account is used to deploy the `StakeHolderERC20` and the `ERC1967Proxy` contracts. +* `ROLE_ADMIN`: Account that will be the initial role administrator. Accounts with the role administrator access can manage which accounts have `UPGRADE_ADMIN` and `DISTRIBUTED_ADMIN` access. Specify 0x0000000000000000000000000000000000000000 to have no account with role administrator access. +* `UPGRADE_ADMIN`: Initial account that will be authorised to upgrade the StakeHolderERC20 contract. Specify 0x0000000000000000000000000000000000000000 to have no account with upgrade administrator access. +* `DISTRIBUTE_ADMIN`: Initial account that will be authorised to upgrade the StakeHolderERC20 contract. Specify 0x0000000000000000000000000000000000000000 to have no account with distribute administrator access. +* `ERC20_STAKING_TOKEN`: Address of ERC20 token to be used for staking. For use by ERC20 deployment script. +* `WIMX_TOKEN`: Address of WIMX token contract. For use by the WIMX deployment scirpt. + +## Complex Deployment + +To deploy the `StakeHolderERC20.sol` contract with a `ERC1967Proxy.sol` and a `TimelockController` using an `OwnableCreate3Deployer`, use the `deployComplex.sh` script. If you do not have access to an `OwnableCreate3Deployer` contract, use the `deployDeployer.sh` script to deploy this contract first. + +The following variables must be specified via the environment or a `.env` file for the `deployDeployer.sh` script: + +* `DEPLOYER_ADDRESS`: Address that corresponds to the hardware wallet of private key. This account is used to deploy the `OwnableCreate3Deployer` contract. + +The following variables must be specified via the environment or a `.env` file for the `deployComplex.sh` script: + +* `DEPLOYER_ADDRESS`: Address that corresponds to the hardware wallet or private key. This account is used to deploy the contracts via the `OwnableCreate3Deployer` contract. +* `OWNABLE_CREATE3_FACTORY_ADDRESS`: Address of the `OwnableCreate3Deployer` contract. +* `DISTRIBUTE_ADMIN`: Initial account that will be authorised to upgrade the StakeHolderERC20 contract. Specify 0x0000000000000000000000000000000000000000 to have no account with distribute administrator access. +* `ERC20_STAKING_TOKEN`: Address of ERC20 token to be used for staking. +* `TIMELOCK_DELAY_SECONDS`: Time in seconds between proposing actions and executing them. +* `TIMELOCK_PROPOSER_ADMIN`: Address of account that can propose actions. Multiple proposers can be specified by modifying `StakeHolderScript.t.sol`. +* `TIMELOCK_EXECUTOR_ADMIN`: Address of account that can execute actions. Multiple executors can be specified by modifying `StakeHolderScript.t.sol`. +* `SALT`: Value used as the basis of salts used to deploy contracts to deterministic addresses. + +## Staking and Unstaking + +The `stake.sh` script can be called to stake tokens and the `unstake.sh` script can be called to unstake tokens. Both scripts use the following variables: + +* `STAKE_HOLDER_CONTRACT`: The address of the deployed stake holder contract. +* `STAKER_ADDRESS`: The address of the staker. The address corresponds to the hardware wallet or the private key. +* `STAKER_AMOUNT`: The number of tokens. Note that the number of decimal places must be taken into account. For example, 1 IMX would be 1000000000000000000. diff --git a/script/staking/StakeHolderScriptERC20.t.sol b/script/staking/StakeHolderScriptERC20.t.sol new file mode 100644 index 00000000..b25f02c5 --- /dev/null +++ b/script/staking/StakeHolderScriptERC20.t.sol @@ -0,0 +1,400 @@ +// Copyright (c) Immutable Pty Ltd 2018 - 2023 +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.20; + +import "forge-std/Test.sol"; +import {ERC1967Proxy} from "openzeppelin-contracts-4.9.3/proxy/ERC1967/ERC1967Proxy.sol"; +import {TimelockController} from "openzeppelin-contracts-4.9.3/governance/TimelockController.sol"; +import {ERC20PresetFixedSupply} from "openzeppelin-contracts-4.9.3/token/ERC20/presets/ERC20PresetFixedSupply.sol"; +import {IERC20} from "openzeppelin-contracts-4.9.3/token/ERC20/IERC20.sol"; + +import {IStakeHolder} from "../../contracts/staking/IStakeHolder.sol"; +import {StakeHolderERC20} from "../../contracts/staking/StakeHolderERC20.sol"; +import {OwnableCreate3Deployer} from "../../contracts/deployer/create3/OwnableCreate3Deployer.sol"; + +/** + * @title IDeployer Interface + * @notice This interface defines the contract responsible for deploying and optionally initializing new contracts + * via a specified deployment method. + * @dev Credit to axelarnetwork https://github.com/axelarnetwork/axelar-gmp-sdk-solidity/blob/main/contracts/interfaces/IDeployer.sol + */ +interface IDeployer { + function deploy(bytes memory bytecode, bytes32 salt) external payable returns (address deployedAddress_); + function deployAndInit(bytes memory bytecode, bytes32 salt, bytes calldata init) + external + payable + returns (address deployedAddress_); + function deployedAddress(bytes calldata bytecode, address sender, bytes32 salt) + external + view + returns (address deployedAddress_); +} + +// Args needed for compex deployment using CREATE3 and a TimelockController +struct ComplexDeploymentArgs { + address signer; + address factory; + string salt; +} +struct ComplexStakeHolderContractArgs { + address distributeAdmin; + address token; +} +struct ComplexTimelockContractArgs { + uint256 timeDelayInSeconds; + address proposerAdmin; + address executorAdmin; +} + + +// Args needed for simple deployment +struct SimpleDeploymentArgs { + address deployer; +} +struct SimpleStakeHolderContractArgs { + address roleAdmin; + address upgradeAdmin; + address distributeAdmin; + address token; +} + + + +/** + * @notice Deployment script and test code for the deployment script. + * @dev testDeploy is the test. + * @dev deploy() is the function the script should call. + * For more details on deployment see ../../contracts/staking/README.md + */ +contract StakeHolderScriptERC20 is Test { + + /** + * Deploy the OwnableCreate3Deployer needed for the complex deployment. + */ + function deployDeployer() external { + address signer = vm.envAddress("DEPLOYER_ADDRESS"); + _deployDeployer(signer); + } + + /** + * Deploy StakeHolderERC20 using Create3, with the TimelockController. + */ + function deployComplex() external { + address signer = vm.envAddress("DEPLOYER_ADDRESS"); + address factory = vm.envAddress("OWNABLE_CREATE3_FACTORY_ADDRESS"); + address distributeAdmin = vm.envAddress("DISTRIBUTE_ADMIN"); + address token = vm.envAddress("ERC20_STAKING_TOKEN"); + uint256 timeDelayInSeconds = vm.envUint("TIMELOCK_DELAY_SECONDS"); + address proposerAdmin = vm.envAddress("TIMELOCK_PROPOSER_ADMIN"); + address executorAdmin = vm.envAddress("TIMELOCK_EXECUTOR_ADMIN"); + string memory salt = vm.envString("SALT"); + + ComplexDeploymentArgs memory deploymentArgs = ComplexDeploymentArgs({signer: signer, factory: factory, salt: salt}); + + ComplexStakeHolderContractArgs memory stakeHolderArgs = + ComplexStakeHolderContractArgs({distributeAdmin: distributeAdmin, token: token}); + + ComplexTimelockContractArgs memory timelockArgs = + ComplexTimelockContractArgs({timeDelayInSeconds: timeDelayInSeconds, proposerAdmin: proposerAdmin, executorAdmin: executorAdmin}); + _deployComplex(deploymentArgs, stakeHolderArgs, timelockArgs); + } + + /** + * Deploy StakeHolderERC20 using an EOA. + */ + function deploySimple() external { + address deployer = vm.envAddress("DEPLOYER_ADDRESS"); + address roleAdmin = vm.envAddress("ROLE_ADMIN"); + address upgradeAdmin = vm.envAddress("UPGRADE_ADMIN"); + address distributeAdmin = vm.envAddress("DISTRIBUTE_ADMIN"); + address token = vm.envAddress("ERC20_STAKING_TOKEN"); + + SimpleDeploymentArgs memory deploymentArgs = SimpleDeploymentArgs({deployer: deployer}); + + SimpleStakeHolderContractArgs memory stakeHolderArgs = + SimpleStakeHolderContractArgs({ + roleAdmin: roleAdmin, upgradeAdmin: upgradeAdmin, + distributeAdmin: distributeAdmin, token: token}); + _deploySimple(deploymentArgs, stakeHolderArgs); + } + + function stake() external { + address stakeHolder = vm.envAddress("STAKE_HOLDER_CONTRACT"); + address staker = vm.envAddress("STAKER_ADDRESS"); + uint256 amount = vm.envUint("STAKER_AMOUNT"); + _stake(IStakeHolder(stakeHolder), staker, amount); + } + + function unstake() external { + address stakeHolder = vm.envAddress("STAKE_HOLDER_CONTRACT"); + address staker = vm.envAddress("STAKER_ADDRESS"); + uint256 amount = vm.envUint("STAKER_AMOUNT"); + _unstake(IStakeHolder(stakeHolder), staker, amount); + } + + + + /** + * Deploy the OwnableCreate3Deployer contract. Set the owner to the + * contract deployer. + */ + function _deployDeployer(address _deployer) private { + vm.startBroadcast(_deployer); + new OwnableCreate3Deployer(_deployer); + vm.stopBroadcast(); + } + + /** + * Deploy StakeHolderERC20 using Create3, with the TimelockController. + */ + function _deployComplex( + ComplexDeploymentArgs memory deploymentArgs, + ComplexStakeHolderContractArgs memory stakeHolderArgs, + ComplexTimelockContractArgs memory timelockArgs) + private + returns (StakeHolderERC20 stakeHolderContract, TimelockController timelockController) + { + IDeployer ownableCreate3 = IDeployer(deploymentArgs.factory); + + bytes32 salt1 = keccak256(abi.encode(deploymentArgs.salt)); + bytes32 salt2 = keccak256(abi.encode(salt1)); + bytes32 salt3 = keccak256(abi.encode(salt2)); + + // Deploy TimelockController via the Ownable Create3 factory. + address timelockAddress; + bytes memory deploymentBytecode; + { + address[] memory proposers = new address[](1); + proposers[0] = timelockArgs.proposerAdmin; + address[] memory executors = new address[](1); + executors[0] = timelockArgs.executorAdmin; + // Create deployment bytecode and encode constructor args + deploymentBytecode = abi.encodePacked( + type(TimelockController).creationCode, + abi.encode( + timelockArgs.timeDelayInSeconds, + proposers, + executors, + address(0) + ) + ); + /// @dev Deploy the contract via the Ownable CREATE3 factory + vm.startBroadcast(deploymentArgs.signer); + timelockAddress = ownableCreate3.deploy(deploymentBytecode, salt1); + vm.stopBroadcast(); + } + + + // Deploy StakeHolderERC20 via the Ownable Create3 factory. + // Create deployment bytecode and encode constructor args + deploymentBytecode = abi.encodePacked( + type(StakeHolderERC20).creationCode + ); + /// @dev Deploy the contract via the Ownable CREATE3 factory + vm.startBroadcast(deploymentArgs.signer); + address stakeHolderImplAddress = ownableCreate3.deploy(deploymentBytecode, salt2); + vm.stopBroadcast(); + + // Deploy ERC1967Proxy via the Ownable Create3 factory. + // Create init data for the ERC1967 Proxy + bytes memory initData = abi.encodeWithSelector( + StakeHolderERC20.initialize.selector, + timelockAddress, // roleAdmin + timelockAddress, // upgradeAdmin + stakeHolderArgs.distributeAdmin, + stakeHolderArgs.token + ); + // Create deployment bytecode and encode constructor args + deploymentBytecode = abi.encodePacked( + type(ERC1967Proxy).creationCode, + abi.encode(stakeHolderImplAddress, initData) + ); + /// @dev Deploy the contract via the Ownable CREATE3 factory + vm.startBroadcast(deploymentArgs.signer); + address stakeHolderContractAddress = ownableCreate3.deploy(deploymentBytecode, salt3); + vm.stopBroadcast(); + + stakeHolderContract = StakeHolderERC20(stakeHolderContractAddress); + timelockController = TimelockController(payable(timelockAddress)); + } + + /** + * Deploy StakeHolderERC20 using an EOA and no time lock. + */ + function _deploySimple( + SimpleDeploymentArgs memory deploymentArgs, + SimpleStakeHolderContractArgs memory stakeHolderArgs) + private + returns (StakeHolderERC20 stakeHolderContract) { + + bytes memory initData = abi.encodeWithSelector( + StakeHolderERC20.initialize.selector, + stakeHolderArgs.roleAdmin, + stakeHolderArgs.upgradeAdmin, + stakeHolderArgs.distributeAdmin, + stakeHolderArgs.token); + + vm.startBroadcast(deploymentArgs.deployer); + StakeHolderERC20 impl = new StakeHolderERC20(); + vm.stopBroadcast(); + vm.startBroadcast(deploymentArgs.deployer); + ERC1967Proxy proxy = new ERC1967Proxy(address(impl), initData); + vm.stopBroadcast(); + + stakeHolderContract = StakeHolderERC20(address(proxy)); + } + + function _stake(IStakeHolder _stakeHolder, address _staker, uint256 _amount) private { + address tokenAddress = _stakeHolder.getToken(); + IERC20 erc20 = IERC20(tokenAddress); + + uint256 bal = erc20.balanceOf(_staker); + console.log("Balance is: %x", bal); + console.log("Amount is: %x", _amount); + if (bal < _amount) { + revert("Insufficient balance"); + } + + vm.startBroadcast(_staker); + erc20.approve(address(_stakeHolder), _amount); + _stakeHolder.stake(_amount); + vm.stopBroadcast(); + } + + function _unstake(IStakeHolder _stakeHolder, address _staker, uint256 _amount) private { + vm.startBroadcast(_staker); + _stakeHolder.unstake(_amount); + vm.stopBroadcast(); + } + + + function testComplex() external { + /// @dev Fork the Immutable zkEVM testnet for this test + string memory rpcURL = "https://rpc.testnet.immutable.com"; + vm.createSelectFork(rpcURL); + + address bank = makeAddr("bank"); + vm.startBroadcast(bank); + ERC20PresetFixedSupply erc20 = + new ERC20PresetFixedSupply("Name", "SYM", 1000 ether, bank); + vm.stopBroadcast(); + + /// @dev These are Immutable zkEVM testnet values where necessary + address immTestNetCreate3 = 0x37a59A845Bb6eD2034098af8738fbFFB9D589610; + ComplexDeploymentArgs memory deploymentArgs = ComplexDeploymentArgs({ + signer: 0xdDA0d9448Ebe3eA43aFecE5Fa6401F5795c19333, + factory: immTestNetCreate3, + salt: "salt" + }); + + address distributeAdmin = makeAddr("distribute"); + ComplexStakeHolderContractArgs memory stakeHolderArgs = + ComplexStakeHolderContractArgs({ + distributeAdmin: distributeAdmin, + token: address(erc20) + }); + + uint256 delay = 604800; // 604800 seconds = 1 week + address proposer = makeAddr("proposer"); + address executor = makeAddr("executor"); + + ComplexTimelockContractArgs memory timelockArgs = + ComplexTimelockContractArgs({ + timeDelayInSeconds: delay, + proposerAdmin: proposer, + executorAdmin: executor + }); + + // Run deployment against forked testnet + StakeHolderERC20 stakeHolder; + TimelockController timelockController; + (stakeHolder, timelockController) = + _deployComplex(deploymentArgs, stakeHolderArgs, timelockArgs); + + _commonTest(true, IStakeHolder(stakeHolder), address(timelockController), + bank, immTestNetCreate3, address(0), address(0), distributeAdmin); + + assertTrue(timelockController.hasRole(timelockController.PROPOSER_ROLE(), proposer), "Proposer not set correcrly"); + assertTrue(timelockController.hasRole(timelockController.EXECUTOR_ROLE(), executor), "Executor not set correcrly"); + assertEq(timelockController.getMinDelay(), delay, "Delay not set correctly"); + } + + function testSimple() external { + /// @dev Fork the Immutable zkEVM testnet for this test + string memory rpcURL = "https://rpc.testnet.immutable.com"; + vm.createSelectFork(rpcURL); + + address deployer = makeAddr("deployer"); + address bank = makeAddr("bank"); + + vm.startBroadcast(deployer); + ERC20PresetFixedSupply erc20 = + new ERC20PresetFixedSupply("Name", "SYM", 1000 ether, bank); + vm.stopBroadcast(); + + /// @dev These are Immutable zkEVM testnet values where necessary + SimpleDeploymentArgs memory deploymentArgs = SimpleDeploymentArgs({ + deployer: deployer + }); + + address roleAdmin = makeAddr("role"); + address upgradeAdmin = makeAddr("upgrade"); + address distributeAdmin = makeAddr("distribute"); + + SimpleStakeHolderContractArgs memory stakeHolderContractArgs = + SimpleStakeHolderContractArgs({ + roleAdmin: roleAdmin, + upgradeAdmin: upgradeAdmin, + distributeAdmin: distributeAdmin, + token: address(erc20) + }); + + // Run deployment against forked testnet + StakeHolderERC20 stakeHolder = _deploySimple(deploymentArgs, stakeHolderContractArgs); + + _commonTest(false, IStakeHolder(stakeHolder), address(0), + bank, deployer, roleAdmin, upgradeAdmin, distributeAdmin); + } + + function _commonTest( + bool _isComplex, + IStakeHolder _stakeHolder, + address _timelockControl, + address _bank, + address _deployer, + address _roleAdmin, + address _upgradeAdmin, + address _distributeAdmin + ) private { + address roleAdmin = _isComplex ? _timelockControl : _roleAdmin; + address upgradeAdmin = _isComplex ? _timelockControl : _upgradeAdmin; + + address tokenAddress = _stakeHolder.getToken(); + IERC20 erc20 = IERC20(tokenAddress); + + // Post deployment checks + { + StakeHolderERC20 temp = new StakeHolderERC20(); + bytes32 defaultAdminRole = temp.DEFAULT_ADMIN_ROLE(); + assertTrue(_stakeHolder.hasRole(_stakeHolder.UPGRADE_ROLE(), upgradeAdmin), "Upgrade admin should have upgrade role"); + assertTrue(_stakeHolder.hasRole(defaultAdminRole, roleAdmin), "Role admin should have default admin role"); + assertTrue(_stakeHolder.hasRole(_stakeHolder.DISTRIBUTE_ROLE(), _distributeAdmin), "Distribute admin should have distribute role"); + // The DEFAULT_ADMIN_ROLE should be revoked from the deployer account + assertFalse(_stakeHolder.hasRole(defaultAdminRole, _deployer), "msg.sender should not be an admin"); + } + + address user1 = makeAddr("user1"); + vm.startBroadcast(_bank); + erc20.transfer(user1, 100 ether); + vm.stopBroadcast(); + + _stake(_stakeHolder, user1, 10 ether); + + assertEq(erc20.balanceOf(user1), 90 ether, "User1 balance after stake"); + assertEq(erc20.balanceOf(address(_stakeHolder)), 10 ether, "StakeHolder balance after stake"); + + _unstake(_stakeHolder, user1, 7 ether); + assertEq(erc20.balanceOf(user1), 97 ether, "User1 balance after unstake"); + assertEq(erc20.balanceOf(address(_stakeHolder)), 3 ether, "StakeHolder balance after unstake"); + } +} diff --git a/script/staking/StakeHolderScriptWIMX.t.sol b/script/staking/StakeHolderScriptWIMX.t.sol new file mode 100644 index 00000000..1ee396f6 --- /dev/null +++ b/script/staking/StakeHolderScriptWIMX.t.sol @@ -0,0 +1,387 @@ +// Copyright (c) Immutable Pty Ltd 2018 - 2023 +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.20; + +import "forge-std/Test.sol"; +import {ERC1967Proxy} from "openzeppelin-contracts-4.9.3/proxy/ERC1967/ERC1967Proxy.sol"; +import {TimelockController} from "openzeppelin-contracts-4.9.3/governance/TimelockController.sol"; +import {IERC20} from "openzeppelin-contracts-4.9.3/token/ERC20/IERC20.sol"; + +import {IStakeHolder} from "../../contracts/staking/IStakeHolder.sol"; +import {StakeHolderWIMX} from "../../contracts/staking/StakeHolderWIMX.sol"; +import {WIMX} from "../../contracts/staking/WIMX.sol"; +import {OwnableCreate3Deployer} from "../../contracts/deployer/create3/OwnableCreate3Deployer.sol"; + +/** + * @title IDeployer Interface + * @notice This interface defines the contract responsible for deploying and optionally initializing new contracts + * via a specified deployment method. + * @dev Credit to axelarnetwork https://github.com/axelarnetwork/axelar-gmp-sdk-solidity/blob/main/contracts/interfaces/IDeployer.sol + */ +interface IDeployer { + function deploy(bytes memory bytecode, bytes32 salt) external payable returns (address deployedAddress_); + function deployAndInit(bytes memory bytecode, bytes32 salt, bytes calldata init) + external + payable + returns (address deployedAddress_); + function deployedAddress(bytes calldata bytecode, address sender, bytes32 salt) + external + view + returns (address deployedAddress_); +} + +// Args needed for compex deployment using CREATE3 and a TimelockController +struct ComplexDeploymentArgs { + address signer; + address factory; + string salt; +} +struct ComplexStakeHolderContractArgs { + address distributeAdmin; + address token; +} +struct ComplexTimelockContractArgs { + uint256 timeDelayInSeconds; + address proposerAdmin; + address executorAdmin; +} + + +// Args needed for simple deployment +struct SimpleDeploymentArgs { + address deployer; +} +struct SimpleStakeHolderContractArgs { + address roleAdmin; + address upgradeAdmin; + address distributeAdmin; + address token; +} + + + +/** + * @notice Deployment script and test code for the deployment script. + * @dev testDeploy is the test. + * @dev deploy() is the function the script should call. + * For more details on deployment see ../../contracts/staking/README.md + */ +contract StakeHolderScriptWIMX is Test { + + /** + * Deploy the OwnableCreate3Deployer needed for the complex deployment. + */ + function deployDeployer() external { + address signer = vm.envAddress("DEPLOYER_ADDRESS"); + _deployDeployer(signer); + } + + /** + * Deploy StakeHolderWIMX using Create3, with the TimelockController. + */ + function deployComplex() external { + address signer = vm.envAddress("DEPLOYER_ADDRESS"); + address factory = vm.envAddress("OWNABLE_CREATE3_FACTORY_ADDRESS"); + address distributeAdmin = vm.envAddress("DISTRIBUTE_ADMIN"); + address token = vm.envAddress("WIMX_TOKEN"); + uint256 timeDelayInSeconds = vm.envUint("TIMELOCK_DELAY_SECONDS"); + address proposerAdmin = vm.envAddress("TIMELOCK_PROPOSER_ADMIN"); + address executorAdmin = vm.envAddress("TIMELOCK_EXECUTOR_ADMIN"); + string memory salt = vm.envString("SALT"); + + ComplexDeploymentArgs memory deploymentArgs = ComplexDeploymentArgs({signer: signer, factory: factory, salt: salt}); + + ComplexStakeHolderContractArgs memory stakeHolderArgs = + ComplexStakeHolderContractArgs({distributeAdmin: distributeAdmin, token: token}); + + ComplexTimelockContractArgs memory timelockArgs = + ComplexTimelockContractArgs({timeDelayInSeconds: timeDelayInSeconds, proposerAdmin: proposerAdmin, executorAdmin: executorAdmin}); + _deployComplex(deploymentArgs, stakeHolderArgs, timelockArgs); + } + + /** + * Deploy StakeHolderWIMX using an EOA. + */ + function deploySimple() external { + address deployer = vm.envAddress("DEPLOYER_ADDRESS"); + address roleAdmin = vm.envAddress("ROLE_ADMIN"); + address upgradeAdmin = vm.envAddress("UPGRADE_ADMIN"); + address distributeAdmin = vm.envAddress("DISTRIBUTE_ADMIN"); + address token = vm.envAddress("WIMX_TOKEN"); + + SimpleDeploymentArgs memory deploymentArgs = SimpleDeploymentArgs({deployer: deployer}); + + SimpleStakeHolderContractArgs memory stakeHolderArgs = + SimpleStakeHolderContractArgs({ + roleAdmin: roleAdmin, upgradeAdmin: upgradeAdmin, + distributeAdmin: distributeAdmin, token: token}); + _deploySimple(deploymentArgs, stakeHolderArgs); + } + + function stake() external { + address stakeHolder = vm.envAddress("STAKE_HOLDER_CONTRACT"); + address staker = vm.envAddress("STAKER_ADDRESS"); + uint256 amount = vm.envUint("STAKER_AMOUNT"); + _stake(IStakeHolder(stakeHolder), staker, amount); + } + + function unstake() external { + address stakeHolder = vm.envAddress("STAKE_HOLDER_CONTRACT"); + address staker = vm.envAddress("STAKER_ADDRESS"); + uint256 amount = vm.envUint("STAKER_AMOUNT"); + _unstake(IStakeHolder(stakeHolder), staker, amount); + } + + + + /** + * Deploy the OwnableCreate3Deployer contract. Set the owner to the + * contract deployer. + */ + function _deployDeployer(address _deployer) private { + vm.startBroadcast(_deployer); + new OwnableCreate3Deployer(_deployer); + vm.stopBroadcast(); + } + + /** + * Deploy StakeHolderWIMX using Create3, with the TimelockController. + */ + function _deployComplex( + ComplexDeploymentArgs memory deploymentArgs, + ComplexStakeHolderContractArgs memory stakeHolderArgs, + ComplexTimelockContractArgs memory timelockArgs) + private + returns (StakeHolderWIMX stakeHolderContract, TimelockController timelockController) + { + IDeployer ownableCreate3 = IDeployer(deploymentArgs.factory); + + bytes32 salt1 = keccak256(abi.encode(deploymentArgs.salt)); + bytes32 salt2 = keccak256(abi.encode(salt1)); + bytes32 salt3 = keccak256(abi.encode(salt2)); + + // Deploy TimelockController via the Ownable Create3 factory. + address timelockAddress; + bytes memory deploymentBytecode; + { + address[] memory proposers = new address[](1); + proposers[0] = timelockArgs.proposerAdmin; + address[] memory executors = new address[](1); + executors[0] = timelockArgs.executorAdmin; + // Create deployment bytecode and encode constructor args + deploymentBytecode = abi.encodePacked( + type(TimelockController).creationCode, + abi.encode( + timelockArgs.timeDelayInSeconds, + proposers, + executors, + address(0) + ) + ); + /// @dev Deploy the contract via the Ownable CREATE3 factory + vm.startBroadcast(deploymentArgs.signer); + timelockAddress = ownableCreate3.deploy(deploymentBytecode, salt1); + vm.stopBroadcast(); + } + + + // Deploy StakeHolderWIMX via the Ownable Create3 factory. + // Create deployment bytecode and encode constructor args + deploymentBytecode = abi.encodePacked( + type(StakeHolderWIMX).creationCode + ); + /// @dev Deploy the contract via the Ownable CREATE3 factory + vm.startBroadcast(deploymentArgs.signer); + address stakeHolderImplAddress = ownableCreate3.deploy(deploymentBytecode, salt2); + vm.stopBroadcast(); + + // Deploy ERC1967Proxy via the Ownable Create3 factory. + // Create init data for the ERC1967 Proxy + bytes memory initData = abi.encodeWithSelector( + StakeHolderWIMX.initialize.selector, + timelockAddress, // roleAdmin + timelockAddress, // upgradeAdmin + stakeHolderArgs.distributeAdmin, + stakeHolderArgs.token + ); + // Create deployment bytecode and encode constructor args + deploymentBytecode = abi.encodePacked( + type(ERC1967Proxy).creationCode, + abi.encode(stakeHolderImplAddress, initData) + ); + /// @dev Deploy the contract via the Ownable CREATE3 factory + vm.startBroadcast(deploymentArgs.signer); + address stakeHolderContractAddress = ownableCreate3.deploy(deploymentBytecode, salt3); + vm.stopBroadcast(); + + stakeHolderContract = StakeHolderWIMX(payable(stakeHolderContractAddress)); + timelockController = TimelockController(payable(timelockAddress)); + } + + /** + * Deploy StakeHolderWIMX using an EOA and no time lock. + */ + function _deploySimple( + SimpleDeploymentArgs memory deploymentArgs, + SimpleStakeHolderContractArgs memory stakeHolderArgs) + private + returns (StakeHolderWIMX stakeHolderContract) { + + bytes memory initData = abi.encodeWithSelector( + StakeHolderWIMX.initialize.selector, + stakeHolderArgs.roleAdmin, + stakeHolderArgs.upgradeAdmin, + stakeHolderArgs.distributeAdmin, + stakeHolderArgs.token); + + vm.startBroadcast(deploymentArgs.deployer); + StakeHolderWIMX impl = new StakeHolderWIMX(); + vm.stopBroadcast(); + vm.startBroadcast(deploymentArgs.deployer); + ERC1967Proxy proxy = new ERC1967Proxy(address(impl), initData); + vm.stopBroadcast(); + + stakeHolderContract = StakeHolderWIMX(payable(address(proxy))); + } + + function _stake(IStakeHolder _stakeHolder, address _staker, uint256 _amount) private { + uint256 bal = _staker.balance; + console.log("Balance is: %x", bal); + console.log("Amount is: %x", _amount); + if (bal < _amount) { + revert("Insufficient balance"); + } + + vm.startBroadcast(_staker); + _stakeHolder.stake{value: _amount} (_amount); + vm.stopBroadcast(); + } + + function _unstake(IStakeHolder _stakeHolder, address _staker, uint256 _amount) private { + vm.startBroadcast(_staker); + _stakeHolder.unstake(_amount); + vm.stopBroadcast(); + } + + + function testComplex() external { + /// @dev Fork the Immutable zkEVM testnet for this test + string memory rpcURL = "https://rpc.testnet.immutable.com"; + vm.createSelectFork(rpcURL); + + address payable wimxOnTestnet = payable(address(0x1CcCa691501174B4A623CeDA58cC8f1a76dc3439)); + WIMX erc20 = WIMX(wimxOnTestnet); + + /// @dev These are Immutable zkEVM testnet values where necessary + address immTestNetCreate3 = 0x37a59A845Bb6eD2034098af8738fbFFB9D589610; + ComplexDeploymentArgs memory deploymentArgs = ComplexDeploymentArgs({ + signer: 0xdDA0d9448Ebe3eA43aFecE5Fa6401F5795c19333, + factory: immTestNetCreate3, + salt: "salt" + }); + + address distributeAdmin = makeAddr("distribute"); + ComplexStakeHolderContractArgs memory stakeHolderArgs = + ComplexStakeHolderContractArgs({ + distributeAdmin: distributeAdmin, + token: address(erc20) + }); + + uint256 delay = 604800; // 604800 seconds = 1 week + address proposer = makeAddr("proposer"); + address executor = makeAddr("executor"); + + ComplexTimelockContractArgs memory timelockArgs = + ComplexTimelockContractArgs({ + timeDelayInSeconds: delay, + proposerAdmin: proposer, + executorAdmin: executor + }); + + // Run deployment against forked testnet + StakeHolderWIMX stakeHolder; + TimelockController timelockController; + (stakeHolder, timelockController) = + _deployComplex(deploymentArgs, stakeHolderArgs, timelockArgs); + + _commonTest(true, IStakeHolder(stakeHolder), address(timelockController), + immTestNetCreate3, address(0), address(0), distributeAdmin); + + assertTrue(timelockController.hasRole(timelockController.PROPOSER_ROLE(), proposer), "Proposer not set correcrly"); + assertTrue(timelockController.hasRole(timelockController.EXECUTOR_ROLE(), executor), "Executor not set correcrly"); + assertEq(timelockController.getMinDelay(), delay, "Delay not set correctly"); + } + + function testSimple() external { + /// @dev Fork the Immutable zkEVM testnet for this test + string memory rpcURL = "https://rpc.testnet.immutable.com"; + vm.createSelectFork(rpcURL); + + address deployer = makeAddr("deployer"); + + address payable wimxOnTestnet = payable(address(0x1CcCa691501174B4A623CeDA58cC8f1a76dc3439)); + WIMX erc20 = WIMX(wimxOnTestnet); + + /// @dev These are Immutable zkEVM testnet values where necessary + SimpleDeploymentArgs memory deploymentArgs = SimpleDeploymentArgs({ + deployer: deployer + }); + + address roleAdmin = makeAddr("role"); + address upgradeAdmin = makeAddr("upgrade"); + address distributeAdmin = makeAddr("distribute"); + + SimpleStakeHolderContractArgs memory stakeHolderContractArgs = + SimpleStakeHolderContractArgs({ + roleAdmin: roleAdmin, + upgradeAdmin: upgradeAdmin, + distributeAdmin: distributeAdmin, + token: address(erc20) + }); + + // Run deployment against forked testnet + StakeHolderWIMX stakeHolder = _deploySimple(deploymentArgs, stakeHolderContractArgs); + + _commonTest(false, IStakeHolder(stakeHolder), address(0), + deployer, roleAdmin, upgradeAdmin, distributeAdmin); + } + + function _commonTest( + bool _isComplex, + IStakeHolder _stakeHolder, + address _timelockControl, + address _deployer, + address _roleAdmin, + address _upgradeAdmin, + address _distributeAdmin + ) private { + address roleAdmin = _isComplex ? _timelockControl : _roleAdmin; + address upgradeAdmin = _isComplex ? _timelockControl : _upgradeAdmin; + + address tokenAddress = _stakeHolder.getToken(); + IERC20 erc20 = IERC20(tokenAddress); + + // Post deployment checks + { + StakeHolderWIMX temp = new StakeHolderWIMX(); + bytes32 defaultAdminRole = temp.DEFAULT_ADMIN_ROLE(); + assertTrue(_stakeHolder.hasRole(_stakeHolder.UPGRADE_ROLE(), upgradeAdmin), "Upgrade admin should have upgrade role"); + assertTrue(_stakeHolder.hasRole(defaultAdminRole, roleAdmin), "Role admin should have default admin role"); + assertTrue(_stakeHolder.hasRole(_stakeHolder.DISTRIBUTE_ROLE(), _distributeAdmin), "Distribute admin should have distribute role"); + // The DEFAULT_ADMIN_ROLE should be revoked from the deployer account + assertFalse(_stakeHolder.hasRole(defaultAdminRole, _deployer), "msg.sender should not be an admin"); + } + + address user1 = makeAddr("user1"); + vm.deal(user1, 100 ether); + + _stake(_stakeHolder, user1, 10 ether); + + assertEq(user1.balance, 90 ether, "User1 balance after stake"); + assertEq(erc20.balanceOf(address(_stakeHolder)), 10 ether, "StakeHolder balance after stake"); + + _unstake(_stakeHolder, user1, 7 ether); + assertEq(user1.balance, 97 ether, "User1 balance after unstake"); + assertEq(erc20.balanceOf(address(_stakeHolder)), 3 ether, "StakeHolder balance after unstake"); + } +} diff --git a/script/staking/common.sh b/script/staking/common.sh new file mode 100644 index 00000000..77c430e0 --- /dev/null +++ b/script/staking/common.sh @@ -0,0 +1,106 @@ +#!/bin/bash + +# Load the .env file if it exists +if [ -f .env ] +then + set -a; source .env; set +a +fi + +if [ -z "${IMMUTABLE_NETWORK}" ]; then + echo "Error: IMMUTABLE_NETWORK variable is not set" + exit 1 +fi +if [[ $IMMUTABLE_NETWORK -eq 1 ]] +then + echo Immutable zkEVM Mainnet Configuration + IMMUTABLE_RPC=https://rpc.immutable.com + BLOCKSCOUT_URI=https://explorer.immutable.com/api? +else + echo Immutable zkEVM Testnet Configuration + IMMUTABLE_RPC=https://rpc.testnet.immutable.com + BLOCKSCOUT_URI=https://explorer.testnet.immutable.com/api? +fi +if [ -z "${BLOCKSCOUT_APIKEY}" ]; then + echo "Error: BLOCKSCOUT_APIKEY environment variable is not set" + exit 1 +fi + +if [ "$HARDWARE_WALLET" = "ledger" ] || [ "$HARDWARE_WALLET" = "trezor" ]; then + echo " with ${HARDWARE_WALLET} Hardware Wallet" + if [ -z "${HD_PATH}" ]; then + echo "Error: HD_PATH environment variable is not set" + exit 1 + fi +else + echo " with a raw private key" + if [ -z "${PRIVATE_KEY}" ]; then + echo "Error: PRIVATE_KEY environment variable is not set" + exit 1 + fi +fi + +if [ -z "${FUNCTION_TO_EXECUTE}" ]; then + echo "Error: FUNCTION_TO_EXECUTE variable is not set" + exit 1 +fi + +if [ -z "${STAKEHOLDER_TYPE}" ]; then + echo "Error: STAKEHOLDER_TYPE variable is not set. Should be ERC20 or WIMX" + exit 1 +fi +if [ "$STAKEHOLDER_TYPE" = "ERC20" ]; then + script=script/staking/StakeHolderScriptERC20.t.sol:StakeHolderScriptERC20 +else + if [ "$STAKEHOLDER_TYPE" = "WIMX" ]; then + script=script/staking/StakeHolderScriptWIMX.t.sol:StakeHolderScriptWIMX + else + echo "Error: Unknown STAKEHOLDER_TYPE: " $STAKEHOLDER_TYPE + exit 1 + fi +fi + + +echo "Configuration" +echo " IMMUTABLE_RPC: $IMMUTABLE_RPC" +echo " BLOCKSCOUT_APIKEY: $BLOCKSCOUT_APIKEY" +echo " BLOCKSCOUT_URI: $BLOCKSCOUT_URI" +if [ "${HARDWARE_WALLET}" = "ledger" ] || [ "${HARDWARE_WALLET}" = "trezor" ]; then + echo Hardware type: ${HARDWARE_WALLET} + echo HD_PATH: $HD_PATH +else + echo " PRIVATE_KEY: " # $PRIVATE_KEY +fi +echo " Function to execute: $FUNCTION_TO_EXECUTE" +echo " Script to execute: $script" + + +# NOTE WELL --------------------------------------------- +# Add resume option if the script fails part way through: +# --resume \ +# NOTE WELL --------------------------------------------- +if [ "${HARDWARE_WALLET}" = "ledger" ] || [ "${HARDWARE_WALLET}" = "trezor" ]; then + forge script --rpc-url $IMMUTABLE_RPC \ + --priority-gas-price 10000000000 \ + --with-gas-price 10000000100 \ + -vvv \ + --broadcast \ + --verify \ + --verifier blockscout \ + --verifier-url $BLOCKSCOUT_URI$BLOCKSCOUT_APIKEY \ + --sig "$FUNCTION_TO_EXECUTE" \ + --$HARDWARE_WALLET \ + --hd-paths "$HD_PATH" \ + $script +else + forge script --rpc-url $IMMUTABLE_RPC \ + --priority-gas-price 10000000000 \ + --with-gas-price 10000000100 \ + -vvv \ + --broadcast \ + --verify \ + --verifier blockscout \ + --verifier-url $BLOCKSCOUT_URI$BLOCKSCOUT_APIKEY \ + --sig "$FUNCTION_TO_EXECUTE" \ + --private-key $PRIVATE_KEY \ + $script +fi diff --git a/script/staking/deployComplex.sh b/script/staking/deployComplex.sh new file mode 100644 index 00000000..6df9142c --- /dev/null +++ b/script/staking/deployComplex.sh @@ -0,0 +1,6 @@ +#!/bin/bash +FUNCTION_TO_EXECUTE='deployComplex()' +# Set-up variables and execute forge +source $(dirname "$0")/common.sh + + diff --git a/script/staking/deployDeployer.sh b/script/staking/deployDeployer.sh new file mode 100644 index 00000000..65173489 --- /dev/null +++ b/script/staking/deployDeployer.sh @@ -0,0 +1,6 @@ +#!/bin/bash +FUNCTION_TO_EXECUTE='deployDeployer()' +# Set-up variables and execute forge +source $(dirname "$0")/common.sh + + diff --git a/script/staking/deploySimple.sh b/script/staking/deploySimple.sh new file mode 100644 index 00000000..7f971cb0 --- /dev/null +++ b/script/staking/deploySimple.sh @@ -0,0 +1,6 @@ +#!/bin/bash +FUNCTION_TO_EXECUTE='deploySimple()' +# Set-up variables and execute forge +source $(dirname "$0")/common.sh + + diff --git a/script/staking/stake.sh b/script/staking/stake.sh new file mode 100644 index 00000000..96e4a0f9 --- /dev/null +++ b/script/staking/stake.sh @@ -0,0 +1,6 @@ +#!/bin/bash +FUNCTION_TO_EXECUTE='stake()' +# Set-up variables and execute forge +source $(dirname "$0")/common.sh + + diff --git a/script/staking/unstake.sh b/script/staking/unstake.sh new file mode 100644 index 00000000..9c7485cc --- /dev/null +++ b/script/staking/unstake.sh @@ -0,0 +1,6 @@ +#!/bin/bash +FUNCTION_TO_EXECUTE='unstake()' +# Set-up variables and execute forge +source $(dirname "$0")/common.sh + + diff --git a/test/staking/README.md b/test/staking/README.md index d45cc28e..57db53f1 100644 --- a/test/staking/README.md +++ b/test/staking/README.md @@ -1,8 +1,10 @@ # Test Plan for Staking contracts -## [StakeHolder.sol](../../contracts/staking/StakeHolder.sol) +## [IStakeHolder.sol](../../contracts/staking/IStakeHolder.sol) -Initialize testing (in [StakeHolderInit.t.sol](../../contracts/staking/StakeHolderInit.t.sol)): +[StakeHolderNative.sol](../../contracts/staking/StakeHolderNative.sol) and [StakeHolderERC20.sol](../../contracts/staking/StakeHolderERC20.sol) use common base tests. + +Initialize testing (in [StakeHolderInitBase.t.sol](./StakeHolderInitBase.t.sol)): | Test name | Description | Happy Case | Implemented | |---------------------------------|------------------------------------------------------------|------------|-------------| @@ -11,7 +13,7 @@ Initialize testing (in [StakeHolderInit.t.sol](../../contracts/staking/StakeHold | testAdmins | Check that role and upgrade admin have been set correctly. | Yes | Yes | -Configuration tests (in [StakeHolderConfig.t.sol](../../contracts/staking/StakeHolderConfig.t.sol)):: +Configuration tests (in [StakeHolderConfigBase.t.sol](./StakeHolderConfigBase.t.sol)): | Test name | Description | Happy Case | Implemented | |---------------------------------|------------------------------------------------------------|------------|-------------| @@ -21,12 +23,10 @@ Configuration tests (in [StakeHolderConfig.t.sol](../../contracts/staking/StakeH | testUpgradeAuthFail | Try upgrade from account that doesn't have upgrade role. | No | Yes | | testAddRevokeRenounceRoleAdmin | Check adding, removing, and renouncing role admins. | Yes | Yes | | testAddRevokeRenounceUpgradeAdmin | Check adding, removing, and renouncing upgrade admins. | Yes | Yes | -| testRenounceLastRoleAdmin | Check that attempting to renounce last role admin fails. | No | Yes | -| testRevokeLastRoleAdmin | Check that attempting to revoke last role admin fails. | No | Yes | | testRoleAdminAuthFail | Attempt to add an upgrade admin from a non-role admin. | No | Yes | -Operational tests (in [StakeHolderOperational.t.sol](../../contracts/staking/StakeHolderOperational.t.sol)):: +Operational tests (in [StakeHolderOperationalBase.t.sol](./StakeHolderOperationalBase.t.sol)): | Test name | Description | Happy Case | Implemented | |--------------------------------|-------------------------------------------------------------|------------|-------------| @@ -50,3 +50,4 @@ Operational tests (in [StakeHolderOperational.t.sol](../../contracts/staking/Sta | testDistributeToUnusedAccount | Attempt to distribute rewards to an account that has never staked. | No | Yes | | testDistributeBadAuth | Attempt to distribute rewards using an unauthorised account. | No | Yes | + diff --git a/test/staking/StakeHolderAttackWallet.sol b/test/staking/StakeHolderAttackWallet.sol index 33538771..6180853d 100644 --- a/test/staking/StakeHolderAttackWallet.sol +++ b/test/staking/StakeHolderAttackWallet.sol @@ -2,13 +2,13 @@ // SPDX-License-Identifier: Apache 2.0 pragma solidity >=0.8.19 <0.8.29; -import {StakeHolder} from "../../contracts/staking/StakeHolder.sol"; +import {StakeHolderNative} from "../../contracts/staking/StakeHolderNative.sol"; // Wallet designed to attempt reentrancy attacks contract StakeHolderAttackWallet { - StakeHolder public stakeHolder; + StakeHolderNative public stakeHolder; constructor(address _stakeHolder) { - stakeHolder = StakeHolder(_stakeHolder); + stakeHolder = StakeHolderNative(_stakeHolder); } receive() external payable { // Assumung the call to unstake is for a "whole" number, say 1 ether, then @@ -20,7 +20,7 @@ contract StakeHolderAttackWallet { } } function stake(uint256 _amount) external { - stakeHolder.stake{value: _amount}(); + stakeHolder.stake{value: _amount}(_amount); } function unstake(uint256 _amount) external { stakeHolder.unstake(_amount); diff --git a/test/staking/StakeHolderBase.t.sol b/test/staking/StakeHolderBase.t.sol index f46afc84..0fa8fb60 100644 --- a/test/staking/StakeHolderBase.t.sol +++ b/test/staking/StakeHolderBase.t.sol @@ -6,19 +6,16 @@ pragma solidity >=0.8.19 <0.8.29; // solhint-disable-next-line no-global-import import "forge-std/Test.sol"; -import {StakeHolder} from "../../contracts/staking/StakeHolder.sol"; +import {IStakeHolder} from "../../contracts/staking/IStakeHolder.sol"; +import {StakeHolderNative} from "../../contracts/staking/StakeHolderNative.sol"; -import {ERC1967Proxy} from "openzeppelin-contracts-4.9.3/proxy/ERC1967/ERC1967Proxy.sol"; - - -contract StakeHolderBaseTest is Test { +abstract contract StakeHolderBaseTest is Test { bytes32 public defaultAdminRole; bytes32 public upgradeRole; bytes32 public distributeRole; - ERC1967Proxy public proxy; - StakeHolder public stakeHolder; + IStakeHolder public stakeHolder; address public roleAdmin; address public upgradeAdmin; @@ -29,7 +26,7 @@ contract StakeHolderBaseTest is Test { address public staker3; address public bank; - function setUp() public { + function setUp() public virtual { roleAdmin = makeAddr("RoleAdmin"); upgradeAdmin = makeAddr("UpgradeAdmin"); distributeAdmin = makeAddr("DistributeAdmin"); @@ -39,17 +36,9 @@ contract StakeHolderBaseTest is Test { staker3 = makeAddr("Staker3"); bank = makeAddr("bank"); - StakeHolder impl = new StakeHolder(); - - bytes memory initData = abi.encodeWithSelector( - StakeHolder.initialize.selector, roleAdmin, upgradeAdmin, distributeAdmin - ); - - proxy = new ERC1967Proxy(address(impl), initData); - stakeHolder = StakeHolder(address(proxy)); - - defaultAdminRole = stakeHolder.DEFAULT_ADMIN_ROLE(); - upgradeRole = stakeHolder.UPGRADE_ROLE(); - distributeRole = stakeHolder.DISTRIBUTE_ROLE(); + StakeHolderNative temp = new StakeHolderNative(); + defaultAdminRole = temp.DEFAULT_ADMIN_ROLE(); + upgradeRole = temp.UPGRADE_ROLE(); + distributeRole = temp.DISTRIBUTE_ROLE(); } } diff --git a/test/staking/StakeHolderConfig.t.sol b/test/staking/StakeHolderConfigBase.t.sol similarity index 59% rename from test/staking/StakeHolderConfig.t.sol rename to test/staking/StakeHolderConfigBase.t.sol index 5401dcd4..7cf5ef9a 100644 --- a/test/staking/StakeHolderConfig.t.sol +++ b/test/staking/StakeHolderConfigBase.t.sol @@ -4,59 +4,55 @@ pragma solidity >=0.8.19 <0.8.29; // solhint-disable-next-line no-global-import import "forge-std/Test.sol"; -import {StakeHolder} from "../../contracts/staking/StakeHolder.sol"; +import {IStakeHolder} from "../../contracts/staking/IStakeHolder.sol"; +import {StakeHolderBase} from "../../contracts/staking/StakeHolderBase.sol"; import {StakeHolderBaseTest} from "./StakeHolderBase.t.sol"; -contract StakeHolderV2 is StakeHolder { - function upgradeStorage(bytes memory /* _data */) external override { - version = 1; - } -} -contract StakeHolderConfigTest is StakeHolderBaseTest { +abstract contract StakeHolderConfigBaseTest is StakeHolderBaseTest { function testUpgradeToV1() public { - StakeHolderV2 v2Impl = new StakeHolderV2(); - bytes memory initData = abi.encodeWithSelector(StakeHolder.upgradeStorage.selector, bytes("")); + IStakeHolder v2Impl = _deployV2(); + bytes memory initData = abi.encodeWithSelector(StakeHolderBase.upgradeStorage.selector, bytes("")); vm.prank(upgradeAdmin); - stakeHolder.upgradeToAndCall(address(v2Impl), initData); + StakeHolderBase(address(stakeHolder)).upgradeToAndCall(address(v2Impl), initData); uint256 ver = stakeHolder.version(); assertEq(ver, 1, "Upgrade did not upgrade version"); } function testUpgradeToV0() public { - StakeHolder v1Impl = new StakeHolder(); - bytes memory initData = abi.encodeWithSelector(StakeHolder.upgradeStorage.selector, bytes("")); - vm.expectRevert(abi.encodeWithSelector(StakeHolder.CanNotUpgradeToLowerOrSameVersion.selector, 0)); + IStakeHolder v1Impl = _deployV1(); + bytes memory initData = abi.encodeWithSelector(StakeHolderBase.upgradeStorage.selector, bytes("")); + vm.expectRevert(abi.encodeWithSelector(IStakeHolder.CanNotUpgradeToLowerOrSameVersion.selector, 0)); vm.prank(upgradeAdmin); - stakeHolder.upgradeToAndCall(address(v1Impl), initData); + StakeHolderBase(address(stakeHolder)).upgradeToAndCall(address(v1Impl), initData); } function testDowngradeV1ToV0() public { // Upgrade from V0 to V1 - StakeHolderV2 v2Impl = new StakeHolderV2(); - bytes memory initData = abi.encodeWithSelector(StakeHolder.upgradeStorage.selector, bytes("")); + IStakeHolder v2Impl = _deployV2(); + bytes memory initData = abi.encodeWithSelector(StakeHolderBase.upgradeStorage.selector, bytes("")); vm.prank(upgradeAdmin); - stakeHolder.upgradeToAndCall(address(v2Impl), initData); + StakeHolderBase(address(stakeHolder)).upgradeToAndCall(address(v2Impl), initData); // Attempt to downgrade from V1 to V0. - StakeHolder v1Impl = new StakeHolder(); - vm.expectRevert(abi.encodeWithSelector(StakeHolder.CanNotUpgradeToLowerOrSameVersion.selector, 1)); + IStakeHolder v1Impl = _deployV1(); + vm.expectRevert(abi.encodeWithSelector(IStakeHolder.CanNotUpgradeToLowerOrSameVersion.selector, 1)); vm.prank(upgradeAdmin); - stakeHolder.upgradeToAndCall(address(v1Impl), initData); + StakeHolderBase(address(stakeHolder)).upgradeToAndCall(address(v1Impl), initData); } function testUpgradeAuthFail() public { - StakeHolderV2 v2Impl = new StakeHolderV2(); - bytes memory initData = abi.encodeWithSelector(StakeHolder.upgradeStorage.selector, bytes("")); + IStakeHolder v2Impl = _deployV2(); + bytes memory initData = abi.encodeWithSelector(StakeHolderBase.upgradeStorage.selector, bytes("")); // Error will be of the form: // AccessControl: account 0x7fa9385be102ac3eac297483dd6233d62b3e1496 is missing role 0x555047524144455f524f4c450000000000000000000000000000000000000000 vm.expectRevert(); - stakeHolder.upgradeToAndCall(address(v2Impl), initData); + StakeHolderBase(address(stakeHolder)).upgradeToAndCall(address(v2Impl), initData); } function testAddRevokeRenounceRoleAdmin() public { - bytes32 role = stakeHolder.DEFAULT_ADMIN_ROLE(); + bytes32 role = defaultAdminRole; address newRoleAdmin = makeAddr("NewRoleAdmin"); vm.prank(roleAdmin); stakeHolder.grantRole(role, newRoleAdmin); @@ -82,26 +78,16 @@ contract StakeHolderConfigTest is StakeHolderBaseTest { assertFalse(stakeHolder.hasRole(role, upgradeAdmin), "Upgrade admin should not have role"); } - function testRenounceLastRoleAdmin() public { - bytes32 role = stakeHolder.DEFAULT_ADMIN_ROLE(); - vm.expectRevert(abi.encodeWithSelector(StakeHolder.MustHaveOneRoleAdmin.selector)); - vm.prank(roleAdmin); - stakeHolder.renounceRole(role, roleAdmin); - } - - function testRevokeLastRoleAdmin() public { - bytes32 role = stakeHolder.DEFAULT_ADMIN_ROLE(); - vm.expectRevert(abi.encodeWithSelector(StakeHolder.MustHaveOneRoleAdmin.selector)); - vm.prank(roleAdmin); - stakeHolder.revokeRole(role, roleAdmin); - } - function testRoleAdminAuthFail () public { - bytes32 role = stakeHolder.DEFAULT_ADMIN_ROLE(); + bytes32 role = defaultAdminRole; address newRoleAdmin = makeAddr("NewRoleAdmin"); // Error will be of the form: // AccessControl: account 0x7fa9385be102ac3eac297483dd6233d62b3e1496 is missing role 0x555047524144455f524f4c450000000000000000000000000000000000000000 vm.expectRevert(); stakeHolder.grantRole(role, newRoleAdmin); } + + + function _deployV1() internal virtual returns(IStakeHolder); + function _deployV2() internal virtual returns(IStakeHolder); } diff --git a/test/staking/StakeHolderConfigERC20.t.sol b/test/staking/StakeHolderConfigERC20.t.sol new file mode 100644 index 00000000..95ab41a1 --- /dev/null +++ b/test/staking/StakeHolderConfigERC20.t.sol @@ -0,0 +1,41 @@ +// Copyright Immutable Pty Ltd 2018 - 2024 +// SPDX-License-Identifier: Apache 2.0 +pragma solidity >=0.8.19 <0.8.29; + +// solhint-disable-next-line no-global-import +import "forge-std/Test.sol"; +import {StakeHolderERC20} from "../../contracts/staking/StakeHolderERC20.sol"; +import {IStakeHolder} from "../../contracts/staking/IStakeHolder.sol"; +import {StakeHolderBase} from "../../contracts/staking/StakeHolderBase.sol"; +import {StakeHolderConfigBaseTest} from "./StakeHolderConfigBase.t.sol"; +import {ERC1967Proxy} from "openzeppelin-contracts-4.9.3/proxy/ERC1967/ERC1967Proxy.sol"; + +contract StakeHolderERC20V2 is StakeHolderERC20 { + function upgradeStorage(bytes memory /* _data */) external override(StakeHolderBase) { + version = 1; + } +} + +contract StakeHolderConfigERC20Test is StakeHolderConfigBaseTest { + + function setUp() public override { + super.setUp(); + + StakeHolderERC20 impl = new StakeHolderERC20(); + + bytes memory initData = abi.encodeWithSelector( + StakeHolderERC20.initialize.selector, roleAdmin, upgradeAdmin, distributeAdmin, address(0) + ); + + ERC1967Proxy proxy = new ERC1967Proxy(address(impl), initData); + stakeHolder = IStakeHolder(address(proxy)); + } + + function _deployV1() internal override returns(IStakeHolder) { + return IStakeHolder(address(new StakeHolderERC20())); + } + + function _deployV2() internal override returns(IStakeHolder) { + return IStakeHolder(address(new StakeHolderERC20V2())); + } +} \ No newline at end of file diff --git a/test/staking/StakeHolderConfigNative.t.sol b/test/staking/StakeHolderConfigNative.t.sol new file mode 100644 index 00000000..33873b14 --- /dev/null +++ b/test/staking/StakeHolderConfigNative.t.sol @@ -0,0 +1,43 @@ +// Copyright Immutable Pty Ltd 2018 - 2024 +// SPDX-License-Identifier: Apache 2.0 +pragma solidity >=0.8.19 <0.8.29; + +// solhint-disable-next-line no-global-import +import "forge-std/Test.sol"; +import {StakeHolderNative} from "../../contracts/staking/StakeHolderNative.sol"; +import {IStakeHolder} from "../../contracts/staking/IStakeHolder.sol"; +import {StakeHolderBase} from "../../contracts/staking/StakeHolderBase.sol"; +import {StakeHolderConfigBaseTest} from "./StakeHolderConfigBase.t.sol"; +import {ERC1967Proxy} from "openzeppelin-contracts-4.9.3/proxy/ERC1967/ERC1967Proxy.sol"; + +contract StakeHolderNativeV2 is StakeHolderNative { + function upgradeStorage(bytes memory /* _data */) external override(StakeHolderBase) { + version = 1; + } +} + + +contract StakeHolderConfigNativeTest is StakeHolderConfigBaseTest { + + function setUp() public override { + super.setUp(); + + StakeHolderNative impl = new StakeHolderNative(); + + bytes memory initData = abi.encodeWithSelector( + StakeHolderNative.initialize.selector, roleAdmin, upgradeAdmin, distributeAdmin + ); + + ERC1967Proxy proxy = new ERC1967Proxy(address(impl), initData); + stakeHolder = IStakeHolder(address(proxy)); + } + + function _deployV1() internal override returns(IStakeHolder) { + return IStakeHolder(address(new StakeHolderNative())); + } + + function _deployV2() internal override returns(IStakeHolder) { + return IStakeHolder(address(new StakeHolderNativeV2())); + } + +} diff --git a/test/staking/StakeHolderConfigWIMX.t.sol b/test/staking/StakeHolderConfigWIMX.t.sol new file mode 100644 index 00000000..fd2e457e --- /dev/null +++ b/test/staking/StakeHolderConfigWIMX.t.sol @@ -0,0 +1,42 @@ +// Copyright Immutable Pty Ltd 2018 - 2024 +// SPDX-License-Identifier: Apache 2.0 +pragma solidity >=0.8.19 <0.8.29; + +// solhint-disable-next-line no-global-import +import "forge-std/Test.sol"; +import {StakeHolderWIMX} from "../../contracts/staking/StakeHolderWIMX.sol"; +import {WIMX} from "../../contracts/staking/WIMX.sol"; +import {IStakeHolder} from "../../contracts/staking/IStakeHolder.sol"; +import {StakeHolderBase} from "../../contracts/staking/StakeHolderBase.sol"; +import {StakeHolderConfigBaseTest} from "./StakeHolderConfigBase.t.sol"; +import {ERC1967Proxy} from "openzeppelin-contracts-4.9.3/proxy/ERC1967/ERC1967Proxy.sol"; + +contract StakeHolderWIMXV2 is StakeHolderWIMX { + function upgradeStorage(bytes memory /* _data */) external override(StakeHolderBase) { + version = 1; + } +} + +contract StakeHolderConfigWIMXTest is StakeHolderConfigBaseTest { + + function setUp() public override { + super.setUp(); + + StakeHolderWIMX impl = new StakeHolderWIMX(); + + bytes memory initData = abi.encodeWithSelector( + StakeHolderWIMX.initialize.selector, roleAdmin, upgradeAdmin, distributeAdmin, address(0) + ); + + ERC1967Proxy proxy = new ERC1967Proxy(address(impl), initData); + stakeHolder = IStakeHolder(address(proxy)); + } + + function _deployV1() internal override returns(IStakeHolder) { + return IStakeHolder(address(new StakeHolderWIMX())); + } + + function _deployV2() internal override returns(IStakeHolder) { + return IStakeHolder(address(new StakeHolderWIMXV2())); + } +} \ No newline at end of file diff --git a/test/staking/StakeHolderInit.t.sol b/test/staking/StakeHolderInitBase.t.sol similarity index 90% rename from test/staking/StakeHolderInit.t.sol rename to test/staking/StakeHolderInitBase.t.sol index 92d6c4fd..2da06e66 100644 --- a/test/staking/StakeHolderInit.t.sol +++ b/test/staking/StakeHolderInitBase.t.sol @@ -4,10 +4,9 @@ pragma solidity >=0.8.19 <0.8.29; // solhint-disable-next-line no-global-import import "forge-std/Test.sol"; -import {StakeHolder} from "../../contracts/staking/StakeHolder.sol"; import {StakeHolderBaseTest} from "./StakeHolderBase.t.sol"; -contract StakeHolderInitTest is StakeHolderBaseTest { +abstract contract StakeHolderInitBaseTest is StakeHolderBaseTest { function testGetVersion() public { uint256 ver = stakeHolder.version(); assertEq(ver, 0, "Expect initial version of storage layout to be V0"); diff --git a/test/staking/StakeHolderInitERC20.t.sol b/test/staking/StakeHolderInitERC20.t.sol new file mode 100644 index 00000000..8b99a8df --- /dev/null +++ b/test/staking/StakeHolderInitERC20.t.sol @@ -0,0 +1,27 @@ +// Copyright Immutable Pty Ltd 2018 - 2024 +// SPDX-License-Identifier: Apache 2.0 +pragma solidity >=0.8.19 <0.8.29; + +// solhint-disable-next-line no-global-import +import "forge-std/Test.sol"; +import {StakeHolderERC20} from "../../contracts/staking/StakeHolderERC20.sol"; +import {IStakeHolder} from "../../contracts/staking/IStakeHolder.sol"; +import {StakeHolderBase} from "../../contracts/staking/StakeHolderBase.sol"; +import {StakeHolderInitBaseTest} from "./StakeHolderInitBase.t.sol"; +import {ERC1967Proxy} from "openzeppelin-contracts-4.9.3/proxy/ERC1967/ERC1967Proxy.sol"; + +contract StakeHolderInitERC20Test is StakeHolderInitBaseTest { + + function setUp() public override { + super.setUp(); + + StakeHolderERC20 impl = new StakeHolderERC20(); + + bytes memory initData = abi.encodeWithSelector( + StakeHolderERC20.initialize.selector, roleAdmin, upgradeAdmin, distributeAdmin, address(0) + ); + + ERC1967Proxy proxy = new ERC1967Proxy(address(impl), initData); + stakeHolder = IStakeHolder(address(proxy)); + } +} diff --git a/test/staking/StakeHolderInitNative.t.sol b/test/staking/StakeHolderInitNative.t.sol new file mode 100644 index 00000000..1543da45 --- /dev/null +++ b/test/staking/StakeHolderInitNative.t.sol @@ -0,0 +1,27 @@ +// Copyright Immutable Pty Ltd 2018 - 2024 +// SPDX-License-Identifier: Apache 2.0 +pragma solidity >=0.8.19 <0.8.29; + +// solhint-disable-next-line no-global-import +import "forge-std/Test.sol"; +import {StakeHolderNative} from "../../contracts/staking/StakeHolderNative.sol"; +import {IStakeHolder} from "../../contracts/staking/IStakeHolder.sol"; +import {StakeHolderBase} from "../../contracts/staking/StakeHolderBase.sol"; +import {StakeHolderInitBaseTest} from "./StakeHolderInitBase.t.sol"; +import {ERC1967Proxy} from "openzeppelin-contracts-4.9.3/proxy/ERC1967/ERC1967Proxy.sol"; + +contract StakeHolderInitNativeTest is StakeHolderInitBaseTest { + + function setUp() public override { + super.setUp(); + + StakeHolderNative impl = new StakeHolderNative(); + + bytes memory initData = abi.encodeWithSelector( + StakeHolderNative.initialize.selector, roleAdmin, upgradeAdmin, distributeAdmin + ); + + ERC1967Proxy proxy = new ERC1967Proxy(address(impl), initData); + stakeHolder = IStakeHolder(address(proxy)); + } +} diff --git a/test/staking/StakeHolderInitWIMX.t.sol b/test/staking/StakeHolderInitWIMX.t.sol new file mode 100644 index 00000000..914418b2 --- /dev/null +++ b/test/staking/StakeHolderInitWIMX.t.sol @@ -0,0 +1,28 @@ +// Copyright Immutable Pty Ltd 2018 - 2024 +// SPDX-License-Identifier: Apache 2.0 +pragma solidity >=0.8.19 <0.8.29; + +// solhint-disable-next-line no-global-import +import "forge-std/Test.sol"; +import {StakeHolderWIMX} from "../../contracts/staking/StakeHolderWIMX.sol"; +import {WIMX} from "../../contracts/staking/WIMX.sol"; +import {IStakeHolder} from "../../contracts/staking/IStakeHolder.sol"; +import {StakeHolderBase} from "../../contracts/staking/StakeHolderBase.sol"; +import {StakeHolderInitBaseTest} from "./StakeHolderInitBase.t.sol"; +import {ERC1967Proxy} from "openzeppelin-contracts-4.9.3/proxy/ERC1967/ERC1967Proxy.sol"; + +contract StakeHolderInitWIMXTest is StakeHolderInitBaseTest { + + function setUp() public override { + super.setUp(); + + StakeHolderWIMX impl = new StakeHolderWIMX(); + + bytes memory initData = abi.encodeWithSelector( + StakeHolderWIMX.initialize.selector, roleAdmin, upgradeAdmin, distributeAdmin, address(0) + ); + + ERC1967Proxy proxy = new ERC1967Proxy(address(impl), initData); + stakeHolder = IStakeHolder(address(proxy)); + } +} diff --git a/test/staking/StakeHolderOperational.t.sol b/test/staking/StakeHolderOperational.t.sol deleted file mode 100644 index 87cdf7f9..00000000 --- a/test/staking/StakeHolderOperational.t.sol +++ /dev/null @@ -1,331 +0,0 @@ -// Copyright Immutable Pty Ltd 2018 - 2024 -// SPDX-License-Identifier: Apache 2.0 -pragma solidity >=0.8.19 <0.8.29; - -// solhint-disable-next-line no-global-import -import "forge-std/Test.sol"; -import {StakeHolder, AccountAmount} from "../../contracts/staking/StakeHolder.sol"; -import {StakeHolderAttackWallet} from "./StakeHolderAttackWallet.sol"; -import {StakeHolderBaseTest} from "./StakeHolderBase.t.sol"; - -contract StakeHolderOperationalTest is StakeHolderBaseTest { - function testStake() public { - vm.deal(staker1, 100 ether); - - vm.prank(staker1); - stakeHolder.stake{value: 10 ether}(); - - assertEq(stakeHolder.getBalance(staker1), 10 ether, "Incorrect balance"); - assertTrue(stakeHolder.hasStaked(staker1), "Expect staker1 has staked"); - assertEq(stakeHolder.getNumStakers(), 1, "Incorrect number of stakers"); - address[] memory stakers = stakeHolder.getStakers(0, 1); - assertEq(stakers.length, 1, "Incorrect length returned by getStakers"); - assertEq(stakers[0], staker1, "Incorrect staker"); - } - - function testStakeTwice() public { - vm.deal(staker1, 100 ether); - - vm.prank(staker1); - stakeHolder.stake{value: 10 ether}(); - vm.prank(staker1); - stakeHolder.stake{value: 20 ether}(); - - assertEq(stakeHolder.getBalance(staker1), 30 ether, "Incorrect balance"); - assertTrue(stakeHolder.hasStaked(staker1), "Expect staker1 has staked"); - assertEq(stakeHolder.getNumStakers(), 1, "Incorrect number of stakers"); - } - - function testStakeZeroValue() public { - vm.expectRevert(abi.encodeWithSelector(StakeHolder.MustStakeMoreThanZero.selector)); - vm.prank(staker1); - stakeHolder.stake(); - } - - function testMultipleStakers() public { - vm.deal(staker1, 100 ether); - vm.deal(staker2, 100 ether); - vm.deal(staker3, 100 ether); - - vm.prank(staker1); - stakeHolder.stake{value: 10 ether}(); - vm.prank(staker2); - stakeHolder.stake{value: 20 ether}(); - vm.prank(staker3); - stakeHolder.stake{value: 30 ether}(); - - assertEq(stakeHolder.getBalance(staker1), 10 ether, "Incorrect balance1"); - assertTrue(stakeHolder.hasStaked(staker1), "Expect staker1 has staked1"); - assertEq(stakeHolder.getBalance(staker2), 20 ether, "Incorrect balance2"); - assertTrue(stakeHolder.hasStaked(staker2), "Expect staker1 has staked2"); - assertEq(stakeHolder.getBalance(staker3), 30 ether, "Incorrect balance3"); - assertTrue(stakeHolder.hasStaked(staker3), "Expect staker1 has staked3"); - - assertEq(stakeHolder.getNumStakers(), 3, "Incorrect number of stakers"); - address[] memory stakers = stakeHolder.getStakers(0, 3); - assertEq(stakers.length, 3, "Incorrect length returned by getStakers"); - assertEq(stakers[0], staker1, "Incorrect staker1"); - assertEq(stakers[1], staker2, "Incorrect staker2"); - assertEq(stakers[2], staker3, "Incorrect staker3"); - } - - function testUnstake() public { - vm.deal(staker1, 100 ether); - - vm.prank(staker1); - stakeHolder.stake{value: 10 ether}(); - vm.prank(staker1); - stakeHolder.unstake(10 ether); - - assertEq(staker1.balance, 100 ether, "Incorrect native balance"); - assertEq(stakeHolder.getBalance(staker1), 0 ether, "Incorrect balance"); - assertTrue(stakeHolder.hasStaked(staker1), "Expect staker1 has staked"); - assertEq(stakeHolder.getNumStakers(), 1, "Incorrect number of stakers"); - address[] memory stakers = stakeHolder.getStakers(0, 1); - assertEq(stakers.length, 1, "Incorrect length returned by getStakers"); - assertEq(stakers[0], staker1, "Incorrect staker"); - } - - function testUnstakeTooMuch() public { - vm.deal(staker1, 100 ether); - - vm.prank(staker1); - stakeHolder.stake{value: 10 ether}(); - vm.expectRevert(abi.encodeWithSelector(StakeHolder.UnstakeAmountExceedsBalance.selector, 11 ether, 10 ether)); - vm.prank(staker1); - stakeHolder.unstake(11 ether); - } - - function testUnstakePartial() public { - vm.deal(staker1, 100 ether); - - vm.prank(staker1); - stakeHolder.stake{value: 10 ether}(); - vm.prank(staker1); - stakeHolder.unstake(3 ether); - - assertEq(staker1.balance, 93 ether, "Incorrect native balance"); - assertEq(stakeHolder.getBalance(staker1), 7 ether, "Incorrect balance"); - } - - function testUnstakeMultiple() public { - vm.deal(staker1, 100 ether); - - vm.prank(staker1); - stakeHolder.stake{value: 10 ether}(); - vm.prank(staker1); - stakeHolder.unstake(3 ether); - vm.prank(staker1); - stakeHolder.unstake(1 ether); - - assertEq(staker1.balance, 94 ether, "Incorrect native balance"); - assertEq(stakeHolder.getBalance(staker1), 6 ether, "Incorrect balance"); - } - - function testUnstakeReentrantAttack() public { - StakeHolderAttackWallet attacker = new StakeHolderAttackWallet(address(stakeHolder)); - vm.deal(address(attacker), 100 ether); - - attacker.stake(10 ether); - // Attacker's reentracy attack will double the amount being unstaked. - // The attack fails due to attempting to withdraw more than balance (that is, 2 x 6 eth = 12) - vm.expectRevert(abi.encodeWithSelector(StakeHolder.UnstakeAmountExceedsBalance.selector, 6000000000000000001, 4 ether)); - attacker.unstake{gas: 10000000}(6 ether); - } - - function testRestaking() public { - vm.deal(staker1, 100 ether); - - vm.startPrank(staker1); - stakeHolder.stake{value: 10 ether}(); - assertEq(stakeHolder.getBalance(staker1), 10 ether, "Incorrect balance1"); - stakeHolder.unstake(10 ether); - assertEq(stakeHolder.getBalance(staker1), 0 ether, "Incorrect balance2"); - stakeHolder.stake{value: 9 ether}(); - assertEq(stakeHolder.getBalance(staker1), 9 ether, "Incorrect balance3"); - stakeHolder.stake{value: 2 ether}(); - assertEq(stakeHolder.getBalance(staker1), 11 ether, "Incorrect balance4"); - assertEq(stakeHolder.getNumStakers(), 1, "Incorrect number of stakers"); - vm.stopPrank(); - } - - function testGetStakers() public { - vm.deal(staker1, 100 ether); - vm.deal(staker2, 100 ether); - vm.deal(staker3, 100 ether); - - vm.prank(staker1); - stakeHolder.stake{value: 10 ether}(); - vm.prank(staker2); - stakeHolder.stake{value: 20 ether}(); - vm.prank(staker3); - stakeHolder.stake{value: 30 ether}(); - - address[] memory stakers = stakeHolder.getStakers(0, 1); - assertEq(stakers.length, 1, "Incorrect length returned by getStakers"); - assertEq(stakers[0], staker1, "Incorrect staker1"); - - stakers = stakeHolder.getStakers(1, 1); - assertEq(stakers.length, 1, "Incorrect length returned by getStakers"); - assertEq(stakers[0], staker2, "Incorrect staker2"); - - stakers = stakeHolder.getStakers(2, 1); - assertEq(stakers.length, 1, "Incorrect length returned by getStakers"); - assertEq(stakers[0], staker3, "Incorrect staker3"); - - stakers = stakeHolder.getStakers(1, 2); - assertEq(stakers.length, 2, "Incorrect length returned by getStakers"); - assertEq(stakers[0], staker2, "Incorrect staker2"); - assertEq(stakers[1], staker3, "Incorrect staker3"); - } - - function testGetStakersOutOfRange() public { - vm.deal(staker1, 100 ether); - vm.deal(staker2, 100 ether); - vm.deal(staker3, 100 ether); - - vm.prank(staker1); - stakeHolder.stake{value: 10 ether}(); - vm.prank(staker2); - stakeHolder.stake{value: 20 ether}(); - vm.prank(staker3); - stakeHolder.stake{value: 30 ether}(); - - vm.expectRevert(stdError.indexOOBError); - stakeHolder.getStakers(1, 3); - } - - function testDistributeRewardsOne() public { - vm.deal(staker1, 100 ether); - vm.deal(staker2, 100 ether); - vm.deal(staker3, 100 ether); - vm.deal(distributeAdmin, 100 ether); - - vm.prank(staker1); - stakeHolder.stake{value: 10 ether}(); - vm.prank(staker2); - stakeHolder.stake{value: 20 ether}(); - vm.prank(staker3); - stakeHolder.stake{value: 30 ether}(); - - // Distribute rewards to staker2 only. - AccountAmount[] memory accountsAmounts = new AccountAmount[](1); - accountsAmounts[0] = AccountAmount(staker2, 0.5 ether); - vm.prank(distributeAdmin); - stakeHolder.distributeRewards{value: 0.5 ether}(accountsAmounts); - - assertEq(stakeHolder.getBalance(staker1), 10 ether, "Incorrect balance1"); - assertEq(stakeHolder.getBalance(staker2), 20.5 ether, "Incorrect balance2"); - assertEq(stakeHolder.getBalance(staker3), 30 ether, "Incorrect balance3"); - } - - function testDistributeRewardsMultiple() public { - vm.deal(staker1, 100 ether); - vm.deal(staker2, 100 ether); - vm.deal(staker3, 100 ether); - vm.deal(distributeAdmin, 100 ether); - - vm.prank(staker1); - stakeHolder.stake{value: 10 ether}(); - vm.prank(staker2); - stakeHolder.stake{value: 20 ether}(); - vm.prank(staker3); - stakeHolder.stake{value: 30 ether}(); - - // Distribute rewards to staker2 and staker3. - AccountAmount[] memory accountsAmounts = new AccountAmount[](2); - accountsAmounts[0] = AccountAmount(staker2, 0.5 ether); - accountsAmounts[1] = AccountAmount(staker3, 1 ether); - vm.prank(distributeAdmin); - stakeHolder.distributeRewards{value: 1.5 ether}(accountsAmounts); - - assertEq(stakeHolder.getBalance(staker1), 10 ether, "Incorrect balance1"); - assertEq(stakeHolder.getBalance(staker2), 20.5 ether, "Incorrect balance2"); - assertEq(stakeHolder.getBalance(staker3), 31 ether, "Incorrect balance3"); - } - - function testDistributeZeroReward() public { - vm.deal(staker1, 100 ether); - vm.deal(distributeAdmin, 100 ether); - - vm.prank(staker1); - stakeHolder.stake{value: 10 ether}(); - - // Distribute rewards of 0 to staker1. - AccountAmount[] memory accountsAmounts = new AccountAmount[](1); - accountsAmounts[0] = AccountAmount(staker2, 0 ether); - vm.expectRevert(abi.encodeWithSelector(StakeHolder.MustDistributeMoreThanZero.selector)); - vm.prank(distributeAdmin); - stakeHolder.distributeRewards{value: 0 ether}(accountsAmounts); - } - - function testDistributeMismatch() public { - vm.deal(staker1, 100 ether); - vm.deal(staker2, 100 ether); - vm.deal(staker3, 100 ether); - vm.deal(distributeAdmin, 100 ether); - - vm.prank(staker1); - stakeHolder.stake{value: 10 ether}(); - vm.prank(staker2); - stakeHolder.stake{value: 20 ether}(); - vm.prank(staker3); - stakeHolder.stake{value: 30 ether}(); - - // Distribute rewards to staker2 and staker3. - AccountAmount[] memory accountsAmounts = new AccountAmount[](2); - accountsAmounts[0] = AccountAmount(staker2, 0.5 ether); - accountsAmounts[1] = AccountAmount(staker3, 1 ether); - vm.expectRevert(abi.encodeWithSelector(StakeHolder.DistributionAmountsDoNotMatchTotal.selector, 1 ether, 1.5 ether)); - vm.prank(distributeAdmin); - stakeHolder.distributeRewards{value: 1 ether}(accountsAmounts); - } - - function testDistributeToEmptyAccount() public { - vm.deal(staker1, 100 ether); - vm.deal(distributeAdmin, 100 ether); - - vm.prank(staker1); - stakeHolder.stake{value: 10 ether}(); - vm.prank(staker1); - stakeHolder.unstake(10 ether); - - // Distribute rewards to staker2 only. - AccountAmount[] memory accountsAmounts = new AccountAmount[](1); - accountsAmounts[0] = AccountAmount(staker1, 0.5 ether); - vm.prank(distributeAdmin); - stakeHolder.distributeRewards{value: 0.5 ether}(accountsAmounts); - - assertEq(stakeHolder.getBalance(staker1), 0.5 ether, "Incorrect balance1"); - assertTrue(stakeHolder.hasStaked(staker1), "Expect staker1 has staked"); - assertEq(stakeHolder.getNumStakers(), 1, "Incorrect number of stakers"); - } - - function testDistributeToUnusedAccount() public { - vm.deal(distributeAdmin, 100 ether); - - // Distribute rewards to staker2 only. - AccountAmount[] memory accountsAmounts = new AccountAmount[](1); - accountsAmounts[0] = AccountAmount(staker1, 0.5 ether); - vm.expectRevert(abi.encodeWithSelector(StakeHolder.AttemptToDistributeToNewAccount.selector, staker1, 0.5 ether)); - vm.prank(distributeAdmin); - stakeHolder.distributeRewards{value: 0.5 ether}(accountsAmounts); - } - - function testDistributeBadAuth() public { - vm.deal(staker1, 100 ether); - vm.deal(bank, 100 ether); - - vm.prank(staker1); - stakeHolder.stake{value: 10 ether}(); - - // Distribute rewards to staker1 only, but not from distributeAdmin - AccountAmount[] memory accountsAmounts = new AccountAmount[](1); - accountsAmounts[0] = AccountAmount(staker1, 0.5 ether); - vm.prank(bank); - // Error will be of the form: - // AccessControl: account 0x7fa9385be102ac3eac297483dd6233d62b3e1496 is missing role 0x555047524144455f524f4c450000000000000000000000000000000000000000 - vm.expectRevert(); - stakeHolder.distributeRewards{value: 0.5 ether}(accountsAmounts); - } -} diff --git a/test/staking/StakeHolderOperationalBase.t.sol b/test/staking/StakeHolderOperationalBase.t.sol new file mode 100644 index 00000000..931194c7 --- /dev/null +++ b/test/staking/StakeHolderOperationalBase.t.sol @@ -0,0 +1,288 @@ +// Copyright Immutable Pty Ltd 2018 - 2024 +// SPDX-License-Identifier: Apache 2.0 +pragma solidity >=0.8.19 <0.8.29; + +// solhint-disable-next-line no-global-import +import "forge-std/Test.sol"; +import {IStakeHolder} from "../../contracts/staking/IStakeHolder.sol"; +import {StakeHolderBaseTest} from "./StakeHolderBase.t.sol"; + +abstract contract StakeHolderOperationalBaseTest is StakeHolderBaseTest { + function testStake() public { + _deal(staker1, 100 ether); + _addStake(staker1, 10 ether); + assertEq(_getBalanceStaker(staker1), 90 ether, "Incorrect balance1"); + assertEq(_getBalanceStakeHolderContract(), 10 ether, "Incorrect balance2"); + + assertEq(stakeHolder.getBalance(staker1), 10 ether, "Incorrect balance3"); + assertTrue(stakeHolder.hasStaked(staker1), "Expect staker1 has staked"); + assertEq(stakeHolder.getNumStakers(), 1, "Incorrect number of stakers"); + address[] memory stakers = stakeHolder.getStakers(0, 1); + assertEq(stakers.length, 1, "Incorrect length returned by getStakers"); + assertEq(stakers[0], staker1, "Incorrect staker"); + } + + function testStakeTwice() public { + _deal(staker1, 100 ether); + + _addStake(staker1, 10 ether); + _addStake(staker1, 20 ether); + + assertEq(stakeHolder.getBalance(staker1), 30 ether, "Incorrect balance"); + assertTrue(stakeHolder.hasStaked(staker1), "Expect staker1 has staked"); + assertEq(stakeHolder.getNumStakers(), 1, "Incorrect number of stakers"); + } + + function testStakeZeroValue() public { + _addStake(staker1, 0 ether, abi.encodeWithSelector(IStakeHolder.MustStakeMoreThanZero.selector)); + } + + function testMultipleStakers() public { + _deal(staker1, 100 ether); + _deal(staker2, 100 ether); + _deal(staker3, 100 ether); + + _addStake(staker1, 10 ether); + _addStake(staker2, 20 ether); + _addStake(staker3, 30 ether); + + assertEq(stakeHolder.getBalance(staker1), 10 ether, "Incorrect balance1"); + assertTrue(stakeHolder.hasStaked(staker1), "Expect staker1 has staked1"); + assertEq(stakeHolder.getBalance(staker2), 20 ether, "Incorrect balance2"); + assertTrue(stakeHolder.hasStaked(staker2), "Expect staker1 has staked2"); + assertEq(stakeHolder.getBalance(staker3), 30 ether, "Incorrect balance3"); + assertTrue(stakeHolder.hasStaked(staker3), "Expect staker1 has staked3"); + + assertEq(stakeHolder.getNumStakers(), 3, "Incorrect number of stakers"); + address[] memory stakers = stakeHolder.getStakers(0, 3); + assertEq(stakers.length, 3, "Incorrect length returned by getStakers"); + assertEq(stakers[0], staker1, "Incorrect staker1"); + assertEq(stakers[1], staker2, "Incorrect staker2"); + assertEq(stakers[2], staker3, "Incorrect staker3"); + } + + function testUnstake() public { + _deal(staker1, 100 ether); + + _addStake(staker1, 10 ether); + vm.prank(staker1); + stakeHolder.unstake(10 ether); + + assertEq(_getBalanceStaker(staker1), 100 ether, "Incorrect native balance"); + assertEq(stakeHolder.getBalance(staker1), 0 ether, "Incorrect balance"); + assertTrue(stakeHolder.hasStaked(staker1), "Expect staker1 has staked"); + assertEq(stakeHolder.getNumStakers(), 1, "Incorrect number of stakers"); + address[] memory stakers = stakeHolder.getStakers(0, 1); + assertEq(stakers.length, 1, "Incorrect length returned by getStakers"); + assertEq(stakers[0], staker1, "Incorrect staker"); + } + + function testUnstakeTooMuch() public { + _deal(staker1, 100 ether); + + _addStake(staker1, 10 ether); + vm.expectRevert(abi.encodeWithSelector(IStakeHolder.UnstakeAmountExceedsBalance.selector, 11 ether, 10 ether)); + vm.prank(staker1); + stakeHolder.unstake(11 ether); + } + + function testUnstakePartial() public { + _deal(staker1, 100 ether); + + _addStake(staker1, 10 ether); + vm.prank(staker1); + stakeHolder.unstake(3 ether); + + assertEq(_getBalanceStaker(staker1), 93 ether, "Incorrect native balance"); + assertEq(stakeHolder.getBalance(staker1), 7 ether, "Incorrect balance"); + } + + function testUnstakeMultiple() public { + _deal(staker1, 100 ether); + + _addStake(staker1, 10 ether); + vm.prank(staker1); + stakeHolder.unstake(3 ether); + vm.prank(staker1); + stakeHolder.unstake(1 ether); + + assertEq(_getBalanceStaker(staker1), 94 ether, "Incorrect native balance"); + assertEq(stakeHolder.getBalance(staker1), 6 ether, "Incorrect balance"); + } + + function testRestaking() public { + _deal(staker1, 100 ether); + + _addStake(staker1, 10 ether); + assertEq(stakeHolder.getBalance(staker1), 10 ether, "Incorrect balance1"); + vm.prank(staker1); + stakeHolder.unstake(10 ether); + assertEq(stakeHolder.getBalance(staker1), 0 ether, "Incorrect balance2"); + _addStake(staker1, 9 ether); + assertEq(stakeHolder.getBalance(staker1), 9 ether, "Incorrect balance3"); + _addStake(staker1,2 ether); + assertEq(stakeHolder.getBalance(staker1), 11 ether, "Incorrect balance4"); + assertEq(stakeHolder.getNumStakers(), 1, "Incorrect number of stakers"); + vm.stopPrank(); + } + + function testGetStakers() public { + _deal(staker1, 100 ether); + _deal(staker2, 100 ether); + _deal(staker3, 100 ether); + + _addStake(staker1, 10 ether); + _addStake(staker2, 20 ether); + _addStake(staker3, 30 ether); + + address[] memory stakers = stakeHolder.getStakers(0, 1); + assertEq(stakers.length, 1, "Incorrect length returned by getStakers"); + assertEq(stakers[0], staker1, "Incorrect staker1"); + + stakers = stakeHolder.getStakers(1, 1); + assertEq(stakers.length, 1, "Incorrect length returned by getStakers"); + assertEq(stakers[0], staker2, "Incorrect staker2"); + + stakers = stakeHolder.getStakers(2, 1); + assertEq(stakers.length, 1, "Incorrect length returned by getStakers"); + assertEq(stakers[0], staker3, "Incorrect staker3"); + + stakers = stakeHolder.getStakers(1, 2); + assertEq(stakers.length, 2, "Incorrect length returned by getStakers"); + assertEq(stakers[0], staker2, "Incorrect staker2"); + assertEq(stakers[1], staker3, "Incorrect staker3"); + } + + function testGetStakersOutOfRange() public { + _deal(staker1, 100 ether); + _deal(staker2, 100 ether); + _deal(staker3, 100 ether); + + _addStake(staker1, 10 ether); + _addStake(staker2, 20 ether); + _addStake(staker3, 30 ether); + + vm.expectRevert(stdError.indexOOBError); + stakeHolder.getStakers(1, 3); + } + + function testDistributeRewardsOne() public { + _deal(staker1, 100 ether); + _deal(staker2, 100 ether); + _deal(staker3, 100 ether); + _deal(distributeAdmin, 100 ether); + + _addStake(staker1, 10 ether); + _addStake(staker2, 20 ether); + _addStake(staker3, 30 ether); + + // Distribute rewards to staker2 only. + IStakeHolder.AccountAmount[] memory accountsAmounts = new IStakeHolder.AccountAmount[](1); + accountsAmounts[0] = IStakeHolder.AccountAmount(staker2, 0.5 ether); + _distributeRewards(distributeAdmin, 0.5 ether, accountsAmounts); + + assertEq(stakeHolder.getBalance(staker1), 10 ether, "Incorrect balance1"); + assertEq(stakeHolder.getBalance(staker2), 20.5 ether, "Incorrect balance2"); + assertEq(stakeHolder.getBalance(staker3), 30 ether, "Incorrect balance3"); + } + + function testDistributeRewardsMultiple() public { + _deal(staker1, 100 ether); + _deal(staker2, 100 ether); + _deal(staker3, 100 ether); + _deal(distributeAdmin, 100 ether); + + _addStake(staker1, 10 ether); + _addStake(staker2, 20 ether); + _addStake(staker3, 30 ether); + + // Distribute rewards to staker2 and staker3. + IStakeHolder.AccountAmount[] memory accountsAmounts = new IStakeHolder.AccountAmount[](2); + accountsAmounts[0] = IStakeHolder.AccountAmount(staker2, 0.5 ether); + accountsAmounts[1] = IStakeHolder.AccountAmount(staker3, 1 ether); + _distributeRewards(distributeAdmin, 1.5 ether, accountsAmounts); + + assertEq(stakeHolder.getBalance(staker1), 10 ether, "Incorrect balance1"); + assertEq(stakeHolder.getBalance(staker2), 20.5 ether, "Incorrect balance2"); + assertEq(stakeHolder.getBalance(staker3), 31 ether, "Incorrect balance3"); + } + + function testDistributeZeroReward() public { + _deal(staker1, 100 ether); + _deal(distributeAdmin, 100 ether); + + _addStake(staker1, 10 ether); + + // Distribute rewards of 0 to staker1. + IStakeHolder.AccountAmount[] memory accountsAmounts = new IStakeHolder.AccountAmount[](1); + accountsAmounts[0] = IStakeHolder.AccountAmount(staker1, 0 ether); + _distributeRewards(distributeAdmin, 0 ether, accountsAmounts, + abi.encodeWithSelector(IStakeHolder.MustDistributeMoreThanZero.selector)); + } + + function testDistributeToEmptyAccount() public { + _deal(staker1, 100 ether); + _deal(distributeAdmin, 100 ether); + + uint256 amount = 10 ether; + _addStake(staker1, amount); + vm.prank(staker1); + stakeHolder.unstake(amount); + + // Distribute rewards to staker2 only. + IStakeHolder.AccountAmount[] memory accountsAmounts = new IStakeHolder.AccountAmount[](1); + accountsAmounts[0] = IStakeHolder.AccountAmount(staker1, 0.5 ether); + _distributeRewards(distributeAdmin, 0.5 ether, accountsAmounts); + + assertEq(stakeHolder.getBalance(staker1), 0.5 ether, "Incorrect balance1"); + assertTrue(stakeHolder.hasStaked(staker1), "Expect staker1 has staked"); + assertEq(stakeHolder.getNumStakers(), 1, "Incorrect number of stakers"); + } + + function testDistributeToUnusedAccount() public { + _deal(distributeAdmin, 100 ether); + + // Distribute rewards to staker2 only. + IStakeHolder.AccountAmount[] memory accountsAmounts = new IStakeHolder.AccountAmount[](1); + accountsAmounts[0] = IStakeHolder.AccountAmount(staker1, 0.5 ether); + _distributeRewards(distributeAdmin, 0.5 ether, accountsAmounts, + abi.encodeWithSelector(IStakeHolder.AttemptToDistributeToNewAccount.selector, staker1, 0.5 ether)); + } + + function testDistributeBadAuth() public { + _deal(staker1, 100 ether); + _deal(bank, 100 ether); + + _addStake(staker1, 10 ether); + + // Distribute rewards to staker1 only, but not from distributeAdmin + IStakeHolder.AccountAmount[] memory accountsAmounts = new IStakeHolder.AccountAmount[](1); + accountsAmounts[0] = IStakeHolder.AccountAmount(staker1, 0.5 ether); + _distributeRewards(bank, 0.5 ether, accountsAmounts, + abi.encodePacked("AccessControl: account 0x3448fc79c22032be61bee8d832ebc59744f5cc40 is missing role 0x444953545249425554455f524f4c450000000000000000000000000000000000")); + } + + + function _deal(address _to, uint256 _amount) internal virtual; + function _getBalanceStaker(address _staker) internal virtual view returns (uint256); + function _getBalanceStakeHolderContract() internal virtual view returns (uint256); + + function _addStake(address _staker, uint256 _amount) internal { + _addStake(_staker, _amount, false, bytes("")); + } + function _addStake(address _staker, uint256 _amount, bytes memory _error) internal { + _addStake(_staker, _amount, true, _error); + + } + function _addStake(address _staker, uint256 _amount, bool _hasError, bytes memory _error) internal virtual; + + + function _distributeRewards(address _distributor, uint256 _total, IStakeHolder.AccountAmount[] memory _accountAmounts) internal { + _distributeRewards(_distributor, _total, _accountAmounts, false, bytes("")); + } + function _distributeRewards(address _distributor, uint256 _total, IStakeHolder.AccountAmount[] memory _accountAmounts, bytes memory _error) internal { + _distributeRewards(_distributor, _total, _accountAmounts, true, _error); + } + function _distributeRewards(address _distributor, uint256 _total, IStakeHolder.AccountAmount[] memory _accountAmounts, + bool _hasError, bytes memory _error) internal virtual; +} diff --git a/test/staking/StakeHolderOperationalERC20.t.sol b/test/staking/StakeHolderOperationalERC20.t.sol new file mode 100644 index 00000000..a91f9ebb --- /dev/null +++ b/test/staking/StakeHolderOperationalERC20.t.sol @@ -0,0 +1,75 @@ +// Copyright Immutable Pty Ltd 2018 - 2024 +// SPDX-License-Identifier: Apache 2.0 +pragma solidity >=0.8.19 <0.8.29; + +// solhint-disable-next-line no-global-import +import "forge-std/Test.sol"; +import {StakeHolderERC20} from "../../contracts/staking/StakeHolderERC20.sol"; +import {IStakeHolder} from "../../contracts/staking/IStakeHolder.sol"; +import {StakeHolderOperationalBaseTest} from "./StakeHolderOperationalBase.t.sol"; +import {ERC1967Proxy} from "openzeppelin-contracts-4.9.3/proxy/ERC1967/ERC1967Proxy.sol"; +import {ERC20PresetFixedSupply} from "openzeppelin-contracts-4.9.3/token/ERC20/presets/ERC20PresetFixedSupply.sol"; + +contract StakeHolderOperationalERC20Test is StakeHolderOperationalBaseTest { + ERC20PresetFixedSupply erc20; + + + function setUp() public override { + super.setUp(); + + erc20 = new ERC20PresetFixedSupply("Name", "SYM", 1000 ether, bank); + + StakeHolderERC20 impl = new StakeHolderERC20(); + + bytes memory initData = abi.encodeWithSelector( + StakeHolderERC20.initialize.selector, roleAdmin, upgradeAdmin, distributeAdmin, address(erc20) + ); + + ERC1967Proxy proxy = new ERC1967Proxy(address(impl), initData); + stakeHolder = IStakeHolder(address(proxy)); + } + + + function testStakeWithValue() public { + uint256 amount = 100 ether; + vm.deal(staker1, amount); + _deal(staker1, amount); + + vm.prank(staker1); + erc20.approve(address(stakeHolder), amount); + vm.expectRevert(abi.encodeWithSelector(IStakeHolder.NonPayable.selector)); + vm.prank(staker1); + stakeHolder.stake{value: amount}(amount); + } + + function _deal(address _to, uint256 _amount) internal override { + vm.prank(bank); + erc20.transfer(_to, _amount); + } + + function _addStake(address _staker, uint256 _amount, bool _hasError, bytes memory _error) internal override { + vm.prank(_staker); + erc20.approve(address(stakeHolder), _amount); + if (_hasError) { + vm.expectRevert(_error); + } + vm.prank(_staker); + stakeHolder.stake(_amount); + } + function _distributeRewards(address _distributor, uint256 _total, IStakeHolder.AccountAmount[] memory _accountAmounts, + bool _hasError, bytes memory _error) internal override { + vm.prank(_distributor); + erc20.approve(address(stakeHolder), _total); + if (_hasError) { + vm.expectRevert(_error); + } + vm.prank(_distributor); + stakeHolder.distributeRewards(_accountAmounts); + } + function _getBalanceStaker(address _staker) internal view override returns (uint256) { + return erc20.balanceOf(_staker); + } + function _getBalanceStakeHolderContract() internal view override returns (uint256) { + return erc20.balanceOf(address(stakeHolder)); + } +} diff --git a/test/staking/StakeHolderOperationalNative.t.sol b/test/staking/StakeHolderOperationalNative.t.sol new file mode 100644 index 00000000..0d599f54 --- /dev/null +++ b/test/staking/StakeHolderOperationalNative.t.sol @@ -0,0 +1,90 @@ +// Copyright Immutable Pty Ltd 2018 - 2024 +// SPDX-License-Identifier: Apache 2.0 +pragma solidity >=0.8.19 <0.8.29; + +// solhint-disable-next-line no-global-import +import "forge-std/Test.sol"; +import {StakeHolderNative} from "../../contracts/staking/StakeHolderNative.sol"; +import {IStakeHolder} from "../../contracts/staking/IStakeHolder.sol"; +import {StakeHolderOperationalBaseTest} from "./StakeHolderOperationalBase.t.sol"; +import {ERC1967Proxy} from "openzeppelin-contracts-4.9.3/proxy/ERC1967/ERC1967Proxy.sol"; +import {StakeHolderAttackWallet} from "./StakeHolderAttackWallet.sol"; + +contract StakeHolderOperationalNativeTest is StakeHolderOperationalBaseTest { + + function setUp() public override { + super.setUp(); + + StakeHolderNative impl = new StakeHolderNative(); + + bytes memory initData = abi.encodeWithSelector( + StakeHolderNative.initialize.selector, roleAdmin, upgradeAdmin, distributeAdmin + ); + + ERC1967Proxy proxy = new ERC1967Proxy(address(impl), initData); + stakeHolder = IStakeHolder(address(proxy)); + } + + function testUnstakeReentrantAttack() public { + StakeHolderAttackWallet attacker = new StakeHolderAttackWallet(address(stakeHolder)); + _deal(address(attacker), 100 ether); + + attacker.stake(10 ether); + // Attacker's reentracy attack will double the amount being unstaked. + // The attack fails due to attempting to withdraw more than balance (that is, 2 x 6 eth = 12) + vm.expectRevert(abi.encodePacked("ReentrancyGuard: reentrant call")); + attacker.unstake{gas: 10000000}(6 ether); + } + + function testDistributeMismatch() public { + _deal(staker1, 100 ether); + _deal(staker2, 100 ether); + _deal(staker3, 100 ether); + _deal(distributeAdmin, 100 ether); + + _addStake(staker1, 10 ether); + _addStake(staker2, 20 ether); + _addStake(staker3, 30 ether); + + // Distribute rewards to staker2 and staker3. + IStakeHolder.AccountAmount[] memory accountsAmounts = new IStakeHolder.AccountAmount[](2); + accountsAmounts[0] = IStakeHolder.AccountAmount(staker2, 0.5 ether); + accountsAmounts[1] = IStakeHolder.AccountAmount(staker3, 1 ether); + _distributeRewards(distributeAdmin, 1 ether, accountsAmounts, + abi.encodeWithSelector(IStakeHolder.MismatchMsgValueAmount.selector, 1 ether, 1.5 ether)); + } + + function testAddStakeMismatch() public { + uint256 amount = 100 ether; + _deal(staker1, amount); + vm.prank(staker1); + vm.expectRevert(abi.encodeWithSelector(IStakeHolder.MismatchMsgValueAmount.selector, amount, amount+1)); + stakeHolder.stake{value: amount}(amount + 1); + } + + function _deal(address _to, uint256 _amount) internal override { + vm.deal(_to, _amount); + } + function _addStake(address _staker, uint256 _amount, bool _hasError, bytes memory _error) internal override { + if (_hasError) { + vm.expectRevert(_error); + } + vm.prank(_staker); + stakeHolder.stake{value: _amount}(_amount); + } + function _distributeRewards(address _distributor, uint256 _total, IStakeHolder.AccountAmount[] memory _accountAmounts, + bool _hasError, bytes memory _error) internal override { + if (_hasError) { + vm.expectRevert(_error); + } + vm.prank(_distributor); + stakeHolder.distributeRewards{value: _total}(_accountAmounts); + } + function _getBalanceStaker(address _staker) internal view override returns (uint256) { + return _staker.balance; + } + function _getBalanceStakeHolderContract() internal view override returns (uint256) { + return address(stakeHolder).balance; + } + +} diff --git a/test/staking/StakeHolderOperationalWIMX.t.sol b/test/staking/StakeHolderOperationalWIMX.t.sol new file mode 100644 index 00000000..df8f5e10 --- /dev/null +++ b/test/staking/StakeHolderOperationalWIMX.t.sol @@ -0,0 +1,99 @@ +// Copyright Immutable Pty Ltd 2018 - 2024 +// SPDX-License-Identifier: Apache 2.0 +pragma solidity >=0.8.19 <0.8.29; + +// solhint-disable-next-line no-global-import +import "forge-std/Test.sol"; +import {StakeHolderWIMX} from "../../contracts/staking/StakeHolderWIMX.sol"; +import {IStakeHolder} from "../../contracts/staking/IStakeHolder.sol"; +import {WIMX} from "../../contracts/staking/WIMX.sol"; +import {StakeHolderOperationalBaseTest} from "./StakeHolderOperationalBase.t.sol"; +import {ERC1967Proxy} from "openzeppelin-contracts-4.9.3/proxy/ERC1967/ERC1967Proxy.sol"; +import {ERC20PresetFixedSupply} from "openzeppelin-contracts-4.9.3/token/ERC20/presets/ERC20PresetFixedSupply.sol"; + +contract StakeHolderOperationalWIMXTest is StakeHolderOperationalBaseTest { + WIMX erc20; + + + function setUp() public override { + super.setUp(); + + erc20 = new WIMX(); + + StakeHolderWIMX impl = new StakeHolderWIMX(); + + bytes memory initData = abi.encodeWithSelector( + StakeHolderWIMX.initialize.selector, roleAdmin, upgradeAdmin, distributeAdmin, address(erc20) + ); + + ERC1967Proxy proxy = new ERC1967Proxy(address(impl), initData); + stakeHolder = IStakeHolder(address(proxy)); + } + + +//TODO + + // function testUnstakeReentrantAttack() public { + // StakeHolderAttackWallet attacker = new StakeHolderAttackWallet(address(stakeHolder)); + // _deal(address(attacker), 100 ether); + + // attacker.stake(10 ether); + // // Attacker's reentracy attack will double the amount being unstaked. + // // The attack fails due to attempting to withdraw more than balance (that is, 2 x 6 eth = 12) + // vm.expectRevert(abi.encodePacked("ReentrancyGuard: reentrant call")); + // attacker.unstake{gas: 10000000}(6 ether); + // } + + function testDistributeMismatch() public { + _deal(staker1, 100 ether); + _deal(staker2, 100 ether); + _deal(staker3, 100 ether); + _deal(distributeAdmin, 100 ether); + + _addStake(staker1, 10 ether); + _addStake(staker2, 20 ether); + _addStake(staker3, 30 ether); + + // Distribute rewards to staker2 and staker3. + IStakeHolder.AccountAmount[] memory accountsAmounts = new IStakeHolder.AccountAmount[](2); + accountsAmounts[0] = IStakeHolder.AccountAmount(staker2, 0.5 ether); + accountsAmounts[1] = IStakeHolder.AccountAmount(staker3, 1 ether); + _distributeRewards(distributeAdmin, 1 ether, accountsAmounts, + abi.encodeWithSelector(IStakeHolder.MismatchMsgValueAmount.selector, 1 ether, 1.5 ether)); + } + + + function testAddStakeMismatch() public { + uint256 amount = 100 ether; + _deal(staker1, amount); + vm.prank(staker1); + vm.expectRevert(abi.encodeWithSelector(IStakeHolder.MismatchMsgValueAmount.selector, amount, amount+1)); + stakeHolder.stake{value: amount}(amount + 1); + } + + function _deal(address _to, uint256 _amount) internal override { + vm.deal(_to, _amount); + } + + function _addStake(address _staker, uint256 _amount, bool _hasError, bytes memory _error) internal override { + if (_hasError) { + vm.expectRevert(_error); + } + vm.prank(_staker); + stakeHolder.stake{value: _amount}(_amount); + } + function _distributeRewards(address _distributor, uint256 _total, IStakeHolder.AccountAmount[] memory _accountAmounts, + bool _hasError, bytes memory _error) internal override { + if (_hasError) { + vm.expectRevert(_error); + } + vm.prank(_distributor); + stakeHolder.distributeRewards{value: _total}(_accountAmounts); + } + function _getBalanceStaker(address _staker) internal view override returns (uint256) { + return _staker.balance; + } + function _getBalanceStakeHolderContract() internal view override returns (uint256) { + return erc20.balanceOf(address(stakeHolder)); + } +} diff --git a/test/staking/StakeHolderTimeDelayBase.t.sol b/test/staking/StakeHolderTimeDelayBase.t.sol new file mode 100644 index 00000000..1750616d --- /dev/null +++ b/test/staking/StakeHolderTimeDelayBase.t.sol @@ -0,0 +1,121 @@ +// Copyright Immutable Pty Ltd 2018 - 2024 +// SPDX-License-Identifier: Apache 2.0 +pragma solidity >=0.8.19 <0.8.29; + +// solhint-disable-next-line no-global-import +import "forge-std/Test.sol"; +import {IStakeHolder} from "../../contracts/staking/IStakeHolder.sol"; +import {StakeHolderBaseTest} from "./StakeHolderBase.t.sol"; +import {TimelockController} from "openzeppelin-contracts-4.9.3/governance/TimelockController.sol"; +import {UUPSUpgradeable} from "openzeppelin-contracts-upgradeable-4.9.3/proxy/utils/UUPSUpgradeable.sol"; +import {StakeHolderBase} from "../../contracts/staking/StakeHolderBase.sol"; + + +abstract contract StakeHolderTimeDelayBaseTest is StakeHolderBaseTest { + TimelockController stakeHolderTimeDelay; + + uint256 delay = 604800; // 604800 seconds = 1 week + + address adminProposer; + address adminExecutor; + + function setUp() public virtual override { + super.setUp(); + + adminProposer = makeAddr("adminProposer"); + adminExecutor = makeAddr("adminExecutor"); + + address[] memory proposers = new address[](1); + proposers[0] = adminProposer; + address[] memory executors = new address[](1); + executors[0] = adminExecutor; + + stakeHolderTimeDelay = new TimelockController(delay, proposers, executors, address(0)); + } + + + function testTimeLockControllerDeployment() public { + assertEq(stakeHolderTimeDelay.getMinDelay(), delay, "Incorrect time delay"); + } + + function testUpgrade() public { + IStakeHolder v2Impl = _deployV2(); + + bytes memory initData = abi.encodeWithSelector(StakeHolderBase.upgradeStorage.selector, bytes("")); + bytes memory upgradeCall = abi.encodeWithSelector( + UUPSUpgradeable.upgradeToAndCall.selector, address(v2Impl), initData); + + address target = address(stakeHolder); + uint256 value = 0; + bytes memory data = upgradeCall; + bytes32 predecessor = bytes32(0); + bytes32 salt = bytes32(uint256(1)); + uint256 theDelay = delay; + + uint256 timeNow = block.timestamp; + + vm.prank(adminProposer); + stakeHolderTimeDelay.schedule( + target, value, data, predecessor, salt, theDelay); + + vm.warp(timeNow + delay); + + vm.prank(adminExecutor); + stakeHolderTimeDelay.execute(target, value, data, predecessor, salt); + + uint256 ver = stakeHolder.version(); + assertEq(ver, 1, "Upgrade did not upgrade version"); + } + + function testTooShortDelay() public { + IStakeHolder v2Impl = _deployV2(); + + bytes memory initData = abi.encodeWithSelector(StakeHolderBase.upgradeStorage.selector, bytes("")); + bytes memory upgradeCall = abi.encodeWithSelector( + UUPSUpgradeable.upgradeToAndCall.selector, address(v2Impl), initData); + + address target = address(stakeHolder); + uint256 value = 0; + bytes memory data = upgradeCall; + bytes32 predecessor = bytes32(0); + bytes32 salt = bytes32(uint256(1)); + uint256 theDelay = delay - 1; // Too small + + vm.expectRevert(abi.encodePacked("TimelockController: insufficient delay")); + vm.prank(adminProposer); + stakeHolderTimeDelay.schedule( + target, value, data, predecessor, salt, theDelay); + } + + function testExecuteEarly() public { + IStakeHolder v2Impl = _deployV2(); + + bytes memory initData = abi.encodeWithSelector(StakeHolderBase.upgradeStorage.selector, bytes("")); + bytes memory upgradeCall = abi.encodeWithSelector( + UUPSUpgradeable.upgradeToAndCall.selector, address(v2Impl), initData); + + address target = address(stakeHolder); + uint256 value = 0; + bytes memory data = upgradeCall; + bytes32 predecessor = bytes32(0); + bytes32 salt = bytes32(uint256(1)); + uint256 theDelay = delay; + + uint256 timeNow = block.timestamp; + + vm.prank(adminProposer); + stakeHolderTimeDelay.schedule( + target, value, data, predecessor, salt, theDelay); + + vm.expectRevert(abi.encodePacked("TimelockController: operation is not ready")); + vm.warp(timeNow + delay - 1); // Too early + + vm.prank(adminExecutor); + stakeHolderTimeDelay.execute(target, value, data, predecessor, salt); + } + + + + + function _deployV2() internal virtual returns(IStakeHolder); +} diff --git a/test/staking/StakeHolderTimeDelayERC20.t.sol b/test/staking/StakeHolderTimeDelayERC20.t.sol new file mode 100644 index 00000000..ff88dd32 --- /dev/null +++ b/test/staking/StakeHolderTimeDelayERC20.t.sol @@ -0,0 +1,44 @@ +// Copyright Immutable Pty Ltd 2018 - 2024 +// SPDX-License-Identifier: Apache 2.0 +pragma solidity >=0.8.19 <0.8.29; + +// solhint-disable-next-line no-global-import +import "forge-std/Test.sol"; +import {StakeHolderERC20} from "../../contracts/staking/StakeHolderERC20.sol"; +import {IStakeHolder} from "../../contracts/staking/IStakeHolder.sol"; +import {StakeHolderTimeDelayBaseTest} from "./StakeHolderTimeDelayBase.t.sol"; +import {ERC1967Proxy} from "openzeppelin-contracts-4.9.3/proxy/ERC1967/ERC1967Proxy.sol"; +import {ERC20PresetFixedSupply} from "openzeppelin-contracts-4.9.3/token/ERC20/presets/ERC20PresetFixedSupply.sol"; +import {StakeHolderBase} from "../../contracts/staking/StakeHolderBase.sol"; + +contract StakeHolderERC20V2 is StakeHolderERC20 { + function upgradeStorage(bytes memory /* _data */) external override(StakeHolderBase) { + version = 1; + } +} + + +contract StakeHolderTimeDelayERC20Test is StakeHolderTimeDelayBaseTest { + ERC20PresetFixedSupply erc20; + + function setUp() public override { + super.setUp(); + + erc20 = new ERC20PresetFixedSupply("Name", "SYM", 1000 ether, bank); + + StakeHolderERC20 impl = new StakeHolderERC20(); + + bytes memory initData = abi.encodeWithSelector( + StakeHolderERC20.initialize.selector, address(stakeHolderTimeDelay), address(stakeHolderTimeDelay), + distributeAdmin, address(erc20) + ); + + ERC1967Proxy proxy = new ERC1967Proxy(address(impl), initData); + stakeHolder = IStakeHolder(address(proxy)); + } + + function _deployV2() internal override returns(IStakeHolder) { + return IStakeHolder(address(new StakeHolderERC20V2())); + } + +} diff --git a/test/staking/StakeHolderTimeDelayWIMX.t.sol b/test/staking/StakeHolderTimeDelayWIMX.t.sol new file mode 100644 index 00000000..0a55d1b9 --- /dev/null +++ b/test/staking/StakeHolderTimeDelayWIMX.t.sol @@ -0,0 +1,43 @@ +// Copyright Immutable Pty Ltd 2018 - 2024 +// SPDX-License-Identifier: Apache 2.0 +pragma solidity >=0.8.19 <0.8.29; + +// solhint-disable-next-line no-global-import +import "forge-std/Test.sol"; +import {StakeHolderWIMX} from "../../contracts/staking/StakeHolderWIMX.sol"; +import {IStakeHolder} from "../../contracts/staking/IStakeHolder.sol"; +import {StakeHolderTimeDelayBaseTest} from "./StakeHolderTimeDelayBase.t.sol"; +import {ERC1967Proxy} from "openzeppelin-contracts-4.9.3/proxy/ERC1967/ERC1967Proxy.sol"; +import {WIMX} from "../../contracts/staking/WIMX.sol"; +import {StakeHolderBase} from "../../contracts/staking/StakeHolderBase.sol"; + +contract StakeHolderWIMXV2 is StakeHolderWIMX { + function upgradeStorage(bytes memory /* _data */) external override(StakeHolderBase) { + version = 1; + } +} + + +contract StakeHolderTimeDelayERC20Test is StakeHolderTimeDelayBaseTest { + WIMX erc20; + + function setUp() public override { + super.setUp(); + + erc20 = new WIMX(); + + StakeHolderWIMX impl = new StakeHolderWIMX(); + + bytes memory initData = abi.encodeWithSelector( + StakeHolderWIMX.initialize.selector, address(stakeHolderTimeDelay), address(stakeHolderTimeDelay), + distributeAdmin, address(erc20) + ); + + ERC1967Proxy proxy = new ERC1967Proxy(address(impl), initData); + stakeHolder = IStakeHolder(address(proxy)); + } + + function _deployV2() internal override returns(IStakeHolder) { + return IStakeHolder(address(new StakeHolderWIMXV2())); + } +}