Skip to content

DRAFT: StakeFor upgrade to staking system #280

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 8 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions ,,.x.txt
Original file line number Diff line number Diff line change
@@ -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
321 changes: 321 additions & 0 deletions audits/staking/202506-threat-model-stake-holder.md

Large diffs are not rendered by default.

14 changes: 10 additions & 4 deletions contracts/staking/IStakeHolder.sol
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,7 @@
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.
Expand Down Expand Up @@ -39,9 +38,14 @@
/// @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.

Check failure on line 41 in contracts/staking/IStakeHolder.sol

View workflow job for this annotation

GitHub Actions / Run solhint

Delete ·
/// @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;
Expand All @@ -61,7 +65,9 @@
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.
*/
Expand Down
23 changes: 23 additions & 0 deletions contracts/staking/IStakeHolderV2.sol
Original file line number Diff line number Diff line change
@@ -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.

Check failure on line 11 in contracts/staking/IStakeHolderV2.sol

View workflow job for this annotation

GitHub Actions / Run solhint

Replace ///·@notice·Event·summarising·a·distribution·via·the·stakeFor·function.· with ·///·@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;
}
6 changes: 5 additions & 1 deletion contracts/staking/README.md
Original file line number Diff line number Diff line change
@@ -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).

Expand All @@ -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:
Expand Down
8 changes: 4 additions & 4 deletions contracts/staking/StakeHolderBase.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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;
Expand Down
105 changes: 105 additions & 0 deletions contracts/staking/StakeHolderBaseV2.sol
Original file line number Diff line number Diff line change
@@ -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;


Check failure on line 16 in contracts/staking/StakeHolderBaseV2.sol

View workflow job for this annotation

GitHub Actions / Run solhint

Delete ⏎
/**
* @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

Check failure on line 46 in contracts/staking/StakeHolderBaseV2.sol

View workflow job for this annotation

GitHub Actions / Run solhint

Delete ·
// changing the storage version number.
version = _VERSION2;
}

Check failure on line 49 in contracts/staking/StakeHolderBaseV2.sol

View workflow job for this annotation

GitHub Actions / Run solhint

Delete ⏎·······
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);
}
}
75 changes: 75 additions & 0 deletions contracts/staking/StakeHolderERC20V2.sol
Original file line number Diff line number Diff line change
@@ -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";

Check warning on line 7 in contracts/staking/StakeHolderERC20V2.sol

View workflow job for this annotation

GitHub Actions / Run solhint

Variable "StakeHolderBase" is unused

Check warning on line 7 in contracts/staking/StakeHolderERC20V2.sol

View workflow job for this annotation

GitHub Actions / Run solhint

Variable "IStakeHolder" is unused

/**
* @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.
*/

Check failure on line 13 in contracts/staking/StakeHolderERC20V2.sol

View workflow job for this annotation

GitHub Actions / Run solhint

Insert ·
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
}
2 changes: 0 additions & 2 deletions contracts/staking/StakeHolderNative.sol
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,14 @@
// SPDX-License-Identifier: Apache 2
pragma solidity >=0.8.19 <0.8.29;

import {IStakeHolder, StakeHolderBase} from "./StakeHolderBase.sol";

Check warning on line 5 in contracts/staking/StakeHolderNative.sol

View workflow job for this annotation

GitHub Actions / Run solhint

Variable "IStakeHolder" is unused

/**
* @title StakeHolder: allows anyone to stake any amount of native IMX and to then remove all or part of that stake.
* @dev The StakeHolder contract is designed to be upgradeable.
*/
contract StakeHolderNative is StakeHolderBase {
/// @notice Error: Unstake transfer failed.
error UnstakeTransferFailed();

Check failure on line 12 in contracts/staking/StakeHolderNative.sol

View workflow job for this annotation

GitHub Actions / Run solhint

Delete ⏎
/**
* @notice Initialises the upgradeable contract, setting up admin accounts.
* @param _roleAdmin the address to grant `DEFAULT_ADMIN_ROLE` to
Expand Down
66 changes: 66 additions & 0 deletions contracts/staking/StakeHolderNativeV2.sol
Original file line number Diff line number Diff line change
@@ -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";

Check warning on line 5 in contracts/staking/StakeHolderNativeV2.sol

View workflow job for this annotation

GitHub Actions / Run solhint

Variable "StakeHolderBase" is unused

Check warning on line 5 in contracts/staking/StakeHolderNativeV2.sol

View workflow job for this annotation

GitHub Actions / Run solhint

Variable "IStakeHolder" is unused

/**
* @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.
*/

Check failure on line 11 in contracts/staking/StakeHolderNativeV2.sol

View workflow job for this annotation

GitHub Actions / Run solhint

Insert ·
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
}
Loading
Loading