Skip to content

Commit c91f0f1

Browse files
drinkcoffeew3njah
andauthored
Staking contracts: ERC20 and Native IMX
Added code, tests, and scripts for Staking using ERC20s, native IMX, and native IMX where the IMX is held as WIMX. The contracts can be deployed simply, or via a time delay upgrade Co-authored-by: w3njah <[email protected]>
1 parent 2887518 commit c91f0f1

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

45 files changed

+3036
-850
lines changed

.env.example

+8-3
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1-
ETHERSCAN_API_KEY=<YOUR_ETHERSCAN_API_KEY>
2-
SEPOLIA_URL=https://eth-sepolia.g.alchemy.com/v2/<YOUR_ALCHEMY_API_KEY>
3-
MAINNET_URL=https://eth-mainnet.g.alchemy.com/v2/<YOUR_ALCHEMY_API_KEY>
1+
IMMUTABLE_NETWORK=0
2+
BLOCKSCOUT_APIKEY=<YOUR_BLOCKSCOUT_APIKEY>
43
PRIVATE_KEY=<YOUR_PRIVATE_KEY>
4+
HD_PATH="m/44'/60'/0'/0/0"
5+
DEPLOYER_ADDRESS=<Account matching private key or HD_PATH>
6+
ROLE_ADMIN=<Account or 0x0000000000000000000000000000000000000000>
7+
UPGRADE_ADMIN=<Account or 0x0000000000000000000000000000000000000000>
8+
DISTRIBUTE_ADMIN=<Account or 0x0000000000000000000000000000000000000000>
9+
ERC20_STAKING_TOKEN=<Token address?

.github/workflows/test.yml

+2
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,8 @@ jobs:
7474
steps:
7575
- name: Checkout Code
7676
uses: actions/checkout@v3
77+
- name: Uninstall Debian package that slither needs to uninstall
78+
run: sudo apt remove python3-typing-extensions
7779
- name: Install Slither
7880
run: sudo pip3 install slither-analyzer
7981
- name: Show Slither Version

audits/staking/202504-threat-model-stake-holder.md

+293
Large diffs are not rendered by default.

contracts/deployer/README.md

-2
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
# Contract Deployers
22

3-
At present, use of the Contract Deployers described below is limited to Immutable.
4-
53
This directory provides two types of contract deployers: CREATE2 and CREATE3. Both deployer types facilitate contract deployment to predictable addresses, independent of the deployer account’s nonce. The deployers offer a more reliable alternative to using a Nonce Reserver Key (a key that is only used for deploying contracts, and has specific nonces reserved for deploying specific contracts), particularly across different chains. These factories can also be utilized for contracts that don't necessarily need predictable addresses. The advantage of this method, compared to using a deployer key in conjunction with a deployment factory contract, is that it can enable better standardisation and simplification of deployment processes and enables the rotation of the deployer key without impacting the consistency of the perceived deployer address for contracts.
64

75
Deployments via these factories can only be performed by the owner of the factory.

contracts/payment-splitter/PaymentSplitter.sol

+1-1
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ contract PaymentSplitter is AccessControlEnumerable, IPaymentSplitterErrors, Ree
6565
/**
6666
* @notice Payable fallback method to receive IMX. The IMX received will be logged with {PaymentReceived} events.
6767
* this contract has no other payable method, all IMX receives will be tracked by the events emitted by this event
68-
* ERC20 receives will not be tracked by this contract but tranfers events will be emitted by the erc20 contracts themselves.
68+
* ERC20 receives will not be tracked by this contract but transfers events will be emitted by the erc20 contracts themselves.
6969
*/
7070
receive() external payable virtual {
7171
emit PaymentReceived(_msgSender(), msg.value);

contracts/staking/IStakeHolder.sol

+129
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
// Copyright (c) Immutable Pty Ltd 2018 - 2025
2+
// SPDX-License-Identifier: Apache 2
3+
pragma solidity >=0.8.19 <0.8.29;
4+
5+
import {IAccessControlEnumerableUpgradeable} from "openzeppelin-contracts-upgradeable-4.9.3/access/IAccessControlEnumerableUpgradeable.sol";
6+
7+
/**
8+
* @title StakeHolderBase: allows anyone to stake any amount of an ERC20 token and to then remove all or part of that stake.
9+
* @dev The StakeHolderERC20 contract is designed to be upgradeable.
10+
*/
11+
interface IStakeHolder is IAccessControlEnumerableUpgradeable {
12+
/// @notice implementation does not accept native tokens.
13+
error NonPayable();
14+
15+
/// @notice Error: Attempting to upgrade contract storage to version 0.
16+
error CanNotUpgradeToLowerOrSameVersion(uint256 _storageVersion);
17+
18+
/// @notice Error: Attempting to renounce the last role admin / default admin.
19+
error MustHaveOneRoleAdmin();
20+
21+
/// @notice Error: Attempting to stake with zero value.
22+
error MustStakeMoreThanZero();
23+
24+
/// @notice Error: Attempting to distribute zero value.
25+
error MustDistributeMoreThanZero();
26+
27+
/// @notice Error: Attempting to unstake amount greater than the balance.
28+
error UnstakeAmountExceedsBalance(uint256 _amountToUnstake, uint256 _currentStake);
29+
30+
/// @notice Error: Distributions can only be made to accounts that have staked.
31+
error AttemptToDistributeToNewAccount(address _account, uint256 _amount);
32+
33+
/// @notice Error: Call to stake for implementations that accept value require value and parameter to match.
34+
error MismatchMsgValueAmount(uint256 _msgValue, uint256 _amount);
35+
36+
/// @notice Event when an amount has been staked or when an amount is distributed to an account.
37+
event StakeAdded(address _staker, uint256 _amountAdded, uint256 _newBalance);
38+
39+
/// @notice Event when an amount has been unstaked.
40+
event StakeRemoved(address _staker, uint256 _amountRemoved, uint256 _newBalance);
41+
42+
/// @notice Event summarising a distribution. There will also be one StakeAdded event for each recipient.
43+
event Distributed(address _distributor, uint256 _totalDistribution, uint256 _numRecipients);
44+
45+
/// @notice Struct to combine an account and an amount.
46+
struct AccountAmount {
47+
address account;
48+
uint256 amount;
49+
}
50+
51+
/**
52+
* @notice Allow any account to stake more value.
53+
* @param _amount The amount of tokens to be staked.
54+
*/
55+
function stake(uint256 _amount) external payable;
56+
57+
/**
58+
* @notice Allow any account to remove some or all of their own stake.
59+
* @param _amountToUnstake Amount of stake to remove.
60+
*/
61+
function unstake(uint256 _amountToUnstake) external;
62+
63+
/**
64+
* @notice Accounts with DISTRIBUTE_ROLE can distribute tokens to any set of accounts.
65+
* @param _recipientsAndAmounts An array of recipients to distribute value to and
66+
* amounts to be distributed to each recipient.
67+
*/
68+
function distributeRewards(AccountAmount[] calldata _recipientsAndAmounts) external payable;
69+
70+
/**
71+
* @notice Get the balance of an account.
72+
* @param _account The account to return the balance for.
73+
* @return _balance The balance of the account.
74+
*/
75+
function getBalance(address _account) external view returns (uint256 _balance);
76+
77+
/**
78+
* @notice Determine if an account has ever staked.
79+
* @param _account The account to determine if they have staked
80+
* @return _everStaked True if the account has ever staked.
81+
*/
82+
function hasStaked(address _account) external view returns (bool _everStaked);
83+
84+
/**
85+
* @notice Get the length of the stakers array.
86+
* @dev This will be equal to the number of staker accounts that have ever staked.
87+
* Some of the accounts might have a zero balance, having staked and then
88+
* unstaked.
89+
* @return _len The length of the stakers array.
90+
*/
91+
function getNumStakers() external view returns (uint256 _len);
92+
93+
/**
94+
* @notice Get the staker accounts from the stakers array.
95+
* @dev Given the stakers list could grow arbitrarily long. To prevent out of memory or out of
96+
* gas situations due to attempting to return a very large array, this function call specifies
97+
* the start offset and number of accounts to be return.
98+
* NOTE: This code will cause a panic if the start offset + number to return is greater than
99+
* the length of the array. Use getNumStakers before calling this function to determine the
100+
* length of the array.
101+
* @param _startOffset First offset in the stakers array to return the account number for.
102+
* @param _numberToReturn The number of accounts to return.
103+
* @return _stakers A subset of the stakers array.
104+
*/
105+
function getStakers(
106+
uint256 _startOffset,
107+
uint256 _numberToReturn
108+
) external view returns (address[] memory _stakers);
109+
110+
/**
111+
* @return The address of the staking token.
112+
*/
113+
function getToken() external view returns (address);
114+
115+
/**
116+
* @notice version number of the storage variable layout.
117+
*/
118+
function version() external view returns (uint256);
119+
120+
/**
121+
* @notice Only UPGRADE_ROLE can upgrade the contract
122+
*/
123+
function UPGRADE_ROLE() external pure returns (bytes32);
124+
125+
/**
126+
* @notice Only DISTRIBUTE_ROLE can call the distribute function
127+
*/
128+
function DISTRIBUTE_ROLE() external pure returns (bytes32);
129+
}

contracts/staking/IWIMX.sol

+36
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
// Copyright Immutable Pty Ltd 2018 - 2023
2+
// SPDX-License-Identifier: Apache 2.0
3+
pragma solidity >=0.8.19 <0.8.29;
4+
5+
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
6+
7+
/*
8+
* @notice Interface for the Wrapped IMX (wIMX) contract.
9+
* @dev Based on the interface for the [Wrapped IMX contract](https://etherscan.io/token/0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2#code)
10+
*/
11+
interface IWIMX is IERC20 {
12+
/**
13+
* @notice Emitted when native IMX is deposited to the contract, and a corresponding amount of wIMX are minted
14+
* @param account The address of the account that deposited the tokens.
15+
* @param value The amount of tokens that were deposited.
16+
*/
17+
event Deposit(address indexed account, uint256 value);
18+
19+
/**
20+
* @notice Emitted when wIMX is withdrawn from the contract, and a corresponding amount of wIMX are burnt.
21+
* @param account The address of the account that withdrew the tokens.
22+
* @param value The amount of tokens that were withdrawn.
23+
*/
24+
event Withdrawal(address indexed account, uint256 value);
25+
26+
/**
27+
* @notice Deposit native IMX to the contract and mint an equal amount of wrapped IMX to msg.sender.
28+
*/
29+
function deposit() external payable;
30+
31+
/**
32+
* @notice Withdraw given amount of native IMX to msg.sender after burning an equal amount of wrapped IMX.
33+
* @param value The amount to withdraw.
34+
*/
35+
function withdraw(uint256 value) external;
36+
}

contracts/staking/README.md

+40-19
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,33 @@
11
# Staking
22

3-
The Immutable zkEVM staking system consists of the Staking Holder contract. This contract holds staked native IMX. Any account (EOA or contract) can stake any amount at any time. An account can remove all or some of their stake at any time. The contract has the facility to distribute rewards to stakers.
3+
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.
4+
5+
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).
6+
7+
The system consists of a set of contracts show in the diagram below.
8+
9+
![Staking Architecture](./staking-architecture.png)
10+
11+
`IStakeHolder.sol` is the interface that all staking implementations comply with.
12+
13+
`StakeHolderBase.sol` is the abstract base contract that all staking implementation use.
14+
15+
`StakeHolderWIMX.sol` allows the native token, IMX, to be used as the staking currency. Stake is held as wrapped IMX, WIMX, an ERC20 token.
16+
17+
`StakeHolderERC20.sol` allows an ERC20 token to be used as the staking currency.
18+
19+
`StakeHolderNative.sol` uses the native token, IMX, to be used as the staking currency. Stake is held as native IMX.
20+
21+
`ERC1967Proxy.sol` is a proxy contract. All calls to StakeHolder contracts go via the ERC1967Proxy contract.
22+
23+
`TimelockController.sol` can be used with the staking contracts to provide a one week delay between when upgrade or other admin changes are proposed and when they are executed. See below for information on how to configure the time lock controller.
24+
25+
`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.
426

527
## Immutable Contract Addresses
628

29+
StakeHolderERC20.sol configured with IMX as the staking token:
30+
731
| Environment/Network | Deployment Address | Commit Hash |
832
|--------------------------|--------------------|-------------|
933
| Immutable zkEVM Testnet | Not deployed yet | -|
@@ -16,31 +40,24 @@ Contract threat models and audits:
1640
| Description | Date |Version Audited | Link to Report |
1741
|---------------------------|------------------|-----------------|----------------|
1842
| Threat model | Oct 21, 2024 | [`fd982abc49884af41e05f18349b13edc9eefbc1e`](https://github.com/immutable/contracts/blob/fd982abc49884af41e05f18349b13edc9eefbc1e/contracts/staking/README.md) | [202410-threat-model-stake-holder.md](../../audits/staking/202410-threat-model-stake-holder.md) |
43+
| Threat model | April 24, 2025 | [`bf327c7abdadd48fd51ae632500510ac2b07b5f0`](https://github.com/immutable/contracts/blob/bf327c7abdadd48fd51ae632500510ac2b07b5f0/contracts/staking/README.md) | [202504-threat-model-stake-holder.md](../../audits/staking/202504-threat-model-stake-holder.md) |
1944

2045

2146

2247
# Deployment
2348

24-
**Deploy and verify using CREATE3 factory contract:**
25-
26-
This repo includes a script for deploying via a CREATE3 factory contract. The script is defined as a test contract as per the examples [here](https://book.getfoundry.sh/reference/forge/forge-script#examples) and can be found in `./script/staking/DeployStakeHolder.sol`.
27-
28-
See the `.env.example` for required environment variables.
29-
30-
```sh
31-
forge script script/staking/DeployStakeHolder.sol --tc DeployStakeHolder --sig "deploy()" -vvv --rpc-url {rpc-url} --broadcast --verifier-url https://explorer.immutable.com/api --verifier blockscout --verify --gas-price 10000000000
32-
```
33-
34-
Optionally, you can also specify `--ledger` or `--trezor` for hardware deployments. See docs [here](https://book.getfoundry.sh/reference/forge/forge-script#wallet-options---hardware-wallet).
49+
See [deployment scripts](../../script/staking/README.md).
3550

3651

3752
# Usage
3853

39-
To stake, any account should call `stake()`, passing in the amount to be staked as the msg.value.
54+
For StakeHolderERC20 and StakeHolderWIMX, the ERC20 staking token must be specified when the contract is being initialised. The token can not be changed.
55+
56+
To stake, any account should call `stake(uint256 _amount)`. For the WIMX and the native IMX variants, the amount to be staked must be passed in as the msg.value.
4057

4158
To unstake, the account that previously staked should call, `unstake(uint256 _amountToUnstake)`.
4259

43-
Accounts that have DISTRIBUTE_ROLE that wish to distribute rewards should call, `distributeRewards(AccountAmount[] calldata _recipientsAndAmounts)`. The `AccountAmount` structure consists of recipient address and amount to distribute pairs. Distributions can only be made to accounts that have previously or are currently staking. The amount to be distributed must be passed in as msg.value and must equal to the sum of the amounts specified in the `_recipientsAndAmounts` array.
60+
Accounts that have DISTRIBUTE_ROLE that wish to distribute rewards should call, `distributeRewards(AccountAmount[] calldata _recipientsAndAmounts)`. The `AccountAmount` structure consists of recipient address and amount to distribute pairs. Distributions can only be made to accounts that have previously or are currently staking. For the WIMX and the native IMX variants, the amount to be distributed must be passed in as msg.value and must equal to the sum of the amounts specified in the `_recipientsAndAmounts` array.
4461

4562
The `stakers` array needs to be analysed to determine which accounts have staked and how much. The following functions provide access to this data structure:
4663

@@ -51,11 +68,7 @@ The `stakers` array needs to be analysed to determine which accounts have staked
5168

5269
# Administration Notes
5370

54-
The `StakeHolder` contract is `AccessControlEnumerableUpgradeable`, with the following minor modification:
55-
56-
* `_revokeRole(bytes32 _role, address _account)` has been overridden to prevent the last DEFAULT_ADMIN_ROLE (the last role admin) from either being revoked or renounced.
57-
58-
The `StakeHolder` contract is `UUPSUpgradeable`. Only accounts with `UPGRADE_ROLE` are authorised to upgrade the contract.
71+
The `StakeHolderBase` contract is `AccessControlEnumerableUpgradeable`. The `StakeHolderERC20` and `StakeHolderNative` contracts are `UUPSUpgradeable`. Only accounts with `UPGRADE_ROLE` are authorised to upgrade the contract.
5972

6073
## Upgrade Concept
6174

@@ -67,3 +80,11 @@ The `upgradeStorage` function should be updated each new contract version. It sh
6780
* The value is the same as the newt version: Someone (an attacker) has called the `upgradeStorage` function after the code has been upgraded. The function should revert.
6881
* Based on the old code version and storage format indicated by the `version`, update the storage variables. Typically, upgrades only involve code changes, and require no storage variable changes. However, in some circumstances storage variables should also be updated.
6982
* Update the `version` storage variable to indicate the new code version.
83+
84+
## Time Delay Upgrade and Admin
85+
86+
A staking systems may wish to delay upgrade actions and the granting of additional administrative access. To do this, the only account with UPGRADE_ROLE and DEFAULT_ADMIN_ROLE roles should be an instance of Open Zeppelin's [TimelockController](https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/governance/TimelockController.sol). This ensures any upgrade proposals or proposals to add more accounts with `DEFAULT_ADMIN_ROLE`, `UPGRADE_ROLE` or `DISTRIBUTE_ROLE` must go through a time delay before being actioned. The account with `DEFAULT_ADMIN_ROLE` could choose to renounce this role to ensure the `TimelockController` can not be bypassed at a later date by having a compromised account with `DEFAULT_ADMIN_ROLE` adding addtional accounts with `UPGRADE_ROLE`.
87+
88+
## Preventing Upgrade
89+
90+
A staking system could choose to have no account with DEFAULT_ADMIN_ROLE to to prevent additional accounts being granted UPGRADE_ROLE role. The system could have no acccounts with UPGRADE_ROLE, thus preventing upgrade. The system could configure this from start-up by passing in `address(0)` as the `roleAdmin` and `upgradeAdmin` to the constructor. Alternative, the `revokeRole` function can be used to revoke the roles from accounts.

0 commit comments

Comments
 (0)