diff --git a/,,.x.txt b/,,.x.txt new file mode 100644 index 00000000..d7bb179f --- /dev/null +++ b/,,.x.txt @@ -0,0 +1,3 @@ +Compiling 118 files with Solc 0.8.17 +Compiling 55 files with Solc 0.8.20 +Compiling 305 files with Solc 0.8.28 diff --git a/audits/staking/202506-threat-model-stake-holder.md b/audits/staking/202506-threat-model-stake-holder.md new file mode 100644 index 00000000..28083d4c --- /dev/null +++ b/audits/staking/202506-threat-model-stake-holder.md @@ -0,0 +1,321 @@ +# Stake Holder Threat Model + +## Introduction + +This threat model has been written to assist an internal audit in anticipation of the upgrade of StakeHolderWIMX to StakeHolderWIMXV2. This threat model also covers [StakeHolderERC20V2 and StakeHolderNativeV2](../../contracts/staking/README.md) contracts. + +## Rationale + +Immutable operates a system whereby people can place native IMX in a holding contract, do some actions (which are outside of the scope of this threat model), and then are paid a reward. The people, known as stakers, have full custody of their tokens they place in the holding contract; they can withdraw deposited IMX at any time. Administrators can choose to distribute rewards to stakers at any time. + +The StakeHolderERC20 contract can be used for any staking system that uses ERC20 tokens. The StakeHolderNative contract is an alternative implementation that allows native IMX, rather than ERC20 tokens, to be used for staking. The difference between the StakeHolderNative and StakeHolderWIMX is that the StakeHolderWIMX holds the staked value as wrapped IMX (WIMX), an ERC20 contract. + + +## Threat Model Scope + +The threat model is limited to the stake holder Solidity files at GitHash [`11445d246d931100cca3d863d254a18c8160a68e`](https://github.com/immutable/contracts/tree/11445d246d931100cca3d863d254a18c8160a68e/contracts/staking): + +* [IStakeHolder.sol](https://github.com/immutable/contracts/blob/11445d246d931100cca3d863d254a18c8160a68e/contracts/staking/IStakeHolder.sol) is the interface that all staking implementations comply with. +* [IStakeHolderV2.sol](https://github.com/immutable/contracts/blob/11445d246d931100cca3d863d254a18c8160a68e/contracts/staking/IStakeHolderV2.sol) is the interface that adds the `stakeFor` function. +* [StakeHolderBase.sol](https://github.com/immutable/contracts/tree/11445d246d931100cca3d863d254a18c8160a68e/contracts/staking/StakeHolderBase.sol) is the abstract base contract that all staking implementations use. +* [StakeHolderBaseV2.sol](https://github.com/immutable/contracts/tree/v/contracts/staking/StakeHolderBase.sol) is the abstract base contract that all V2 staking implementations use. +* [StakeHolderWIMX.sol](https://github.com/immutable/contracts/tree/11445d246d931100cca3d863d254a18c8160a68e/contracts/staking/StakeHolderWIMX.sol) allows the native token, IMX, to be used as the staking currency. +* [StakeHolderERC20.sol](https://github.com/immutable/contracts/tree/11445d246d931100cca3d863d254a18c8160a68e/contracts/staking/StakeHolderERC20.sol) allows an ERC20 token to be used as the staking currency. +* [StakeHolderNative.sol](https://github.com/immutable/contracts/tree/11445d246d931100cca3d863d254a18c8160a68e/contracts/staking/StakeHolderNative.sol) uses the native token, IMX, to be used as the staking currency. + +Additionally, this threat model analyses whether the documentation for the time controller contract correctly advises operators how to achieve the required time delay upgrade functionality: + +* [TimelockController.sol](https://github.com/OpenZeppelin/openzeppelin-contracts/blob/v4.9.3/contracts/governance/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. + + +## Background + +See the [README](https://github.com/immutable/contracts/tree/bf327c7abdadd48fd51ae632500510ac2b07b5f0/contracts/staking/README.md) file for information about the usage and design of the stake holder contract system. + +### Other Information + +This section provides links to test plans and test code. + +#### Test Plans and Test Code + +The test plan is available here: [Test Plan](../../test/staking/README.md). The test code is contained in the same directory at the test plan. + +#### Continuous Integration + +Each time a commit is pushed to a pull request, the [continuous integration loop executes](https://github.com/immutable/contracts/actions). + +#### Building, Testing, Coverage and Static Code Analysis + +For instructions on building the code, running tests, coverage, and Slither, see the [BUILD.md](https://github.com/immutable/contracts/blob/main/BUILD.md). + +## Attack Surfaces + +The following sections list attack surfaces evaluated as part of this threat modelling exercise. + +### Externally Visible Functions + +An attacker could formulate an attack in which they send one or more transactions that execute one or more of the externally visible functions. + +The list of functions and their function selectors was determined by the following commands. The additional information was obtained by reviewing the code. `StakeHolderWIMXV2`, `StakeHolderERC20V2` and `StakeHolderNativeV2` have identical functions with the exception of the `initialize` function. `StakeHolderWIMXV2` and `StakeHolderERC20V2` use the `initialize` function that has four parameters and `StakeHolderNativeV2` uses the `initialize` function with three parameters. + +``` +forge inspect StakeHolderWIMXV2 methods +forge inspect StakeHolderERC20V2 methods +forge inspect StakeHolderNativeV2 methods +``` + +Functions that *change* state: + +| Name | Function Selector | Access Control | +| --------------------------------------- | ----------------- | ------------------- | +| `distributeRewards((address,uint256)[])`| 00cfb539 | Distribute role only | +| `grantRole(bytes32,address)` | 2f2ff15d | Role admin | +| `initialize(address,address,address)` | c0c53b8b | Can only be called once during deployment | +| `initialize(address,address,address,address)` | f8c8765e | Can only be called once during deployment | +| `renounceRole(bytes32,address)` | 36568abe | `msg.sender` | +| `revokeRole(bytes32,address)` | d547741f | Role admin | +| `stake(uint256)` | a694fc3a | Operations based on msg.sender |\ +| `stakeFor((address, uint256)[])` | fa703f03 | Distribute role only | +| `unstake(uint256)` | 2e17de78 | Operations based on msg.sender | +| `upgradeStorage(bytes)` | ffd0016f | Can only be called once during upgrade | +| `upgradeTo(address)` | 3659cfe6 | Upgrade role only | +| `upgradeToAndCall(address,bytes)` | 4f1ef286 | Upgrade role only | + + +Functions that *do not change* state: + +| Name | Function Selector | +| -------------------------------- | ----------------- | +| `DEFAULT_ADMIN_ROLE()` | a217fddf | +| `DISTRIBUTE_ROLE()` | 7069257d | +| `UPGRADE_ROLE()` | b908afa8 | +| `getBalance(address)` | f8b2cb4f | +| `getNumStakers()` | bc788d46 | +| `getRoleAdmin(bytes32)` | 248a9ca3 | +| `getRoleMember(bytes32,uint256)` | 9010d07c | +| `getRoleMemberCount(bytes32)` | ca15c873 | +| `getStakers(uint256,uint256)` | ad71bd36 | +| `getToken()` | 21df0da7 | +| `hasRole(bytes32,address)` | 91d14854 | +| `hasStaked(address)` | c93c8f34 | +| `proxiableUUID()` | 52d1902d | +| `supportsInterface(bytes4)` | 01ffc9a7 | +| `version()` | 54fd4d50 | + + + +### Admin Roles + +Accounts with administrative privileges could be used by attackers to facilitate attacks. This section analyses what each role can do. + +#### Accounts with `DEFAULT_ADMIN_ROLE` + +The `DEFAULT_ADMIN_ROLE` is the role that is granted to the `roleAdmin` specified in the `initialize` function of the `StakeHolderWIMX`, `StakeHolderERC20` and `StakeHolderNative` contracts. Accounts with the `DEFAULT_ADMIN_ROLE` can: + +* Grant administrator roles to any account, including the `DEFAULT_ADMIN_ROLE`. +* Revoke administrator roles from any account, including the `DEFAULT_ADMIN_ROLE`. +* Renounce the `DEFAULT_ADMIN_ROLE` for itself. + +Exploiting this attack surface requires compromising an account with `DEFAULT_ADMIN_ROLE`. + +#### Accounts with `UPGRADE_ROLE` + +An account with `UPGRADE_ROLE` can: + +* Upgrade the implementation contract. +* Renounce the `UPGRADE_ROLE` for itself. + +Exploiting this attack surface requires compromising an account with `UPGRADE_ROLE`. + +#### Accounts with `DISTRIBUTE_ROLE` + +An account with `DISTRIBUTE_ROLE` can: + +* Call the `distributeRewards` function to distribute rewards. +* Call the `stakeFor` function to stake on behalf of accounts. +* Renounce the `DISTRIBUTE_ROLE` for itself. + +Exploiting this attack surface requires compromising an account with `DISTRIBUTE_ROLE`. + + +### Upgrade and Storage Slots + +#### Upgrade and Storage Slots for StakeHolderWIMX + +The table was constructed by using the command described below, and analysing the source code. + +``` +forge inspect StakeHolderWIMX 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 | +| wIMX | contract IWIMX | 454 | 0 | 20 | StakeHolderWIMX.sol | +| \_\_StakeHolderWIMXGap | uint256[50] | 455 | 0 | 1600 | StakeHolderWIMX.sol | + +#### Upgrade and Storage Slots for StakeHolderWIMXV2 + +The storage slots used for `StakeHolderWIMXV2` where checked using the command below. The usage is identical to `StakeHolderWIMX`. + +``` +forge inspect StakeHolderWIMXV2 storage +``` + +#### 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 StakeHolderERC20V2 + +The storage slots used for `StakeHolderERC20V2` where checked using the command below. The usage is identical to `StakeHolderERC20`. + +``` +forge inspect StakeHolderERC20V2 storage +``` + + +#### 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 | + +#### Upgrade and Storage Slots for StakeHolderNativeV2 + +The storage slots used for `StakeHolderNativeV2` where checked using the command below. The usage is identical to `StakeHolderNative`. + +``` +forge inspect StakeHolderNativeV2 storage +``` + + +### 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/staking/IStakeHolder.sol b/contracts/staking/IStakeHolder.sol index 12c06fa5..9c1c4e65 100644 --- a/contracts/staking/IStakeHolder.sol +++ b/contracts/staking/IStakeHolder.sol @@ -5,8 +5,7 @@ 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. + * @title IStakeHolder: Interface for staking system. */ interface IStakeHolder is IAccessControlEnumerableUpgradeable { /// @notice implementation does not accept native tokens. @@ -39,9 +38,14 @@ interface IStakeHolder is IAccessControlEnumerableUpgradeable { /// @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. + /// @notice Event summarising a distribution. + /// @dev There will also be one StakeAdded event for each recipient. event Distributed(address _distributor, uint256 _totalDistribution, uint256 _numRecipients); + /// @notice Error: Unstake native value transfer failed with revert with no revert informaiton. + /// @dev An error was detected by the EVM. For example a function call to an address with no contract associated with it. + error UnstakeTransferFailed(); + /// @notice Struct to combine an account and an amount. struct AccountAmount { address account; @@ -61,7 +65,9 @@ interface IStakeHolder is IAccessControlEnumerableUpgradeable { function unstake(uint256 _amountToUnstake) external; /** - * @notice Accounts with DISTRIBUTE_ROLE can distribute tokens to any set of accounts. + * @notice Distribute rewards to stakers. + * @dev Only callable by accounts with DISTRIBUTE_ROLE. + * @dev Receipients must have staked value prior to this function call. * @param _recipientsAndAmounts An array of recipients to distribute value to and * amounts to be distributed to each recipient. */ diff --git a/contracts/staking/IStakeHolderV2.sol b/contracts/staking/IStakeHolderV2.sol new file mode 100644 index 00000000..91305a45 --- /dev/null +++ b/contracts/staking/IStakeHolderV2.sol @@ -0,0 +1,23 @@ +// Copyright (c) Immutable Pty Ltd 2018 - 2025 +// SPDX-License-Identifier: Apache 2 +pragma solidity >=0.8.19 <0.8.29; + +import {IStakeHolder} from "./IStakeHolder.sol"; + +/** + * @title IStakeHolderV2: Interface for V2 staking system. + */ +interface IStakeHolderV2 is IStakeHolder { + /// @notice Event summarising a distribution via the stakeFor function. + /// @dev There will be one StakeAdded event for each recipient. + event StakedFor(address _distributor, uint256 _totalDistribution, uint256 _numRecipients); + + /** + * @notice Stake on behalf of others. + * @dev Only callable by accounts with DISTRIBUTE_ROLE. + * @dev Unlike the distributeRewards function, there is no requirement that recipients are existing stakers. + * @param _recipientsAndAmounts An array of recipients to distribute value to and + * amounts to be distributed to each recipient. + */ + function stakeFor(AccountAmount[] calldata _recipientsAndAmounts) external payable; +} diff --git a/contracts/staking/README.md b/contracts/staking/README.md index c6ec3801..03a2aab1 100644 --- a/contracts/staking/README.md +++ b/contracts/staking/README.md @@ -1,6 +1,6 @@ # Staking -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 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 contracts have the facility to distribute rewards to stakers and to stake on behalf of accounts 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). @@ -24,6 +24,10 @@ The system consists of a set of contracts show in the diagram below. `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. +## Staking System V2 + +Files, contracts, and interfaced suffixed with `V2` form a part of the version two staking system. Version two introduces the ability for an admin account to stake on behalf of other accounts using the `stakeFor` function. + ## Immutable Contract Addresses TimelockController.sol: diff --git a/contracts/staking/StakeHolderBase.sol b/contracts/staking/StakeHolderBase.sol index 77c50ef7..0a434ac0 100644 --- a/contracts/staking/StakeHolderBase.sol +++ b/contracts/staking/StakeHolderBase.sol @@ -8,8 +8,8 @@ import {ReentrancyGuardUpgradeable} from "openzeppelin-contracts-upgradeable-4.9 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. + * @title StakeHolderBase: allows anyone to stake and unstake value. + * @dev This contract is designed to be upgradeable. */ abstract contract StakeHolderBase is IStakeHolder, @@ -58,7 +58,7 @@ abstract contract StakeHolderBase is address _roleAdmin, address _upgradeAdmin, address _distributeAdmin - ) internal onlyInitializing { + ) internal virtual onlyInitializing { __UUPSUpgradeable_init(); __AccessControl_init(); __ReentrancyGuard_init(); @@ -117,7 +117,7 @@ abstract contract StakeHolderBase is */ function distributeRewards( AccountAmount[] calldata _recipientsAndAmounts - ) external payable nonReentrant onlyRole(DISTRIBUTE_ROLE) { + ) external payable virtual nonReentrant onlyRole(DISTRIBUTE_ROLE) { // Distribute the value. uint256 total = 0; uint256 len = _recipientsAndAmounts.length; diff --git a/contracts/staking/StakeHolderBaseV2.sol b/contracts/staking/StakeHolderBaseV2.sol new file mode 100644 index 00000000..0e373d29 --- /dev/null +++ b/contracts/staking/StakeHolderBaseV2.sol @@ -0,0 +1,105 @@ +// Copyright (c) Immutable Pty Ltd 2018 - 2025 +// SPDX-License-Identifier: Apache 2 +pragma solidity >=0.8.19 <0.8.29; + +import {StakeHolderBase} from "./StakeHolderBase.sol"; +import {IStakeHolderV2, IStakeHolder} from "./IStakeHolderV2.sol"; + +/** + * @title StakeHolderBase: allows anyone to stake any amount of an ERC20 token and to then remove all or part of that stake. + * @dev This contract is designed to be upgradeable. + */ +abstract contract StakeHolderBaseV2 is IStakeHolderV2, StakeHolderBase { + /// @notice Version 2 version number + uint256 internal constant _VERSION2 = 2; + + + /** + * @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 virtual override { + // NOTE: onlyInitializing is called in super. + super.__StakeHolderBase_init(_roleAdmin, _upgradeAdmin, _distributeAdmin); + version = _VERSION2; + } + + /** + * @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 override { + if (version == _VERSION0) { + // Upgrading from version 0 to 2 involves only code changes and + // changing the storage version number. + version = _VERSION2; + } + else { + // Don't allow downgrade or re-initialising. + revert CanNotUpgradeToLowerOrSameVersion(version); + } + } + + /** + * @inheritdoc IStakeHolder + */ + function distributeRewards( + AccountAmount[] calldata _recipientsAndAmounts + ) external payable override(IStakeHolder, StakeHolderBase) nonReentrant onlyRole(DISTRIBUTE_ROLE) { + uint256 total = _distributeRewards(_recipientsAndAmounts, true); + uint256 len = _recipientsAndAmounts.length; + emit Distributed(msg.sender, total, len); + } + + /** + * @inheritdoc IStakeHolderV2 + */ + function stakeFor( + AccountAmount[] calldata _recipientsAndAmounts + ) external payable nonReentrant onlyRole(DISTRIBUTE_ROLE) { + uint256 total = _distributeRewards(_recipientsAndAmounts, false); + uint256 len = _recipientsAndAmounts.length; + emit StakedFor(msg.sender, total, len); + } + + /** + * @notice Distribute tokens to a set of accounts. + * @param _recipientsAndAmounts An array of recipients to distribute value to and + * amounts to be distributed to each recipient. + * @param _existingAccountsOnly If true, revert if the account has never been used. + * @return _total Value distirbuted. + */ + function _distributeRewards( + AccountAmount[] calldata _recipientsAndAmounts, + bool _existingAccountsOnly + ) private returns (uint256 _total) { + // Distribute the value. + _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, _existingAccountsOnly); + _total += amount; + } + if (_total == 0) { + revert MustDistributeMoreThanZero(); + } + _checksAndTransfer(_total); + } +} diff --git a/contracts/staking/StakeHolderERC20V2.sol b/contracts/staking/StakeHolderERC20V2.sol new file mode 100644 index 00000000..fac0f35d --- /dev/null +++ b/contracts/staking/StakeHolderERC20V2.sol @@ -0,0 +1,75 @@ +// 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, StakeHolderBaseV2} from "./StakeHolderBaseV2.sol"; + +/** + * @title StakeHolderERC20V2: 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. + * @dev This contract is the same as StakeHolderERC20, with the exception that it derives from StakeHolderBaseV2. +*/ +contract StakeHolderERC20V2 is StakeHolderBaseV2 { + 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 index 6961c35d..37f83d6d 100644 --- a/contracts/staking/StakeHolderNative.sol +++ b/contracts/staking/StakeHolderNative.sol @@ -9,8 +9,6 @@ import {IStakeHolder, StakeHolderBase} from "./StakeHolderBase.sol"; * @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. diff --git a/contracts/staking/StakeHolderNativeV2.sol b/contracts/staking/StakeHolderNativeV2.sol new file mode 100644 index 00000000..ed1f41e4 --- /dev/null +++ b/contracts/staking/StakeHolderNativeV2.sol @@ -0,0 +1,66 @@ +// Copyright (c) Immutable Pty Ltd 2018 - 2025 +// SPDX-License-Identifier: Apache 2 +pragma solidity >=0.8.19 <0.8.29; + +import {IStakeHolder, StakeHolderBase, StakeHolderBaseV2} from "./StakeHolderBaseV2.sol"; + +/** + * @title StakeHolderNativeV2: 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. + * @dev This contract is the same as StakeHolderNative, with the exception that it derives from StakeHolderBaseV2. +*/ +contract StakeHolderNativeV2 is StakeHolderBaseV2 { + /** + * @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/StakeHolderWIMXV2.sol b/contracts/staking/StakeHolderWIMXV2.sol new file mode 100644 index 00000000..fef58139 --- /dev/null +++ b/contracts/staking/StakeHolderWIMXV2.sol @@ -0,0 +1,72 @@ +// Copyright (c) Immutable Pty Ltd 2018 - 2025 +// SPDX-License-Identifier: Apache 2 +pragma solidity >=0.8.19 <0.8.29; + +import {IStakeHolder, StakeHolderBase, StakeHolderBaseV2, StakeHolderNativeV2} from "./StakeHolderNativeV2.sol"; +import {IWIMX} from "./IWIMX.sol"; + +/** + * @title StakeHolderWIMXV2: 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. + * @dev The StakeHolderWIMX contract is designed to be upgradeable. + * @dev This contract is the same as StakeHolderWIMX, with the exception that it derives from StakeHolderBaseV2. + */ +contract StakeHolderWIMXV2 is StakeHolderNativeV2 { + /// @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/script/staking/README.md b/script/staking/README.md index f47dfa31..10b66f8b 100644 --- a/script/staking/README.md +++ b/script/staking/README.md @@ -20,17 +20,17 @@ The following variables must be specified for all scripts. They can be supplied ## Simple Deployment -To deploy the `StakeHolderERC20.sol` or the `StakeHolderWIMX.sol` contract with a `ERC1967Proxy.sol`, use the `deploySimple.sh` script. +To deploy the `StakeHolderERC20V2.sol` or the `StakeHolderWIMXV2.sol` contract with a `ERC1967Proxy.sol`, use the `deploySimple.sh` script. In addition to the common variables described above, 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` or `StakeHolderWIMX` and the `ERC1967Proxy` contracts. +* `DEPLOYER_ADDRESS`: Address that corresponds to the hardware wallet or private key. This account is used to deploy the `StakeHolderERC20V2` or `StakeHolderWIMXV2` 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. +* `UPGRADE_ADMIN`: Initial account that will be authorised to upgrade the StakeHolderERC20V2 contract. Specify 0x0000000000000000000000000000000000000000 to have no account with upgrade administrator access. ## Complex Deployment -To deploy the `StakeHolderERC20.sol` or the `StakeHolderWIMX.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. +To deploy the `StakeHolderERC20V2.sol` or the `StakeHolderWIMXV2.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. In addition to the common variables described above, the following variables must be specified via the environment or a `.env` file for the `deployDeployer.sh` script: @@ -52,3 +52,12 @@ The `stake.sh` script can be called to stake tokens and the `unstake.sh` script * `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. + +## Upgrading StakeHolderWIMX to StakeHolderWIMXV2 on MainNet + +The following scripts are used as part of the process for upgrading the staking contract used on Immutable zkEVM Mainnet. + +* `upgradeToWIMXV2_Deploy.sh`: Deploys `StakeHolderWIMXV2` using the `OwnableCreate3Deployer`. No additional environment variables need to be set to run this script. +* `upgradeToWIMXV2_Propose.sh`: Proposes the upgrade to the TimelockController contract. Once the `StakeHolderWIMXV2` contract has been deployed, update the value of the `STAKE_HOLDER_V2` constant in `StakeHolderScriptWIMX.t.sol` to reflect the deployed address. Temporarily +update `common.sh`, removing the `--broadcast` line. This will log the calldata, but not execute the transaction. Submit the calldata to Safe wallet. +* `upgradeToWIMXV2_Execute.sh`: Executes the upgrade using the TimelockController contract. Once one week has elapsed, the upgrade can be executed. Temporarily update `common.sh`, removing the `--broadcast` line. This will log the calldata, but not execute the transaction. Submit the calldata to Safe wallet. diff --git a/script/staking/StakeHolderScriptERC20.t.sol b/script/staking/StakeHolderScriptERC20.t.sol index b25f02c5..0f1df6d2 100644 --- a/script/staking/StakeHolderScriptERC20.t.sol +++ b/script/staking/StakeHolderScriptERC20.t.sol @@ -9,7 +9,7 @@ import {ERC20PresetFixedSupply} from "openzeppelin-contracts-4.9.3/token/ERC20/p 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 {StakeHolderERC20V2} from "../../contracts/staking/StakeHolderERC20V2.sol"; import {OwnableCreate3Deployer} from "../../contracts/deployer/create3/OwnableCreate3Deployer.sol"; /** @@ -77,7 +77,7 @@ contract StakeHolderScriptERC20 is Test { } /** - * Deploy StakeHolderERC20 using Create3, with the TimelockController. + * Deploy StakeHolderERC20V2 using Create3, with the TimelockController. */ function deployComplex() external { address signer = vm.envAddress("DEPLOYER_ADDRESS"); @@ -100,7 +100,7 @@ contract StakeHolderScriptERC20 is Test { } /** - * Deploy StakeHolderERC20 using an EOA. + * Deploy StakeHolderERC20V2 using an EOA. */ function deploySimple() external { address deployer = vm.envAddress("DEPLOYER_ADDRESS"); @@ -145,14 +145,14 @@ contract StakeHolderScriptERC20 is Test { } /** - * Deploy StakeHolderERC20 using Create3, with the TimelockController. + * Deploy StakeHolderERC20V2 using Create3, with the TimelockController. */ function _deployComplex( ComplexDeploymentArgs memory deploymentArgs, ComplexStakeHolderContractArgs memory stakeHolderArgs, ComplexTimelockContractArgs memory timelockArgs) private - returns (StakeHolderERC20 stakeHolderContract, TimelockController timelockController) + returns (StakeHolderERC20V2 stakeHolderContract, TimelockController timelockController) { IDeployer ownableCreate3 = IDeployer(deploymentArgs.factory); @@ -185,10 +185,10 @@ contract StakeHolderScriptERC20 is Test { } - // Deploy StakeHolderERC20 via the Ownable Create3 factory. + // Deploy StakeHolderERC20V2 via the Ownable Create3 factory. // Create deployment bytecode and encode constructor args deploymentBytecode = abi.encodePacked( - type(StakeHolderERC20).creationCode + type(StakeHolderERC20V2).creationCode ); /// @dev Deploy the contract via the Ownable CREATE3 factory vm.startBroadcast(deploymentArgs.signer); @@ -198,7 +198,7 @@ contract StakeHolderScriptERC20 is Test { // Deploy ERC1967Proxy via the Ownable Create3 factory. // Create init data for the ERC1967 Proxy bytes memory initData = abi.encodeWithSelector( - StakeHolderERC20.initialize.selector, + StakeHolderERC20V2.initialize.selector, timelockAddress, // roleAdmin timelockAddress, // upgradeAdmin stakeHolderArgs.distributeAdmin, @@ -214,34 +214,34 @@ contract StakeHolderScriptERC20 is Test { address stakeHolderContractAddress = ownableCreate3.deploy(deploymentBytecode, salt3); vm.stopBroadcast(); - stakeHolderContract = StakeHolderERC20(stakeHolderContractAddress); + stakeHolderContract = StakeHolderERC20V2(stakeHolderContractAddress); timelockController = TimelockController(payable(timelockAddress)); } /** - * Deploy StakeHolderERC20 using an EOA and no time lock. + * Deploy StakeHolderERC20V2 using an EOA and no time lock. */ function _deploySimple( SimpleDeploymentArgs memory deploymentArgs, SimpleStakeHolderContractArgs memory stakeHolderArgs) private - returns (StakeHolderERC20 stakeHolderContract) { + returns (StakeHolderERC20V2 stakeHolderContract) { bytes memory initData = abi.encodeWithSelector( - StakeHolderERC20.initialize.selector, + StakeHolderERC20V2.initialize.selector, stakeHolderArgs.roleAdmin, stakeHolderArgs.upgradeAdmin, stakeHolderArgs.distributeAdmin, stakeHolderArgs.token); vm.startBroadcast(deploymentArgs.deployer); - StakeHolderERC20 impl = new StakeHolderERC20(); + StakeHolderERC20V2 impl = new StakeHolderERC20V2(); vm.stopBroadcast(); vm.startBroadcast(deploymentArgs.deployer); ERC1967Proxy proxy = new ERC1967Proxy(address(impl), initData); vm.stopBroadcast(); - stakeHolderContract = StakeHolderERC20(address(proxy)); + stakeHolderContract = StakeHolderERC20V2(address(proxy)); } function _stake(IStakeHolder _stakeHolder, address _staker, uint256 _amount) private { @@ -306,7 +306,7 @@ contract StakeHolderScriptERC20 is Test { }); // Run deployment against forked testnet - StakeHolderERC20 stakeHolder; + StakeHolderERC20V2 stakeHolder; TimelockController timelockController; (stakeHolder, timelockController) = _deployComplex(deploymentArgs, stakeHolderArgs, timelockArgs); @@ -350,7 +350,7 @@ contract StakeHolderScriptERC20 is Test { }); // Run deployment against forked testnet - StakeHolderERC20 stakeHolder = _deploySimple(deploymentArgs, stakeHolderContractArgs); + StakeHolderERC20V2 stakeHolder = _deploySimple(deploymentArgs, stakeHolderContractArgs); _commonTest(false, IStakeHolder(stakeHolder), address(0), bank, deployer, roleAdmin, upgradeAdmin, distributeAdmin); @@ -374,7 +374,7 @@ contract StakeHolderScriptERC20 is Test { // Post deployment checks { - StakeHolderERC20 temp = new StakeHolderERC20(); + StakeHolderERC20V2 temp = new StakeHolderERC20V2(); 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"); diff --git a/script/staking/StakeHolderScriptWIMX.t.sol b/script/staking/StakeHolderScriptWIMX.t.sol index 1ee396f6..e1c61fb4 100644 --- a/script/staking/StakeHolderScriptWIMX.t.sol +++ b/script/staking/StakeHolderScriptWIMX.t.sol @@ -6,9 +6,11 @@ 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 {UUPSUpgradeable} from "openzeppelin-contracts-upgradeable-4.9.3/proxy/utils/UUPSUpgradeable.sol"; import {IStakeHolder} from "../../contracts/staking/IStakeHolder.sol"; -import {StakeHolderWIMX} from "../../contracts/staking/StakeHolderWIMX.sol"; +import {StakeHolderBase} from "../../contracts/staking/StakeHolderBase.sol"; +import {StakeHolderWIMXV2} from "../../contracts/staking/StakeHolderWIMXV2.sol"; import {WIMX} from "../../contracts/staking/WIMX.sol"; import {OwnableCreate3Deployer} from "../../contracts/deployer/create3/OwnableCreate3Deployer.sol"; @@ -77,7 +79,7 @@ contract StakeHolderScriptWIMX is Test { } /** - * Deploy StakeHolderWIMX using Create3, with the TimelockController. + * Deploy StakeHolderWIMXV2 using Create3, with the TimelockController. */ function deployComplex() external { address signer = vm.envAddress("DEPLOYER_ADDRESS"); @@ -100,7 +102,7 @@ contract StakeHolderScriptWIMX is Test { } /** - * Deploy StakeHolderWIMX using an EOA. + * Deploy StakeHolderWIMXV2 using an EOA. */ function deploySimple() external { address deployer = vm.envAddress("DEPLOYER_ADDRESS"); @@ -132,8 +134,6 @@ contract StakeHolderScriptWIMX is Test { _unstake(IStakeHolder(stakeHolder), staker, amount); } - - /** * Deploy the OwnableCreate3Deployer contract. Set the owner to the * contract deployer. @@ -145,14 +145,14 @@ contract StakeHolderScriptWIMX is Test { } /** - * Deploy StakeHolderWIMX using Create3, with the TimelockController. + * Deploy StakeHolderWIMXV2 using Create3, with the TimelockController. */ function _deployComplex( ComplexDeploymentArgs memory deploymentArgs, ComplexStakeHolderContractArgs memory stakeHolderArgs, ComplexTimelockContractArgs memory timelockArgs) private - returns (StakeHolderWIMX stakeHolderContract, TimelockController timelockController) + returns (StakeHolderWIMXV2 stakeHolderContract, TimelockController timelockController) { IDeployer ownableCreate3 = IDeployer(deploymentArgs.factory); @@ -185,10 +185,10 @@ contract StakeHolderScriptWIMX is Test { } - // Deploy StakeHolderWIMX via the Ownable Create3 factory. + // Deploy StakeHolderWIMXV2 via the Ownable Create3 factory. // Create deployment bytecode and encode constructor args deploymentBytecode = abi.encodePacked( - type(StakeHolderWIMX).creationCode + type(StakeHolderWIMXV2).creationCode ); /// @dev Deploy the contract via the Ownable CREATE3 factory vm.startBroadcast(deploymentArgs.signer); @@ -198,7 +198,7 @@ contract StakeHolderScriptWIMX is Test { // Deploy ERC1967Proxy via the Ownable Create3 factory. // Create init data for the ERC1967 Proxy bytes memory initData = abi.encodeWithSelector( - StakeHolderWIMX.initialize.selector, + StakeHolderWIMXV2.initialize.selector, timelockAddress, // roleAdmin timelockAddress, // upgradeAdmin stakeHolderArgs.distributeAdmin, @@ -214,34 +214,34 @@ contract StakeHolderScriptWIMX is Test { address stakeHolderContractAddress = ownableCreate3.deploy(deploymentBytecode, salt3); vm.stopBroadcast(); - stakeHolderContract = StakeHolderWIMX(payable(stakeHolderContractAddress)); + stakeHolderContract = StakeHolderWIMXV2(payable(stakeHolderContractAddress)); timelockController = TimelockController(payable(timelockAddress)); } /** - * Deploy StakeHolderWIMX using an EOA and no time lock. + * Deploy StakeHolderWIMXV2 using an EOA and no time lock. */ function _deploySimple( SimpleDeploymentArgs memory deploymentArgs, SimpleStakeHolderContractArgs memory stakeHolderArgs) private - returns (StakeHolderWIMX stakeHolderContract) { + returns (StakeHolderWIMXV2 stakeHolderContract) { bytes memory initData = abi.encodeWithSelector( - StakeHolderWIMX.initialize.selector, + StakeHolderWIMXV2.initialize.selector, stakeHolderArgs.roleAdmin, stakeHolderArgs.upgradeAdmin, stakeHolderArgs.distributeAdmin, stakeHolderArgs.token); vm.startBroadcast(deploymentArgs.deployer); - StakeHolderWIMX impl = new StakeHolderWIMX(); + StakeHolderWIMXV2 impl = new StakeHolderWIMXV2(); vm.stopBroadcast(); vm.startBroadcast(deploymentArgs.deployer); ERC1967Proxy proxy = new ERC1967Proxy(address(impl), initData); vm.stopBroadcast(); - stakeHolderContract = StakeHolderWIMX(payable(address(proxy))); + stakeHolderContract = StakeHolderWIMXV2(payable(address(proxy))); } function _stake(IStakeHolder _stakeHolder, address _staker, uint256 _amount) private { @@ -263,7 +263,6 @@ contract StakeHolderScriptWIMX is Test { vm.stopBroadcast(); } - function testComplex() external { /// @dev Fork the Immutable zkEVM testnet for this test string memory rpcURL = "https://rpc.testnet.immutable.com"; @@ -299,7 +298,7 @@ contract StakeHolderScriptWIMX is Test { }); // Run deployment against forked testnet - StakeHolderWIMX stakeHolder; + StakeHolderWIMXV2 stakeHolder; TimelockController timelockController; (stakeHolder, timelockController) = _deployComplex(deploymentArgs, stakeHolderArgs, timelockArgs); @@ -340,7 +339,7 @@ contract StakeHolderScriptWIMX is Test { }); // Run deployment against forked testnet - StakeHolderWIMX stakeHolder = _deploySimple(deploymentArgs, stakeHolderContractArgs); + StakeHolderWIMXV2 stakeHolder = _deploySimple(deploymentArgs, stakeHolderContractArgs); _commonTest(false, IStakeHolder(stakeHolder), address(0), deployer, roleAdmin, upgradeAdmin, distributeAdmin); @@ -363,7 +362,7 @@ contract StakeHolderScriptWIMX is Test { // Post deployment checks { - StakeHolderWIMX temp = new StakeHolderWIMX(); + StakeHolderWIMXV2 temp = new StakeHolderWIMXV2(); 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"); @@ -384,4 +383,142 @@ contract StakeHolderScriptWIMX is Test { assertEq(user1.balance, 97 ether, "User1 balance after unstake"); assertEq(erc20.balanceOf(address(_stakeHolder)), 3 ether, "StakeHolder balance after unstake"); } + + + // *********************** UPGRADE TO V2 *************************** + + string constant MAINNET_RPC_URL = "https://rpc.immutable.com/"; + address constant STAKE_HOLDER_PROXY = 0xb6c2aA8690C8Ab6AC380a0bb798Ab0debe5C4C38; + address constant TIMELOCK_CONTROLLER = 0x994a66607f947A47F33C2fA80e0470C03C30e289; + bytes32 constant PROPOSER_ROLE = 0xb09aa5aeb3702cfd50b6b62bc4532604938f21248a27a1d5ca736082b6819cc1; + bytes32 constant EXECUTOR_ROLE = 0xd8aa0f3194971a2a116679f7c2090f6939c8d4e01a2a8d7e41d55e5351469e63; + + // On mainnet Proposer and Execute are a GnosisSafeProxy. + address constant PROPOSER = 0xaA53161A1fD22b258c89bA76B4bA11019034612D; + address constant EXECUTOR = 0xaA53161A1fD22b258c89bA76B4bA11019034612D; + uint256 constant TIMELOCK_DELAY = 604800; + + address constant DEPLOYER_ADDRESS = 0xdDA0d9448Ebe3eA43aFecE5Fa6401F5795c19333; + address constant OWNABLE_CREATE3_FACTORY = 0x37a59A845Bb6eD2034098af8738fbFFB9D589610; + + address constant STAKE_HOLDER_V2 = address(0x123); + + TimelockController stakeHolderTimeDelay = TimelockController(payable(TIMELOCK_CONTROLLER)); + + + function deployV2() external { + address stakeHolderV2 = _deployV2(); + console.log("Deployed StakeHolderWIMXV2 to: %s", stakeHolderV2); + } + + function proposeUpgradeToV2() external { + _proposeUpgradeToV2(STAKE_HOLDER_V2); + } + + function executeUpgradeToV2() external { + _executeUpgradeToV2(STAKE_HOLDER_V2); + } + + function _deployV2() internal returns (address) { + bytes32 salt = bytes32(uint256(17)); + + IDeployer ownableCreate3 = IDeployer(OWNABLE_CREATE3_FACTORY); + + // Deploy StakeHolderWIMXV2 via the Ownable Create3 factory. + // Create deployment bytecode and encode constructor args + bytes memory deploymentBytecode = abi.encodePacked( + type(StakeHolderWIMXV2).creationCode + ); + /// @dev Deploy the contract via the Ownable CREATE3 factory + vm.startBroadcast(DEPLOYER_ADDRESS); + address stakeHolderImplAddress = ownableCreate3.deploy(deploymentBytecode, salt); + vm.stopBroadcast(); + return stakeHolderImplAddress; + } + + function _proposeUpgradeToV2(address _v2Impl) internal { + assertTrue(stakeHolderTimeDelay.hasRole(PROPOSER_ROLE, PROPOSER), "Proposer does not have proposer role"); + assertTrue(stakeHolderTimeDelay.hasRole(EXECUTOR_ROLE, EXECUTOR), "Executor does not have executor role"); + + (address target, uint256 value, bytes memory data, bytes32 predecessor, bytes32 salt) = + _getProposalParams(_v2Impl); + + vm.startBroadcast(PROPOSER); + stakeHolderTimeDelay.schedule(target, value, data, predecessor, salt, TIMELOCK_DELAY); + vm.stopBroadcast(); + } + + function _executeUpgradeToV2(address _v2Impl) internal { + stakeHolderTimeDelay = TimelockController(payable(TIMELOCK_CONTROLLER)); + assertTrue(stakeHolderTimeDelay.hasRole(EXECUTOR_ROLE, EXECUTOR), "Executor does not have executor role"); + + (address target, uint256 value, bytes memory data, bytes32 predecessor, bytes32 salt) = + _getProposalParams(_v2Impl); + + bytes32 id = stakeHolderTimeDelay.hashOperation(target, value, data, predecessor, salt); + assertTrue(stakeHolderTimeDelay.isOperationReady(id), "Operation is not yet ready"); + + vm.startBroadcast(EXECUTOR); + stakeHolderTimeDelay.execute(target, value, data, predecessor, salt); + vm.stopBroadcast(); + + IStakeHolder stakeHolder = IStakeHolder(STAKE_HOLDER_PROXY); + assertEq(stakeHolder.version(), 2, "Upgrade did not upgrade to version 2"); + } + + function _getProposalParams(address _v2Impl) private returns ( + address target, uint256 value, bytes memory data, bytes32 predecessor, bytes32 salt) { + + stakeHolderTimeDelay = TimelockController(payable(TIMELOCK_CONTROLLER)); + assertNotEq(_v2Impl, address(0), "StakeHolderV2 can not be address(0)"); + + bytes memory callData = abi.encodeWithSelector(StakeHolderBase.upgradeStorage.selector, bytes("")); + bytes memory upgradeCall = abi.encodeWithSelector( + UUPSUpgradeable.upgradeToAndCall.selector, _v2Impl, callData); + + target = STAKE_HOLDER_PROXY; + value = 0; + data = upgradeCall; + predecessor = bytes32(0); + salt = bytes32(uint256(1)); + } + + + // Test the remainder of the upgrade process. + function testRemainderOfUpgradeProcessToV2() public { + uint256 mainnetFork = vm.createFork(MAINNET_RPC_URL); + vm.selectFork(mainnetFork); + + IStakeHolder stakeHolder = IStakeHolder(STAKE_HOLDER_PROXY); + if (stakeHolder.version() != 0) { + // Upgrade has occurred. Nothing to test. + return; + } + + address stakeHolderV2 = STAKE_HOLDER_V2; + if (stakeHolderV2 == address(0)) { + // StakeHolderWIMXV2 has not been deployed yet. + stakeHolderV2 = _deployV2(); + } + + (address target, uint256 value, bytes memory data, bytes32 predecessor, bytes32 salt) = + _getProposalParams(stakeHolderV2); + bytes32 id = stakeHolderTimeDelay.hashOperation(target, value, data, predecessor, salt); + if (!stakeHolderTimeDelay.isOperation(id)) { + // The upgrade hasn't been proposed yet. + _proposeUpgradeToV2(stakeHolderV2); + } + + uint256 earliestExecuteTime = stakeHolderTimeDelay.getTimestamp(id); + uint256 time = earliestExecuteTime; + if (time < block.timestamp) { + time = block.timestamp; + } + vm.warp(time); + + uint256 numStakersBefore = stakeHolder.getNumStakers(); + _executeUpgradeToV2(stakeHolderV2); + uint256 numStakersAfter = stakeHolder.getNumStakers(); + assertEq(numStakersBefore, numStakersAfter, "Number of stakers before and after upgrade do not match"); + } } diff --git a/script/staking/common.sh b/script/staking/common.sh index 77c430e0..942f87ac 100644 --- a/script/staking/common.sh +++ b/script/staking/common.sh @@ -77,6 +77,7 @@ echo " Script to execute: $script" # NOTE WELL --------------------------------------------- # Add resume option if the script fails part way through: # --resume \ +# To record the transactions but not execute them, remove the --broadcast line. # NOTE WELL --------------------------------------------- if [ "${HARDWARE_WALLET}" = "ledger" ] || [ "${HARDWARE_WALLET}" = "trezor" ]; then forge script --rpc-url $IMMUTABLE_RPC \ diff --git a/script/staking/upgradeToWIMXV2_Deploy.sh b/script/staking/upgradeToWIMXV2_Deploy.sh new file mode 100644 index 00000000..b1323375 --- /dev/null +++ b/script/staking/upgradeToWIMXV2_Deploy.sh @@ -0,0 +1,7 @@ +#!/bin/bash +FUNCTION_TO_EXECUTE='deployV2()' +STAKEHOLDER_TYPE=WIMX +# Set-up variables and execute forge +source $(dirname "$0")/common.sh + + diff --git a/script/staking/upgradeToWIMXV2_Execute.sh b/script/staking/upgradeToWIMXV2_Execute.sh new file mode 100644 index 00000000..65bb2162 --- /dev/null +++ b/script/staking/upgradeToWIMXV2_Execute.sh @@ -0,0 +1,7 @@ +#!/bin/bash +FUNCTION_TO_EXECUTE='executeUpgradeToV2()' +STAKEHOLDER_TYPE=WIMX +# Set-up variables and execute forge +source $(dirname "$0")/common.sh + + diff --git a/script/staking/upgradeToWIMXV2_Propose.sh b/script/staking/upgradeToWIMXV2_Propose.sh new file mode 100644 index 00000000..b11d35e0 --- /dev/null +++ b/script/staking/upgradeToWIMXV2_Propose.sh @@ -0,0 +1,7 @@ +#!/bin/bash +FUNCTION_TO_EXECUTE='proposeUpgradeToV2()' +STAKEHOLDER_TYPE=WIMX +# Set-up variables and execute forge +source $(dirname "$0")/common.sh + + diff --git a/test/staking/StakeHolderAttackWallet.sol b/test/staking/StakeHolderAttackWallet.sol index 6180853d..4b4e25f0 100644 --- a/test/staking/StakeHolderAttackWallet.sol +++ b/test/staking/StakeHolderAttackWallet.sol @@ -1,4 +1,4 @@ -// Copyright Immutable Pty Ltd 2018 - 2024 +// Copyright Immutable Pty Ltd 2018 - 2025 // SPDX-License-Identifier: Apache 2.0 pragma solidity >=0.8.19 <0.8.29; diff --git a/test/staking/StakeHolderAttackWallet2.sol b/test/staking/StakeHolderAttackWallet2.sol new file mode 100644 index 00000000..2605e66c --- /dev/null +++ b/test/staking/StakeHolderAttackWallet2.sol @@ -0,0 +1,27 @@ +// Copyright Immutable Pty Ltd 2018 - 2025 +// SPDX-License-Identifier: Apache 2.0 +pragma solidity >=0.8.19 <0.8.29; + +import {StakeHolderNative} from "../../contracts/staking/StakeHolderNative.sol"; + +// Wallet designed to check for a native transfer during unstake that fails. +// Not really an attack as such. +contract StakeHolderAttackWallet2 { + StakeHolderNative public stakeHolder; + constructor(address _stakeHolder) { + stakeHolder = StakeHolderNative(_stakeHolder); + } + receive() external payable { + // Cause a revert that has zero call data length. The easiest way to do this is to + // call a function on an address that doesn't have a contract associated with it. + StakeHolderAttackWallet2 notARealContract = StakeHolderAttackWallet2(payable(address(0x12345))); + notARealContract.stake(0); + } + function stake(uint256 _amount) external { + 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 0fa8fb60..8794a98e 100644 --- a/test/staking/StakeHolderBase.t.sol +++ b/test/staking/StakeHolderBase.t.sol @@ -1,4 +1,4 @@ -// Copyright Immutable Pty Ltd 2018 - 2024 +// Copyright Immutable Pty Ltd 2018 - 2025 // SPDX-License-Identifier: Apache 2.0 // solhint-disable not-rely-on-time @@ -7,7 +7,16 @@ 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 {StakeHolderBaseV2} from "../../contracts/staking/StakeHolderBaseV2.sol"; +import {StakeHolderERC20} from "../../contracts/staking/StakeHolderERC20.sol"; +import {StakeHolderERC20V2} from "../../contracts/staking/StakeHolderERC20V2.sol"; import {StakeHolderNative} from "../../contracts/staking/StakeHolderNative.sol"; +import {StakeHolderNativeV2} from "../../contracts/staking/StakeHolderNativeV2.sol"; +import {StakeHolderWIMX} from "../../contracts/staking/StakeHolderWIMX.sol"; +import {StakeHolderWIMXV2} from "../../contracts/staking/StakeHolderWIMXV2.sol"; +import {WIMX} from "../../contracts/staking/WIMX.sol"; +import {ERC20PresetFixedSupply} from "openzeppelin-contracts-4.9.3/token/ERC20/presets/ERC20PresetFixedSupply.sol"; +import {ERC1967Proxy} from "openzeppelin-contracts-4.9.3/proxy/ERC1967/ERC1967Proxy.sol"; abstract contract StakeHolderBaseTest is Test { @@ -26,6 +35,10 @@ abstract contract StakeHolderBaseTest is Test { address public staker3; address public bank; + ERC20PresetFixedSupply erc20; + WIMX wimxErc20; + + function setUp() public virtual { roleAdmin = makeAddr("RoleAdmin"); upgradeAdmin = makeAddr("UpgradeAdmin"); @@ -41,4 +54,61 @@ abstract contract StakeHolderBaseTest is Test { upgradeRole = temp.UPGRADE_ROLE(); distributeRole = temp.DISTRIBUTE_ROLE(); } + + function deployERC20() internal { + erc20 = new ERC20PresetFixedSupply("Name", "SYM", 1000 ether, bank); + } + + function deployWIMX() internal { + wimxErc20 = new WIMX(); + erc20 = ERC20PresetFixedSupply(address(wimxErc20)); + } + + function deployStakeHolderNativeV1() internal { + 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 deployStakeHolderERC20V1() internal { + 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 deployStakeHolderWIMXV1() internal { + StakeHolderWIMX impl = new StakeHolderWIMX(); + bytes memory initData = abi.encodeWithSelector( + StakeHolderWIMX.initialize.selector, roleAdmin, upgradeAdmin, distributeAdmin, address(wimxErc20) + ); + ERC1967Proxy proxy = new ERC1967Proxy(address(impl), initData); + stakeHolder = IStakeHolder(address(proxy)); + } + + function upgradeToStakeHolderNativeV2() internal { + StakeHolderNativeV2 implV2 = new StakeHolderNativeV2(); + bytes memory upgradeData = abi.encodeWithSelector(StakeHolderBaseV2.upgradeStorage.selector, bytes("NotUsed")); + vm.prank(upgradeAdmin); + StakeHolderNativeV2(address(stakeHolder)).upgradeToAndCall(address(implV2), upgradeData); + } + + function upgradeToStakeHolderERC20V2() internal { + StakeHolderERC20V2 implV2 = new StakeHolderERC20V2(); + bytes memory upgradeData = abi.encodeWithSelector(StakeHolderBaseV2.upgradeStorage.selector, bytes("NotUsed")); + vm.prank(upgradeAdmin); + StakeHolderERC20V2(address(stakeHolder)).upgradeToAndCall(address(implV2), upgradeData); + } + + function upgradeToStakeHolderWIMXV2() internal { + StakeHolderWIMXV2 implV2 = new StakeHolderWIMXV2(); + bytes memory upgradeData = abi.encodeWithSelector(StakeHolderBaseV2.upgradeStorage.selector, bytes("NotUsed")); + vm.prank(upgradeAdmin); + StakeHolderWIMXV2(payable(address(stakeHolder))).upgradeToAndCall(address(implV2), upgradeData); + } } diff --git a/test/staking/StakeHolderConfigBase.t.sol b/test/staking/StakeHolderConfigBase.t.sol index 7cf5ef9a..0e9eaae4 100644 --- a/test/staking/StakeHolderConfigBase.t.sol +++ b/test/staking/StakeHolderConfigBase.t.sol @@ -1,4 +1,4 @@ -// Copyright Immutable Pty Ltd 2018 - 2024 +// Copyright Immutable Pty Ltd 2018 - 2025 // SPDX-License-Identifier: Apache 2.0 pragma solidity >=0.8.19 <0.8.29; @@ -10,17 +10,17 @@ import {StakeHolderBaseTest} from "./StakeHolderBase.t.sol"; abstract contract StakeHolderConfigBaseTest is StakeHolderBaseTest { - function testUpgradeToV1() public { + function testUpgradeToV2() public virtual { IStakeHolder v2Impl = _deployV2(); bytes memory initData = abi.encodeWithSelector(StakeHolderBase.upgradeStorage.selector, bytes("")); vm.prank(upgradeAdmin); StakeHolderBase(address(stakeHolder)).upgradeToAndCall(address(v2Impl), initData); uint256 ver = stakeHolder.version(); - assertEq(ver, 1, "Upgrade did not upgrade version"); + assertEq(ver, 2, "Upgrade did not upgrade version"); } - function testUpgradeToV0() public { + function testUpgradeToV1() public virtual { IStakeHolder v1Impl = _deployV1(); bytes memory initData = abi.encodeWithSelector(StakeHolderBase.upgradeStorage.selector, bytes("")); vm.expectRevert(abi.encodeWithSelector(IStakeHolder.CanNotUpgradeToLowerOrSameVersion.selector, 0)); @@ -28,8 +28,8 @@ abstract contract StakeHolderConfigBaseTest is StakeHolderBaseTest { StakeHolderBase(address(stakeHolder)).upgradeToAndCall(address(v1Impl), initData); } - function testDowngradeV1ToV0() public { - // Upgrade from V0 to V1 + function testDowngradeV2ToV1() public virtual { + // Upgrade from V0 to V2 IStakeHolder v2Impl = _deployV2(); bytes memory initData = abi.encodeWithSelector(StakeHolderBase.upgradeStorage.selector, bytes("")); vm.prank(upgradeAdmin); @@ -37,7 +37,7 @@ abstract contract StakeHolderConfigBaseTest is StakeHolderBaseTest { // Attempt to downgrade from V1 to V0. IStakeHolder v1Impl = _deployV1(); - vm.expectRevert(abi.encodeWithSelector(IStakeHolder.CanNotUpgradeToLowerOrSameVersion.selector, 1)); + vm.expectRevert(abi.encodeWithSelector(IStakeHolder.CanNotUpgradeToLowerOrSameVersion.selector, 2)); vm.prank(upgradeAdmin); StakeHolderBase(address(stakeHolder)).upgradeToAndCall(address(v1Impl), initData); } diff --git a/test/staking/StakeHolderConfigBaseV2.t.sol b/test/staking/StakeHolderConfigBaseV2.t.sol new file mode 100644 index 00000000..bcb9843c --- /dev/null +++ b/test/staking/StakeHolderConfigBaseV2.t.sol @@ -0,0 +1,57 @@ +// Copyright Immutable Pty Ltd 2018 - 2025 +// 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 {StakeHolderBase} from "../../contracts/staking/StakeHolderBase.sol"; +import {StakeHolderConfigBaseTest} from "./StakeHolderConfigBase.t.sol"; + + +abstract contract StakeHolderConfigBaseTestV2 is StakeHolderConfigBaseTest { + function testUpgradeToV2() override public { + IStakeHolder v2Impl = _deployV2(); + bytes memory initData = abi.encodeWithSelector(StakeHolderBase.upgradeStorage.selector, bytes("")); + vm.prank(upgradeAdmin); + vm.expectRevert(abi.encodeWithSelector(IStakeHolder.CanNotUpgradeToLowerOrSameVersion.selector, 2)); + StakeHolderBase(address(stakeHolder)).upgradeToAndCall(address(v2Impl), initData); + } + + function testUpgradeToV1() override public { + IStakeHolder v1Impl = _deployV1(); + bytes memory initData = abi.encodeWithSelector(StakeHolderBase.upgradeStorage.selector, bytes("")); + vm.expectRevert(abi.encodeWithSelector(IStakeHolder.CanNotUpgradeToLowerOrSameVersion.selector, 2)); + vm.prank(upgradeAdmin); + StakeHolderBase(address(stakeHolder)).upgradeToAndCall(address(v1Impl), initData); + } + + function testDowngradeV2ToV1() override public { + // This test doesn't make sense in the context of V2. + } + + function testUpgradeToV3() public { + IStakeHolder v3Impl = _deployV3(); + bytes memory initData = abi.encodeWithSelector(StakeHolderBase.upgradeStorage.selector, bytes("")); + vm.prank(upgradeAdmin); + StakeHolderBase(address(stakeHolder)).upgradeToAndCall(address(v3Impl), initData); + + assertEq(stakeHolder.version(), 3, "Wrong version"); + } + + function testDowngradeV3ToV2() public { + // Upgrade from V2 to V3 + IStakeHolder v3Impl = _deployV3(); + bytes memory initData = abi.encodeWithSelector(StakeHolderBase.upgradeStorage.selector, bytes("")); + vm.prank(upgradeAdmin); + StakeHolderBase(address(stakeHolder)).upgradeToAndCall(address(v3Impl), initData); + + // Attempt to downgrade from V3 to V2. + IStakeHolder v2Impl = _deployV2(); + vm.expectRevert(abi.encodeWithSelector(IStakeHolder.CanNotUpgradeToLowerOrSameVersion.selector, 3)); + vm.prank(upgradeAdmin); + StakeHolderBase(address(stakeHolder)).upgradeToAndCall(address(v2Impl), initData); + } + + function _deployV3() internal virtual returns(IStakeHolder); +} diff --git a/test/staking/StakeHolderConfigERC20.t.sol b/test/staking/StakeHolderConfigERC20.t.sol index 95ab41a1..06afa165 100644 --- a/test/staking/StakeHolderConfigERC20.t.sol +++ b/test/staking/StakeHolderConfigERC20.t.sol @@ -1,4 +1,4 @@ -// Copyright Immutable Pty Ltd 2018 - 2024 +// Copyright Immutable Pty Ltd 2018 - 2025 // SPDX-License-Identifier: Apache 2.0 pragma solidity >=0.8.19 <0.8.29; @@ -8,11 +8,10 @@ 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 { +contract StakeHolderERC20V2a is StakeHolderERC20 { function upgradeStorage(bytes memory /* _data */) external override(StakeHolderBase) { - version = 1; + version = 2; } } @@ -20,15 +19,8 @@ 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)); + deployERC20(); + deployStakeHolderERC20V1(); } function _deployV1() internal override returns(IStakeHolder) { @@ -36,6 +28,6 @@ contract StakeHolderConfigERC20Test is StakeHolderConfigBaseTest { } function _deployV2() internal override returns(IStakeHolder) { - return IStakeHolder(address(new StakeHolderERC20V2())); + return IStakeHolder(address(new StakeHolderERC20V2a())); } } \ No newline at end of file diff --git a/test/staking/StakeHolderConfigERC20V2.t.sol b/test/staking/StakeHolderConfigERC20V2.t.sol new file mode 100644 index 00000000..780bd82e --- /dev/null +++ b/test/staking/StakeHolderConfigERC20V2.t.sol @@ -0,0 +1,53 @@ +// Copyright Immutable Pty Ltd 2018 - 2025 +// 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 {StakeHolderERC20V2} from "../../contracts/staking/StakeHolderERC20V2.sol"; +import {IStakeHolder} from "../../contracts/staking/IStakeHolder.sol"; +import {StakeHolderBase} from "../../contracts/staking/StakeHolderBase.sol"; +import {StakeHolderBaseV2} from "../../contracts/staking/StakeHolderBaseV2.sol"; +import {StakeHolderConfigBaseTestV2} from "./StakeHolderConfigBaseV2.t.sol"; +import {ERC1967Proxy} from "openzeppelin-contracts-4.9.3/proxy/ERC1967/ERC1967Proxy.sol"; + +contract StakeHolderERC20V3a is StakeHolderERC20V2 { + function upgradeStorage(bytes memory /* _data */) external override(StakeHolderBaseV2) { + version = 3; + } +} + +contract StakeHolderConfigERC20TestV2 is StakeHolderConfigBaseTestV2 { + + function setUp() public override { + super.setUp(); + deployERC20(); + deployStakeHolderERC20V1(); + upgradeToStakeHolderERC20V2(); + } + + function testDeployStakeHolderERC20V2() public { + // Check that V2 can be installed from scratch: that is, without upgrading form V1. + StakeHolderERC20V2 impl = new StakeHolderERC20V2(); + bytes memory initData = abi.encodeWithSelector( + StakeHolderERC20V2.initialize.selector, roleAdmin, upgradeAdmin, distributeAdmin, erc20 + ); + ERC1967Proxy proxy = new ERC1967Proxy(address(impl), initData); + IStakeHolder stakeHolderV2 = IStakeHolder(address(proxy)); + + assertEq(stakeHolderV2.version(), 2, "Incorrect version"); + } + + function _deployV1() internal override returns(IStakeHolder) { + return IStakeHolder(address(new StakeHolderERC20())); + } + + function _deployV2() internal override returns(IStakeHolder) { + return IStakeHolder(address(new StakeHolderERC20V2())); + } + + function _deployV3() internal override returns(IStakeHolder) { + return IStakeHolder(address(new StakeHolderERC20V3a())); + } +} \ No newline at end of file diff --git a/test/staking/StakeHolderConfigNative.t.sol b/test/staking/StakeHolderConfigNative.t.sol index 33873b14..8feaa4f5 100644 --- a/test/staking/StakeHolderConfigNative.t.sol +++ b/test/staking/StakeHolderConfigNative.t.sol @@ -1,4 +1,4 @@ -// Copyright Immutable Pty Ltd 2018 - 2024 +// Copyright Immutable Pty Ltd 2018 - 2025 // SPDX-License-Identifier: Apache 2.0 pragma solidity >=0.8.19 <0.8.29; @@ -10,9 +10,9 @@ 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 { +contract StakeHolderNativeV2a is StakeHolderNative { function upgradeStorage(bytes memory /* _data */) external override(StakeHolderBase) { - version = 1; + version = 2; } } @@ -21,15 +21,7 @@ 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)); + deployStakeHolderNativeV1(); } function _deployV1() internal override returns(IStakeHolder) { @@ -37,7 +29,7 @@ contract StakeHolderConfigNativeTest is StakeHolderConfigBaseTest { } function _deployV2() internal override returns(IStakeHolder) { - return IStakeHolder(address(new StakeHolderNativeV2())); + return IStakeHolder(address(new StakeHolderNativeV2a())); } } diff --git a/test/staking/StakeHolderConfigNativeV2.t.sol b/test/staking/StakeHolderConfigNativeV2.t.sol new file mode 100644 index 00000000..fea533bf --- /dev/null +++ b/test/staking/StakeHolderConfigNativeV2.t.sol @@ -0,0 +1,52 @@ +// Copyright Immutable Pty Ltd 2018 - 2025 +// 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 {StakeHolderNativeV2} from "../../contracts/staking/StakeHolderNativeV2.sol"; +import {StakeHolderBaseV2} from "../../contracts/staking/StakeHolderBaseV2.sol"; +import {IStakeHolder} from "../../contracts/staking/IStakeHolder.sol"; +import {StakeHolderConfigBaseTestV2} from "./StakeHolderConfigBaseV2.t.sol"; +import {ERC1967Proxy} from "openzeppelin-contracts-4.9.3/proxy/ERC1967/ERC1967Proxy.sol"; + +contract StakeHolderNativeV3a is StakeHolderNativeV2 { + function upgradeStorage(bytes memory /* _data */) external override(StakeHolderBaseV2) { + version = 3; + } +} + + +contract StakeHolderConfigNativeTestV2 is StakeHolderConfigBaseTestV2 { + + function setUp() public override { + super.setUp(); + deployStakeHolderNativeV1(); + upgradeToStakeHolderNativeV2(); + } + + function testDeployStakeHolderNativeV2() public { + // Check that V2 can be installed from scratch: that is, without upgrading form V1. + StakeHolderNativeV2 impl = new StakeHolderNativeV2(); + bytes memory initData = abi.encodeWithSelector( + StakeHolderNativeV2.initialize.selector, roleAdmin, upgradeAdmin, distributeAdmin + ); + ERC1967Proxy proxy = new ERC1967Proxy(address(impl), initData); + IStakeHolder stakeHolderV2 = IStakeHolder(address(proxy)); + + assertEq(stakeHolderV2.version(), 2, "Incorrect version"); + } + + function _deployV1() internal override returns(IStakeHolder) { + return IStakeHolder(address(new StakeHolderNative())); + } + + function _deployV2() internal override returns(IStakeHolder) { + return IStakeHolder(address(new StakeHolderNativeV2())); + } + + function _deployV3() internal override returns(IStakeHolder) { + return IStakeHolder(address(new StakeHolderNativeV3a())); + } +} diff --git a/test/staking/StakeHolderConfigWIMX.t.sol b/test/staking/StakeHolderConfigWIMX.t.sol index fd2e457e..63da18fe 100644 --- a/test/staking/StakeHolderConfigWIMX.t.sol +++ b/test/staking/StakeHolderConfigWIMX.t.sol @@ -1,19 +1,17 @@ -// Copyright Immutable Pty Ltd 2018 - 2024 +// Copyright Immutable Pty Ltd 2018 - 2025 // 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 { +contract StakeHolderWIMXV2a is StakeHolderWIMX { function upgradeStorage(bytes memory /* _data */) external override(StakeHolderBase) { - version = 1; + version = 2; } } @@ -21,15 +19,8 @@ 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)); + deployWIMX(); + deployStakeHolderWIMXV1(); } function _deployV1() internal override returns(IStakeHolder) { @@ -37,6 +28,6 @@ contract StakeHolderConfigWIMXTest is StakeHolderConfigBaseTest { } function _deployV2() internal override returns(IStakeHolder) { - return IStakeHolder(address(new StakeHolderWIMXV2())); + return IStakeHolder(address(new StakeHolderWIMXV2a())); } } \ No newline at end of file diff --git a/test/staking/StakeHolderConfigWIMXV2.t.sol b/test/staking/StakeHolderConfigWIMXV2.t.sol new file mode 100644 index 00000000..dbec1411 --- /dev/null +++ b/test/staking/StakeHolderConfigWIMXV2.t.sol @@ -0,0 +1,53 @@ +// Copyright Immutable Pty Ltd 2018 - 2025 +// 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 {StakeHolderWIMXV2} from "../../contracts/staking/StakeHolderWIMXV2.sol"; +import {WIMX} from "../../contracts/staking/WIMX.sol"; +import {IStakeHolder} from "../../contracts/staking/IStakeHolder.sol"; +import {StakeHolderBaseV2} from "../../contracts/staking/StakeHolderBaseV2.sol"; +import {StakeHolderConfigBaseTestV2} from "./StakeHolderConfigBaseV2.t.sol"; +import {ERC1967Proxy} from "openzeppelin-contracts-4.9.3/proxy/ERC1967/ERC1967Proxy.sol"; + +contract StakeHolderWIMXV3a is StakeHolderWIMXV2 { + function upgradeStorage(bytes memory /* _data */) external override(StakeHolderBaseV2) { + version = 3; + } +} + +contract StakeHolderConfigWIMXTestV2 is StakeHolderConfigBaseTestV2 { + + function setUp() public override { + super.setUp(); + deployWIMX(); + deployStakeHolderWIMXV1(); + upgradeToStakeHolderWIMXV2(); + } + + function testDeployStakeHolderWIMXV2() public { + // Check that V2 can be installed from scratch: that is, without upgrading form V1. + StakeHolderWIMXV2 impl = new StakeHolderWIMXV2(); + bytes memory initData = abi.encodeWithSelector( + StakeHolderWIMXV2.initialize.selector, roleAdmin, upgradeAdmin, distributeAdmin, wimxErc20 + ); + ERC1967Proxy proxy = new ERC1967Proxy(address(impl), initData); + IStakeHolder stakeHolderV2 = IStakeHolder(address(proxy)); + + assertEq(stakeHolderV2.version(), 2, "Incorrect version"); + } + + function _deployV1() internal override returns(IStakeHolder) { + return IStakeHolder(address(new StakeHolderWIMX())); + } + + function _deployV2() internal override returns(IStakeHolder) { + return IStakeHolder(address(new StakeHolderWIMXV2())); + } + + function _deployV3() internal override returns(IStakeHolder) { + return IStakeHolder(address(new StakeHolderWIMXV3a())); + } +} \ No newline at end of file diff --git a/test/staking/StakeHolderInitBase.t.sol b/test/staking/StakeHolderInitBase.t.sol index 2da06e66..4ca9bf44 100644 --- a/test/staking/StakeHolderInitBase.t.sol +++ b/test/staking/StakeHolderInitBase.t.sol @@ -7,7 +7,7 @@ import "forge-std/Test.sol"; import {StakeHolderBaseTest} from "./StakeHolderBase.t.sol"; abstract contract StakeHolderInitBaseTest is StakeHolderBaseTest { - function testGetVersion() public { + function testGetVersion() public virtual { uint256 ver = stakeHolder.version(); assertEq(ver, 0, "Expect initial version of storage layout to be V0"); } @@ -16,6 +16,10 @@ abstract contract StakeHolderInitBaseTest is StakeHolderBaseTest { assertEq(stakeHolder.getNumStakers(), 0, "Expect no stakers at deployment time"); } + function testGetToken() public { + assertEq(stakeHolder.getToken(), address(erc20), "Incorrect token address returned"); + } + function testAdmins() public { assertEq(stakeHolder.getRoleMemberCount(defaultAdminRole), 1, "Expect one role admin"); assertEq(stakeHolder.getRoleMemberCount(upgradeRole), 1, "Expect one upgrade admin"); diff --git a/test/staking/StakeHolderInitBaseV2.t.sol b/test/staking/StakeHolderInitBaseV2.t.sol new file mode 100644 index 00000000..32e06b55 --- /dev/null +++ b/test/staking/StakeHolderInitBaseV2.t.sol @@ -0,0 +1,14 @@ +// 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 {StakeHolderInitBaseTest} from "./StakeHolderInitBase.t.sol"; + +abstract contract StakeHolderInitBaseTestV2 is StakeHolderInitBaseTest { + function testGetVersion() public override { + uint256 ver = stakeHolder.version(); + assertEq(ver, 2, "Incorrect version"); + } +} diff --git a/test/staking/StakeHolderInitERC20.t.sol b/test/staking/StakeHolderInitERC20.t.sol index 8b99a8df..33cb7c8b 100644 --- a/test/staking/StakeHolderInitERC20.t.sol +++ b/test/staking/StakeHolderInitERC20.t.sol @@ -4,24 +4,12 @@ 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)); + deployStakeHolderERC20V1(); } } diff --git a/test/staking/StakeHolderInitERC20V2.t.sol b/test/staking/StakeHolderInitERC20V2.t.sol new file mode 100644 index 00000000..a458c9fd --- /dev/null +++ b/test/staking/StakeHolderInitERC20V2.t.sol @@ -0,0 +1,16 @@ +// 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 {StakeHolderInitBaseTestV2} from "./StakeHolderInitBaseV2.t.sol"; + +contract StakeHolderInitERC20TestV2 is StakeHolderInitBaseTestV2 { + + function setUp() public override { + super.setUp(); + deployStakeHolderERC20V1(); + upgradeToStakeHolderERC20V2(); + } +} diff --git a/test/staking/StakeHolderInitNative.t.sol b/test/staking/StakeHolderInitNative.t.sol index 1543da45..51cebb83 100644 --- a/test/staking/StakeHolderInitNative.t.sol +++ b/test/staking/StakeHolderInitNative.t.sol @@ -4,24 +4,12 @@ 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)); + deployStakeHolderNativeV1(); } } diff --git a/test/staking/StakeHolderInitNativeV2.t.sol b/test/staking/StakeHolderInitNativeV2.t.sol new file mode 100644 index 00000000..f0aa22ba --- /dev/null +++ b/test/staking/StakeHolderInitNativeV2.t.sol @@ -0,0 +1,16 @@ +// 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 {StakeHolderInitBaseTestV2} from "./StakeHolderInitBaseV2.t.sol"; + +contract StakeHolderInitNativeTestV2 is StakeHolderInitBaseTestV2 { + + function setUp() public override { + super.setUp(); + deployStakeHolderNativeV1(); + upgradeToStakeHolderNativeV2(); + } +} diff --git a/test/staking/StakeHolderInitWIMX.t.sol b/test/staking/StakeHolderInitWIMX.t.sol index 914418b2..cbf7f4d1 100644 --- a/test/staking/StakeHolderInitWIMX.t.sol +++ b/test/staking/StakeHolderInitWIMX.t.sol @@ -4,25 +4,13 @@ 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)); + deployWIMX(); + deployStakeHolderWIMXV1(); } } diff --git a/test/staking/StakeHolderInitWIMXV2.t.sol b/test/staking/StakeHolderInitWIMXV2.t.sol new file mode 100644 index 00000000..ba2e4874 --- /dev/null +++ b/test/staking/StakeHolderInitWIMXV2.t.sol @@ -0,0 +1,17 @@ +// 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 {StakeHolderInitBaseTestV2} from "./StakeHolderInitBaseV2.t.sol"; + +contract StakeHolderInitWIMXTestV2 is StakeHolderInitBaseTestV2 { + + function setUp() public override { + super.setUp(); + deployWIMX(); + deployStakeHolderWIMXV1(); + upgradeToStakeHolderWIMXV2(); + } +} diff --git a/test/staking/StakeHolderOperationalBase.t.sol b/test/staking/StakeHolderOperationalBase.t.sol index 931194c7..b28c4792 100644 --- a/test/staking/StakeHolderOperationalBase.t.sol +++ b/test/staking/StakeHolderOperationalBase.t.sol @@ -1,4 +1,4 @@ -// Copyright Immutable Pty Ltd 2018 - 2024 +// Copyright Immutable Pty Ltd 2018 - 2025 // SPDX-License-Identifier: Apache 2.0 pragma solidity >=0.8.19 <0.8.29; diff --git a/test/staking/StakeHolderOperationalBaseV2.t.sol b/test/staking/StakeHolderOperationalBaseV2.t.sol new file mode 100644 index 00000000..9408ba70 --- /dev/null +++ b/test/staking/StakeHolderOperationalBaseV2.t.sol @@ -0,0 +1,118 @@ +// Copyright Immutable Pty Ltd 2018 - 2025 +// 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 {StakeHolderOperationalBaseTest} from "./StakeHolderOperationalBase.t.sol"; + +abstract contract StakeHolderOperationalBaseTestV2 is StakeHolderOperationalBaseTest { + function testStakeForOne() 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); + + // Stake for staker2 only. + IStakeHolder.AccountAmount[] memory accountsAmounts = new IStakeHolder.AccountAmount[](1); + accountsAmounts[0] = IStakeHolder.AccountAmount(staker2, 0.5 ether); + _stakeFor(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 testStakeForMultiple() 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); + + // Stake for 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); + _stakeFor(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 testStakeForZeroReward() public { + _deal(staker1, 100 ether); + _deal(distributeAdmin, 100 ether); + + _addStake(staker1, 10 ether); + + // Stake for of 0 to staker1. + IStakeHolder.AccountAmount[] memory accountsAmounts = new IStakeHolder.AccountAmount[](1); + accountsAmounts[0] = IStakeHolder.AccountAmount(staker1, 0 ether); + _stakeFor(distributeAdmin, 0 ether, accountsAmounts, + abi.encodeWithSelector(IStakeHolder.MustDistributeMoreThanZero.selector)); + } + + function testStakeForToEmptyAccount() public { + _deal(staker1, 100 ether); + _deal(distributeAdmin, 100 ether); + + uint256 amount = 10 ether; + _addStake(staker1, amount); + vm.prank(staker1); + stakeHolder.unstake(amount); + + // Stake for to staker1 only. + IStakeHolder.AccountAmount[] memory accountsAmounts = new IStakeHolder.AccountAmount[](1); + accountsAmounts[0] = IStakeHolder.AccountAmount(staker1, 0.5 ether); + _stakeFor(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 testStakeForToUnusedAccount() public { + _deal(distributeAdmin, 100 ether); + + // Stake for to staker1 only. + IStakeHolder.AccountAmount[] memory accountsAmounts = new IStakeHolder.AccountAmount[](1); + accountsAmounts[0] = IStakeHolder.AccountAmount(staker1, 0.5 ether); + _stakeFor(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 testStakeForBadAuth() 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); + _stakeFor(bank, 0.5 ether, accountsAmounts, + abi.encodePacked("AccessControl: account 0x3448fc79c22032be61bee8d832ebc59744f5cc40 is missing role 0x444953545249425554455f524f4c450000000000000000000000000000000000")); + } + + function _stakeFor(address _distributor, uint256 _total, IStakeHolder.AccountAmount[] memory _accountAmounts) internal { + _stakeFor(_distributor, _total, _accountAmounts, false, bytes("")); + } + function _stakeFor(address _distributor, uint256 _total, IStakeHolder.AccountAmount[] memory _accountAmounts, bytes memory _error) internal { + _stakeFor(_distributor, _total, _accountAmounts, true, _error); + } + function _stakeFor(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 index a91f9ebb..94d5ead4 100644 --- a/test/staking/StakeHolderOperationalERC20.t.sol +++ b/test/staking/StakeHolderOperationalERC20.t.sol @@ -1,4 +1,4 @@ -// Copyright Immutable Pty Ltd 2018 - 2024 +// Copyright Immutable Pty Ltd 2018 - 2025 // SPDX-License-Identifier: Apache 2.0 pragma solidity >=0.8.19 <0.8.29; @@ -8,25 +8,12 @@ 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 { + function setUp() public virtual 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)); + deployERC20(); + deployStakeHolderERC20V1(); } diff --git a/test/staking/StakeHolderOperationalERC20V2.t.sol b/test/staking/StakeHolderOperationalERC20V2.t.sol new file mode 100644 index 00000000..0d174299 --- /dev/null +++ b/test/staking/StakeHolderOperationalERC20V2.t.sol @@ -0,0 +1,30 @@ +// Copyright Immutable Pty Ltd 2018 - 2025 +// 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 {IStakeHolderV2} from "../../contracts/staking/IStakeHolderV2.sol"; +import {StakeHolderBaseTest} from "./StakeHolderBase.t.sol"; +import {StakeHolderOperationalBaseTestV2} from "./StakeHolderOperationalBaseV2.t.sol"; +import {StakeHolderOperationalERC20Test} from "./StakeHolderOperationalERC20.t.sol"; + +contract StakeHolderOperationalERC20TestV2 is StakeHolderOperationalERC20Test, StakeHolderOperationalBaseTestV2 { + function setUp() public override (StakeHolderOperationalERC20Test, StakeHolderBaseTest) { + StakeHolderOperationalERC20Test.setUp(); + upgradeToStakeHolderERC20V2(); + } + + function _stakeFor(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); + IStakeHolderV2(address(stakeHolder)).stakeFor(_accountAmounts); + } +} diff --git a/test/staking/StakeHolderOperationalNative.t.sol b/test/staking/StakeHolderOperationalNative.t.sol index 0d599f54..f43f2430 100644 --- a/test/staking/StakeHolderOperationalNative.t.sol +++ b/test/staking/StakeHolderOperationalNative.t.sol @@ -1,4 +1,4 @@ -// Copyright Immutable Pty Ltd 2018 - 2024 +// Copyright Immutable Pty Ltd 2018 - 2025 // SPDX-License-Identifier: Apache 2.0 pragma solidity >=0.8.19 <0.8.29; @@ -9,20 +9,13 @@ 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"; +import {StakeHolderAttackWallet2} from "./StakeHolderAttackWallet2.sol"; contract StakeHolderOperationalNativeTest is StakeHolderOperationalBaseTest { - function setUp() public override { + function setUp() public virtual 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)); + deployStakeHolderNativeV1(); } function testUnstakeReentrantAttack() public { @@ -36,6 +29,16 @@ contract StakeHolderOperationalNativeTest is StakeHolderOperationalBaseTest { attacker.unstake{gas: 10000000}(6 ether); } + function testWillFailToAcceptTransferDuringUnstake() public { + StakeHolderAttackWallet2 attacker = new StakeHolderAttackWallet2(address(stakeHolder)); + _deal(address(attacker), 100 ether); + + attacker.stake(10 ether); + + vm.expectRevert(abi.encodeWithSelector(IStakeHolder.UnstakeTransferFailed.selector)); + attacker.unstake{gas: 10000000}(6 ether); + } + function testDistributeMismatch() public { _deal(staker1, 100 ether); _deal(staker2, 100 ether); diff --git a/test/staking/StakeHolderOperationalNativeV2.t.sol b/test/staking/StakeHolderOperationalNativeV2.t.sol new file mode 100644 index 00000000..a516587e --- /dev/null +++ b/test/staking/StakeHolderOperationalNativeV2.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 {StakeHolderNative} from "../../contracts/staking/StakeHolderNative.sol"; +import {IStakeHolder} from "../../contracts/staking/IStakeHolder.sol"; +import {IStakeHolderV2} from "../../contracts/staking/IStakeHolderV2.sol"; +import {StakeHolderBaseTest} from "./StakeHolderBase.t.sol"; +import {StakeHolderOperationalBaseTestV2} from "./StakeHolderOperationalBaseV2.t.sol"; +import {StakeHolderOperationalNativeTest} from "./StakeHolderOperationalNative.t.sol"; + +contract StakeHolderOperationalNativeTestV2 is StakeHolderOperationalNativeTest, StakeHolderOperationalBaseTestV2 { + function setUp() public override (StakeHolderOperationalNativeTest, StakeHolderBaseTest) { + StakeHolderOperationalNativeTest.setUp(); + upgradeToStakeHolderNativeV2(); + } + + function _stakeFor(address _distributor, uint256 _total, IStakeHolder.AccountAmount[] memory _accountAmounts, + bool _hasError, bytes memory _error) internal override { + if (_hasError) { + vm.expectRevert(_error); + } + vm.prank(_distributor); + IStakeHolderV2(address(stakeHolder)).stakeFor{value: _total}(_accountAmounts); + } +} diff --git a/test/staking/StakeHolderOperationalWIMX.t.sol b/test/staking/StakeHolderOperationalWIMX.t.sol index df8f5e10..6afd3843 100644 --- a/test/staking/StakeHolderOperationalWIMX.t.sol +++ b/test/staking/StakeHolderOperationalWIMX.t.sol @@ -1,4 +1,4 @@ -// Copyright Immutable Pty Ltd 2018 - 2024 +// Copyright Immutable Pty Ltd 2018 - 2025 // SPDX-License-Identifier: Apache 2.0 pragma solidity >=0.8.19 <0.8.29; @@ -9,25 +9,12 @@ 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 { + function setUp() public virtual 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)); + deployWIMX(); + deployStakeHolderWIMXV1(); } @@ -94,6 +81,6 @@ contract StakeHolderOperationalWIMXTest is StakeHolderOperationalBaseTest { return _staker.balance; } function _getBalanceStakeHolderContract() internal view override returns (uint256) { - return erc20.balanceOf(address(stakeHolder)); + return wimxErc20.balanceOf(address(stakeHolder)); } } diff --git a/test/staking/StakeHolderOperationalWIMXV2.t.sol b/test/staking/StakeHolderOperationalWIMXV2.t.sol new file mode 100644 index 00000000..912875fa --- /dev/null +++ b/test/staking/StakeHolderOperationalWIMXV2.t.sol @@ -0,0 +1,28 @@ +// Copyright Immutable Pty Ltd 2018 - 2025 +// 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 {IStakeHolderV2} from "../../contracts/staking/IStakeHolderV2.sol"; +import {StakeHolderBaseTest} from "./StakeHolderBase.t.sol"; +import {StakeHolderOperationalBaseTestV2} from "./StakeHolderOperationalBaseV2.t.sol"; +import {StakeHolderOperationalWIMXTest} from "./StakeHolderOperationalWIMX.t.sol"; + +contract StakeHolderOperationalWIMXTestV2 is StakeHolderOperationalWIMXTest, StakeHolderOperationalBaseTestV2 { + function setUp() public override (StakeHolderOperationalWIMXTest, StakeHolderBaseTest) { + StakeHolderOperationalWIMXTest.setUp(); + upgradeToStakeHolderWIMXV2(); + } + + function _stakeFor(address _distributor, uint256 _total, IStakeHolder.AccountAmount[] memory _accountAmounts, + bool _hasError, bytes memory _error) internal override { + if (_hasError) { + vm.expectRevert(_error); + } + vm.prank(_distributor); + IStakeHolderV2(address(stakeHolder)).stakeFor{value: _total}(_accountAmounts); + } +} diff --git a/test/staking/StakeHolderTimeDelayBase.t.sol b/test/staking/StakeHolderTimeDelayBase.t.sol index 1750616d..3e89ac91 100644 --- a/test/staking/StakeHolderTimeDelayBase.t.sol +++ b/test/staking/StakeHolderTimeDelayBase.t.sol @@ -1,4 +1,4 @@ -// Copyright Immutable Pty Ltd 2018 - 2024 +// Copyright Immutable Pty Ltd 2018 - 2025 // SPDX-License-Identifier: Apache 2.0 pragma solidity >=0.8.19 <0.8.29; @@ -38,12 +38,12 @@ abstract contract StakeHolderTimeDelayBaseTest is StakeHolderBaseTest { assertEq(stakeHolderTimeDelay.getMinDelay(), delay, "Incorrect time delay"); } - function testUpgrade() public { + function testUpgradeToV2() public { IStakeHolder v2Impl = _deployV2(); - bytes memory initData = abi.encodeWithSelector(StakeHolderBase.upgradeStorage.selector, bytes("")); + bytes memory callData = abi.encodeWithSelector(StakeHolderBase.upgradeStorage.selector, bytes("")); bytes memory upgradeCall = abi.encodeWithSelector( - UUPSUpgradeable.upgradeToAndCall.selector, address(v2Impl), initData); + UUPSUpgradeable.upgradeToAndCall.selector, address(v2Impl), callData); address target = address(stakeHolder); uint256 value = 0; @@ -64,9 +64,41 @@ abstract contract StakeHolderTimeDelayBaseTest is StakeHolderBaseTest { stakeHolderTimeDelay.execute(target, value, data, predecessor, salt); uint256 ver = stakeHolder.version(); - assertEq(ver, 1, "Upgrade did not upgrade version"); + assertEq(ver, 2, "Upgrade did not upgrade version 2"); + } + + function testUpgradeToV3() public { + testUpgradeToV2(); + + IStakeHolder v3Impl = _deployV3(); + + bytes memory callData = abi.encodeWithSelector(StakeHolderBase.upgradeStorage.selector, bytes("")); + bytes memory upgradeCall = abi.encodeWithSelector( + UUPSUpgradeable.upgradeToAndCall.selector, address(v3Impl), callData); + + address target = address(stakeHolder); + uint256 value = 0; + bytes memory data = upgradeCall; + bytes32 predecessor = bytes32(0); + bytes32 salt = bytes32(uint256(2)); + 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, 3, "Upgrade did not upgrade version 3"); } + function testTooShortDelay() public { IStakeHolder v2Impl = _deployV2(); @@ -114,8 +146,6 @@ abstract contract StakeHolderTimeDelayBaseTest is StakeHolderBaseTest { stakeHolderTimeDelay.execute(target, value, data, predecessor, salt); } - - - function _deployV2() internal virtual returns(IStakeHolder); + function _deployV3() internal virtual returns(IStakeHolder); } diff --git a/test/staking/StakeHolderTimeDelayERC20.t.sol b/test/staking/StakeHolderTimeDelayERC20.t.sol index ff88dd32..3219b73f 100644 --- a/test/staking/StakeHolderTimeDelayERC20.t.sol +++ b/test/staking/StakeHolderTimeDelayERC20.t.sol @@ -1,38 +1,35 @@ -// Copyright Immutable Pty Ltd 2018 - 2024 +// Copyright Immutable Pty Ltd 2018 - 2025 // 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 {StakeHolderERC20V2} from "../../contracts/staking/StakeHolderERC20V2.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"; +import {StakeHolderBaseV2} from "../../contracts/staking/StakeHolderBaseV2.sol"; -contract StakeHolderERC20V2 is StakeHolderERC20 { - function upgradeStorage(bytes memory /* _data */) external override(StakeHolderBase) { - version = 1; +contract StakeHolderERC20V3a is StakeHolderERC20V2 { + function upgradeStorage(bytes memory /* _data */) external override(StakeHolderBaseV2) { + version = 3; } } contract StakeHolderTimeDelayERC20Test is StakeHolderTimeDelayBaseTest { - ERC20PresetFixedSupply erc20; function setUp() public override { super.setUp(); - - erc20 = new ERC20PresetFixedSupply("Name", "SYM", 1000 ether, bank); + deployERC20(); 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)); } @@ -41,4 +38,7 @@ contract StakeHolderTimeDelayERC20Test is StakeHolderTimeDelayBaseTest { return IStakeHolder(address(new StakeHolderERC20V2())); } + function _deployV3() internal override returns(IStakeHolder) { + return IStakeHolder(address(new StakeHolderERC20V3a())); + } } diff --git a/test/staking/StakeHolderTimeDelayWIMX.t.sol b/test/staking/StakeHolderTimeDelayWIMX.t.sol index 0a55d1b9..618077ed 100644 --- a/test/staking/StakeHolderTimeDelayWIMX.t.sol +++ b/test/staking/StakeHolderTimeDelayWIMX.t.sol @@ -1,36 +1,34 @@ -// Copyright Immutable Pty Ltd 2018 - 2024 +// Copyright Immutable Pty Ltd 2018 - 2025 // 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 {StakeHolderWIMXV2} from "../../contracts/staking/StakeHolderWIMXV2.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"; +import {StakeHolderBaseV2} from "../../contracts/staking/StakeHolderBaseV2.sol"; -contract StakeHolderWIMXV2 is StakeHolderWIMX { - function upgradeStorage(bytes memory /* _data */) external override(StakeHolderBase) { - version = 1; +contract StakeHolderWIMXV3a is StakeHolderWIMXV2 { + function upgradeStorage(bytes memory /* _data */) external override(StakeHolderBaseV2) { + version = 3; } } -contract StakeHolderTimeDelayERC20Test is StakeHolderTimeDelayBaseTest { - WIMX erc20; +contract StakeHolderTimeDelayWIMXTest is StakeHolderTimeDelayBaseTest { function setUp() public override { super.setUp(); - - erc20 = new WIMX(); + deployWIMX(); StakeHolderWIMX impl = new StakeHolderWIMX(); bytes memory initData = abi.encodeWithSelector( StakeHolderWIMX.initialize.selector, address(stakeHolderTimeDelay), address(stakeHolderTimeDelay), - distributeAdmin, address(erc20) + distributeAdmin, address(wimxErc20) ); ERC1967Proxy proxy = new ERC1967Proxy(address(impl), initData); @@ -40,4 +38,8 @@ contract StakeHolderTimeDelayERC20Test is StakeHolderTimeDelayBaseTest { function _deployV2() internal override returns(IStakeHolder) { return IStakeHolder(address(new StakeHolderWIMXV2())); } + + function _deployV3() internal override returns(IStakeHolder) { + return IStakeHolder(address(new StakeHolderWIMXV3a())); + } } diff --git a/test/staking/StakeHolderUpgradeForkTest.t.sol b/test/staking/StakeHolderUpgradeForkTest.t.sol new file mode 100644 index 00000000..811fc9bc --- /dev/null +++ b/test/staking/StakeHolderUpgradeForkTest.t.sol @@ -0,0 +1,117 @@ +// Copyright Immutable Pty Ltd 2018 - 2025 +// 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 {StakeHolderWIMXV2} from "../../contracts/staking/StakeHolderWIMXV2.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 {UUPSUpgradeable} from "openzeppelin-contracts-upgradeable-4.9.3/proxy/utils/UUPSUpgradeable.sol"; +import {StakeHolderBase} from "../../contracts/staking/StakeHolderBase.sol"; + +contract StakeHolderUpgradeForkTest is Test { + string constant MAINNET_RPC_URL = "https://rpc.immutable.com/"; + address constant STAKE_HOLDER_PROXY = 0xb6c2aA8690C8Ab6AC380a0bb798Ab0debe5C4C38; + address constant TIMELOCK_CONTROLLER = 0x994a66607f947A47F33C2fA80e0470C03C30e289; + address constant WIMX = 0x3A0C2Ba54D6CBd3121F01b96dFd20e99D1696C9D; + + bytes32 constant PROPOSER_ROLE = 0xb09aa5aeb3702cfd50b6b62bc4532604938f21248a27a1d5ca736082b6819cc1; + bytes32 constant EXECUTOR_ROLE = 0xd8aa0f3194971a2a116679f7c2090f6939c8d4e01a2a8d7e41d55e5351469e63; + + address constant PROPOSER = 0xaA53161A1fD22b258c89bA76B4bA11019034612D; + address constant EXECUTOR = 0xaA53161A1fD22b258c89bA76B4bA11019034612D; + + uint256 constant TIMELOCK_DELAY = 604800; + + uint256 constant STAKERS_TO_CHECK = 100; + + StakeHolderWIMX stakeHolder; + TimelockController stakeHolderTimeDelay; + + // Put the variables below into storage so we don't need to worry about + // stack depth issues. + address stakingTokenAddress; + uint256 numStakers; + address[] stakers; + uint256[] stakersBalances; + address stakingTokenAddress2; + + function setUp() public { + uint256 mainnetFork = vm.createFork(MAINNET_RPC_URL); + vm.selectFork(mainnetFork); + stakeHolder = StakeHolderWIMX(payable(STAKE_HOLDER_PROXY)); + stakeHolderTimeDelay = TimelockController(payable(TIMELOCK_CONTROLLER)); + } + + function testUpgradeToV2() public { + if (stakeHolder.version() != 0) { + // Don't run the rest of this test after the upgrade happens. + // It would fail if we were to run the test. + return; + } + console.log("Executing Staking Contract Upgrade Fork Test"); + + stakingTokenAddress = stakeHolder.getToken(); + assertEq(WIMX, stakingTokenAddress, "WIMX address incorrect prior to upgrade"); + + numStakers = stakeHolder.getNumStakers(); + assertGe(numStakers, 822, "Wrong number of stakers"); + { + address[] memory stakersMem = stakeHolder.getStakers(0, STAKERS_TO_CHECK); + for (uint256 i = 0; i < STAKERS_TO_CHECK; i++) { + stakers.push(stakersMem[i]); + } + } + for (uint256 i = 0; i < stakers.length; i++) { + stakersBalances.push(stakeHolder.getBalance(stakers[i])); + } + + uint256 delay = stakeHolderTimeDelay.getMinDelay(); + assertEq(delay, TIMELOCK_DELAY, "Unexpected timelock delay"); + assertTrue(stakeHolderTimeDelay.hasRole(PROPOSER_ROLE, PROPOSER), "Proposer does not have proposer role"); + assertTrue(stakeHolderTimeDelay.hasRole(EXECUTOR_ROLE, EXECUTOR), "Executor does not have executor role"); + + StakeHolderWIMXV2 v2Impl = new StakeHolderWIMXV2(); + + bytes memory callData = abi.encodeWithSelector(StakeHolderBase.upgradeStorage.selector, bytes("")); + bytes memory upgradeCall = abi.encodeWithSelector( + UUPSUpgradeable.upgradeToAndCall.selector, address(v2Impl), callData); + + 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(PROPOSER); + stakeHolderTimeDelay.schedule( + target, value, data, predecessor, salt, theDelay); + + vm.warp(timeNow + delay); + + vm.prank(EXECUTOR); + stakeHolderTimeDelay.execute(target, value, data, predecessor, salt); + + assertEq(stakeHolder.version(), 2, "Upgrade did not upgrade version 2"); + + stakingTokenAddress2 = stakeHolder.getToken(); + assertEq(stakingTokenAddress, stakingTokenAddress2, "Staking token address incorrect after upgrade"); + + uint256 numStakers2 = stakeHolder.getNumStakers(); + assertGe(numStakers2, numStakers, "After upgrade: Wrong number of stakers"); + address[] memory stakers2 = stakeHolder.getStakers(0, stakers.length); + + for (uint256 i = 0; i < stakers.length; i++) { + assertEq(stakers[i], stakers2[i], "Stakers arrays don't match"); + } + for (uint256 i = 0; i < stakers.length; i++) { + uint256 bal = stakeHolder.getBalance(stakers[i]); + assertEq(bal, stakersBalances[i], "Balance changed"); + } + } +}