From 7453f54fd6fd354f52d038ad2e88067d93f08404 Mon Sep 17 00:00:00 2001 From: Peter Robinson Date: Tue, 15 Apr 2025 10:58:44 +1000 Subject: [PATCH 01/39] Initial ERC20 staking contract --- contracts/staking/StakeHolderERC20.sol | 275 +++++++++++++++++++++++++ 1 file changed, 275 insertions(+) create mode 100644 contracts/staking/StakeHolderERC20.sol diff --git a/contracts/staking/StakeHolderERC20.sol b/contracts/staking/StakeHolderERC20.sol new file mode 100644 index 00000000..7b245812 --- /dev/null +++ b/contracts/staking/StakeHolderERC20.sol @@ -0,0 +1,275 @@ +// Copyright (c) Immutable Pty Ltd 2018 - 2025 +// SPDX-License-Identifier: Apache 2 +pragma solidity >=0.8.19 <0.8.29; + +import {UUPSUpgradeable} from "openzeppelin-contracts-upgradeable-4.9.3/proxy/utils/UUPSUpgradeable.sol"; +import {AccessControlEnumerableUpgradeable} from "openzeppelin-contracts-upgradeable-4.9.3/access/AccessControlEnumerableUpgradeable.sol"; +import {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 {ReentrancyGuardUpgradeable} from "openzeppelin-contracts-upgradeable-4.9.3/security/ReentrancyGuardUpgradeable.sol"; + +/// @notice Struct to combine an account and an amount. +struct AccountAmount { + address account; + uint256 amount; +} + +/** + * @title StakeHolderERC20: allows anyone to stake any amount of an ERC20 token and to then remove all or part of that stake. + * @dev The StakeHolderERC20 contract is designed to be upgradeable. + */ +contract StakeHolderERC20 is AccessControlEnumerableUpgradeable, UUPSUpgradeable, ReentrancyGuardUpgradeable { + using SafeERC20Upgradeable for IERC20Upgradeable; + + /// @notice Error: Attempting to upgrade contract storage to version 0. + error CanNotUpgradeToLowerOrSameVersion(uint256 _storageVersion); + + /// @notice Error: Attempting to renounce the last role admin / default admin. + error MustHaveOneRoleAdmin(); + + /// @notice Error: Attempting to stake with zero value. + error MustStakeMoreThanZero(); + + /// @notice Error: Attempting to distribute zero value. + error MustDistributeMoreThanZero(); + + /// @notice Error: Attempting to unstake amount greater than the balance. + error UnstakeAmountExceedsBalance(uint256 _amountToUnstake, uint256 _currentStake); + + /// @notice Error: Distributions can only be made to accounts that have staked. + error AttemptToDistributeToNewAccount(address _account, uint256 _amount); + + /// @notice Event when an amount has been staked or when an amount is distributed to an account. + event StakeAdded(address _staker, uint256 _amountAdded, uint256 _newBalance); + + /// @notice Event when an amount has been unstaked. + event StakeRemoved(address _staker, uint256 _amountRemoved, uint256 _newBalance); + + /// @notice Event summarising a distribution. There will also be one StakeAdded event for each recipient. + event Distributed(address _distributor, uint256 _totalDistribution, uint256 _numRecipients); + + /// @notice Only UPGRADE_ROLE can upgrade the contract + bytes32 public constant UPGRADE_ROLE = bytes32("UPGRADE_ROLE"); + + /// @notice Only DISTRIBUTE_ROLE can call the distribute function + bytes32 public constant DISTRIBUTE_ROLE = bytes32("DISTRIBUTE_ROLE"); + + /// @notice Version 0 version number + uint256 private constant _VERSION0 = 0; + + /// @notice Holds staking information for a single staker. + struct StakeInfo { + /// @notice Amount of stake. + uint256 stake; + /// @notice True if this account has ever staked. + bool hasStaked; + } + + /// @notice The amount of value owned by each staker + // solhint-disable-next-line private-vars-leading-underscore + mapping(address staker => StakeInfo stakeInfo) private balances; + + /// @notice The token used for staking. + IERC20Upgradeable public token; + + /// @notice A list of all stakers who have ever staked. + /// @dev The list make contain stakers who have completely unstaked (that is, have + /// a balance of 0). This array is never re-ordered. As such, off-chain services + /// could cache the results of getStakers(). + // solhint-disable-next-line private-vars-leading-underscore + address[] private stakers; + + /// @notice version number of the storage variable layout. + uint256 public version; + + /** + * @notice Initialises the upgradeable contract, setting up admin accounts. + * @param _roleAdmin the address to grant `DEFAULT_ADMIN_ROLE` to + * @param _upgradeAdmin the address to grant `UPGRADE_ROLE` to + * @param _distributeAdmin the address to grant `DISTRIBUTE_ROLE` to + */ + function initialize( + address _roleAdmin, + address _upgradeAdmin, + address _distributeAdmin, + address _token + ) public initializer { + __UUPSUpgradeable_init(); + __AccessControl_init(); + __ReentrancyGuard_init(); + _grantRole(DEFAULT_ADMIN_ROLE, _roleAdmin); + _grantRole(UPGRADE_ROLE, _upgradeAdmin); + _grantRole(DISTRIBUTE_ROLE, _distributeAdmin); + token = IERC20Upgradeable(_token); + version = _VERSION0; + } + + /** + * @notice Function to be called when upgrading this contract. + * @dev Call this function as part of upgradeToAndCall(). + * This initial version of this function reverts. There is no situation + * in which it makes sense to upgrade to the V0 storage layout. + * Note that this function is permissionless. Future versions must + * compare the code version and the storage version and upgrade + * appropriately. As such, the code will revert if an attacker calls + * this function attempting a malicious upgrade. + * @ param _data ABI encoded data to be used as part of the contract storage upgrade. + */ + function upgradeStorage(bytes memory /* _data */) external virtual { + revert CanNotUpgradeToLowerOrSameVersion(version); + } + + /** + * @notice Allow any account to stake more value. + * @param _amount The amount of tokens to be staked. + */ + function stake(uint256 _amount) external nonReentrant { + if (_amount == 0) { + revert MustStakeMoreThanZero(); + } + token.safeTransferFrom(msg.sender, address(this), _amount); + _addStake(msg.sender, _amount, false); + } + + /** + * @notice Allow any account to remove some or all of their own stake. + * @param _amountToUnstake Amount of stake to remove. + */ + function unstake(uint256 _amountToUnstake) external nonReentrant { + StakeInfo storage stakeInfo = balances[msg.sender]; + uint256 currentStake = stakeInfo.stake; + if (currentStake < _amountToUnstake) { + revert UnstakeAmountExceedsBalance(_amountToUnstake, currentStake); + } + uint256 newBalance = currentStake - _amountToUnstake; + stakeInfo.stake = newBalance; + + emit StakeRemoved(msg.sender, _amountToUnstake, newBalance); + + token.safeTransfer(msg.sender, _amountToUnstake); + } + + /** + * @notice Accounts with DISTRIBUTE_ROLE can distribute tokens to any set of accounts. + * @param _recipientsAndAmounts An array of recipients to distribute value to and + * amounts to be distributed to each recipient. + */ + function distributeRewards( + AccountAmount[] calldata _recipientsAndAmounts + ) external nonReentrant onlyRole(DISTRIBUTE_ROLE) { + // Initial validity checks + uint256 len = _recipientsAndAmounts.length; + if (len == 0) { + revert MustDistributeMoreThanZero(); + } + + // Distribute the value. + uint256 total = 0; + for (uint256 i = 0; i < len; i++) { + AccountAmount calldata accountAmount = _recipientsAndAmounts[i]; + uint256 amount = accountAmount.amount; + // Add stake, but require the acount to either currently be staking or have + // previously staked. + _addStake(accountAmount.account, amount, true); + total += amount; + } + token.safeTransferFrom(msg.sender, address(this), total); + emit Distributed(msg.sender, total, len); + } + + /** + * @notice Get the balance of an account. + * @param _account The account to return the balance for. + * @return _balance The balance of the account. + */ + function getBalance(address _account) external view returns (uint256 _balance) { + return balances[_account].stake; + } + + /** + * @notice Determine if an account has ever staked. + * @param _account The account to determine if they have staked + * @return _everStaked True if the account has ever staked. + */ + function hasStaked(address _account) external view returns (bool _everStaked) { + return balances[_account].hasStaked; + } + + /** + * @notice Get the length of the stakers array. + * @dev This will be equal to the number of staker accounts that have ever staked. + * Some of the accounts might have a zero balance, having staked and then + * unstaked. + * @return _len The length of the stakers array. + */ + function getNumStakers() external view returns (uint256 _len) { + return stakers.length; + } + + /** + * @notice Get the staker accounts from the stakers array. + * @dev Given the stakers list could grow arbitrarily long. To prevent out of memory or out of + * gas situations due to attempting to return a very large array, this function call specifies + * the start offset and number of accounts to be return. + * NOTE: This code will cause a panic if the start offset + number to return is greater than + * the length of the array. Use getNumStakers before calling this function to determine the + * length of the array. + * @param _startOffset First offset in the stakers array to return the account number for. + * @param _numberToReturn The number of accounts to return. + * @return _stakers A subset of the stakers array. + */ + function getStakers( + uint256 _startOffset, + uint256 _numberToReturn + ) external view returns (address[] memory _stakers) { + address[] memory stakerPartialArray = new address[](_numberToReturn); + for (uint256 i = 0; i < _numberToReturn; i++) { + stakerPartialArray[i] = stakers[_startOffset + i]; + } + return stakerPartialArray; + } + + /** + * @notice Add more stake to an account. + * @dev If the account has a zero balance prior to this call, add the account to the stakers array. + * @param _account Account to add stake to. + * @param _amount The amount of stake to add. + * @param _existingAccountsOnly If true, revert if the account has never been used. + */ + function _addStake(address _account, uint256 _amount, bool _existingAccountsOnly) private { + StakeInfo storage stakeInfo = balances[_account]; + uint256 currentStake = stakeInfo.stake; + if (!stakeInfo.hasStaked) { + if (_existingAccountsOnly) { + revert AttemptToDistributeToNewAccount(_account, _amount); + } + stakers.push(_account); + stakeInfo.hasStaked = true; + } + uint256 newBalance = currentStake + _amount; + stakeInfo.stake = newBalance; + emit StakeAdded(_account, _amount, newBalance); + } + + // Override the _authorizeUpgrade function + // solhint-disable-next-line no-empty-blocks + function _authorizeUpgrade(address newImplementation) internal override onlyRole(UPGRADE_ROLE) {} + + /** + * @notice Prevent revoke or renounce role for the last DEFAULT_ADMIN_ROLE / the last role admin. + * @param _role The role to be renounced. + * @param _account Account to be revoked. + */ + function _revokeRole(bytes32 _role, address _account) internal override { + if (_role == DEFAULT_ADMIN_ROLE && getRoleMemberCount(_role) == 1) { + revert MustHaveOneRoleAdmin(); + } + super._revokeRole(_role, _account); + } + + /// @notice storage gap for additional variables for upgrades + // slither-disable-start unused-state + // solhint-disable-next-line var-name-mixedcase + uint256[50] private __StakeHolderERC20Gap; + // slither-disable-end unused-state +} From 71ceb5a29dc319d3ad837b5c0f4a100a7d440e9e Mon Sep 17 00:00:00 2001 From: Peter Robinson Date: Tue, 15 Apr 2025 11:04:52 +1000 Subject: [PATCH 02/39] Added time stamp to events --- contracts/staking/StakeHolder.sol | 8 ++++---- contracts/staking/StakeHolderERC20.sol | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/contracts/staking/StakeHolder.sol b/contracts/staking/StakeHolder.sol index 31ced4f1..442eed44 100644 --- a/contracts/staking/StakeHolder.sol +++ b/contracts/staking/StakeHolder.sol @@ -41,10 +41,10 @@ contract StakeHolder is AccessControlEnumerableUpgradeable, UUPSUpgradeable { error AttemptToDistributeToNewAccount(address _account, uint256 _amount); /// @notice Event when an amount has been staked or when an amount is distributed to an account. - event StakeAdded(address _staker, uint256 _amountAdded, uint256 _newBalance); + event StakeAdded(address _staker, uint256 _amountAdded, uint256 _newBalance, uint256 _timestamp); /// @notice Event when an amount has been unstaked. - event StakeRemoved(address _staker, uint256 _amountRemoved, uint256 _newBalance); + event StakeRemoved(address _staker, uint256 _amountRemoved, uint256 _newBalance, uint256 _timestamp); /// @notice Event summarising a distribution. There will also be one StakeAdded event for each recipient. event Distributed(address _distributor, uint256 _totalDistribution, uint256 _numRecipients); @@ -138,7 +138,7 @@ contract StakeHolder is AccessControlEnumerableUpgradeable, UUPSUpgradeable { uint256 newBalance = currentStake - _amountToUnstake; stakeInfo.stake = newBalance; - emit StakeRemoved(msg.sender, _amountToUnstake, newBalance); + emit StakeRemoved(msg.sender, _amountToUnstake, newBalance, block.timestamp); // slither-disable-next-line low-level-calls (bool success, bytes memory returndata) = payable(msg.sender).call{value: _amountToUnstake}(""); @@ -263,7 +263,7 @@ contract StakeHolder is AccessControlEnumerableUpgradeable, UUPSUpgradeable { } uint256 newBalance = currentStake + _amount; stakeInfo.stake = newBalance; - emit StakeAdded(_account, _amount, newBalance); + emit StakeAdded(_account, _amount, newBalance, block.timestamp); } // Override the _authorizeUpgrade function diff --git a/contracts/staking/StakeHolderERC20.sol b/contracts/staking/StakeHolderERC20.sol index 7b245812..0fa37b58 100644 --- a/contracts/staking/StakeHolderERC20.sol +++ b/contracts/staking/StakeHolderERC20.sol @@ -40,10 +40,10 @@ contract StakeHolderERC20 is AccessControlEnumerableUpgradeable, UUPSUpgradeable error AttemptToDistributeToNewAccount(address _account, uint256 _amount); /// @notice Event when an amount has been staked or when an amount is distributed to an account. - event StakeAdded(address _staker, uint256 _amountAdded, uint256 _newBalance); + event StakeAdded(address _staker, uint256 _amountAdded, uint256 _newBalance, uint256 _timestamp); /// @notice Event when an amount has been unstaked. - event StakeRemoved(address _staker, uint256 _amountRemoved, uint256 _newBalance); + event StakeRemoved(address _staker, uint256 _amountRemoved, uint256 _newBalance, uint256 _timestamp); /// @notice Event summarising a distribution. There will also be one StakeAdded event for each recipient. event Distributed(address _distributor, uint256 _totalDistribution, uint256 _numRecipients); @@ -144,7 +144,7 @@ contract StakeHolderERC20 is AccessControlEnumerableUpgradeable, UUPSUpgradeable uint256 newBalance = currentStake - _amountToUnstake; stakeInfo.stake = newBalance; - emit StakeRemoved(msg.sender, _amountToUnstake, newBalance); + emit StakeRemoved(msg.sender, _amountToUnstake, newBalance, block.timestamp); token.safeTransfer(msg.sender, _amountToUnstake); } @@ -248,7 +248,7 @@ contract StakeHolderERC20 is AccessControlEnumerableUpgradeable, UUPSUpgradeable } uint256 newBalance = currentStake + _amount; stakeInfo.stake = newBalance; - emit StakeAdded(_account, _amount, newBalance); + emit StakeAdded(_account, _amount, newBalance, block.timestamp); } // Override the _authorizeUpgrade function From fa744762b4d05ad13e8c9f5bb198010fe97c3772 Mon Sep 17 00:00:00 2001 From: Peter Robinson Date: Tue, 15 Apr 2025 15:44:03 +1000 Subject: [PATCH 03/39] Tests working --- contracts/staking/IStakeHolder.sol | 132 +++++++ contracts/staking/StakeHolder.sol | 290 --------------- contracts/staking/StakeHolderBase.sol | 139 ++++++++ contracts/staking/StakeHolderERC20.sol | 177 ++-------- contracts/staking/StakeHolderNative.sol | 150 ++++++++ ...Holder.sol => DeployStakeHolderNative.sol} | 14 +- test/staking/README.md | 51 +++ test/staking/StakeHolderAttackWallet.sol | 8 +- test/staking/StakeHolderBase.t.sol | 29 +- ...nfig.t.sol => StakeHolderConfigBase.t.sol} | 58 +-- test/staking/StakeHolderConfigERC20.t.sol | 41 +++ test/staking/StakeHolderConfigNative.t.sol | 43 +++ ...erInit.t.sol => StakeHolderInitBase.t.sol} | 3 +- test/staking/StakeHolderInitERC20.t.sol | 27 ++ test/staking/StakeHolderInitNative.t.sol | 27 ++ test/staking/StakeHolderOperational.t.sol | 331 ------------------ test/staking/StakeHolderOperationalBase.t.sol | 287 +++++++++++++++ .../staking/StakeHolderOperationalERC20.t.sol | 60 ++++ .../StakeHolderOperationalNative.t.sol | 79 +++++ 19 files changed, 1107 insertions(+), 839 deletions(-) create mode 100644 contracts/staking/IStakeHolder.sol delete mode 100644 contracts/staking/StakeHolder.sol create mode 100644 contracts/staking/StakeHolderBase.sol create mode 100644 contracts/staking/StakeHolderNative.sol rename script/staking/{DeployStakeHolder.sol => DeployStakeHolderNative.sol} (91%) rename test/staking/{StakeHolderConfig.t.sol => StakeHolderConfigBase.t.sol} (63%) create mode 100644 test/staking/StakeHolderConfigERC20.t.sol create mode 100644 test/staking/StakeHolderConfigNative.t.sol rename test/staking/{StakeHolderInit.t.sol => StakeHolderInitBase.t.sol} (90%) create mode 100644 test/staking/StakeHolderInitERC20.t.sol create mode 100644 test/staking/StakeHolderInitNative.t.sol delete mode 100644 test/staking/StakeHolderOperational.t.sol create mode 100644 test/staking/StakeHolderOperationalBase.t.sol create mode 100644 test/staking/StakeHolderOperationalERC20.t.sol create mode 100644 test/staking/StakeHolderOperationalNative.t.sol diff --git a/contracts/staking/IStakeHolder.sol b/contracts/staking/IStakeHolder.sol new file mode 100644 index 00000000..ff3ca60d --- /dev/null +++ b/contracts/staking/IStakeHolder.sol @@ -0,0 +1,132 @@ +// Copyright (c) Immutable Pty Ltd 2018 - 2025 +// SPDX-License-Identifier: Apache 2 +pragma solidity >=0.8.19 <0.8.29; + +import {IAccessControlEnumerableUpgradeable} from "openzeppelin-contracts-upgradeable-4.9.3/access/IAccessControlEnumerableUpgradeable.sol"; + +/** + * @title StakeHolderBase: allows anyone to stake any amount of an ERC20 token and to then remove all or part of that stake. + * @dev The StakeHolderERC20 contract is designed to be upgradeable. + */ +interface IStakeHolder is IAccessControlEnumerableUpgradeable { + /// @notice implementation does not accept native tokens. + error NonPayable(); + + /// @notice Error: Attempting to upgrade contract storage to version 0. + error CanNotUpgradeToLowerOrSameVersion(uint256 _storageVersion); + + /// @notice Error: Attempting to renounce the last role admin / default admin. + error MustHaveOneRoleAdmin(); + + /// @notice Error: Attempting to stake with zero value. + error MustStakeMoreThanZero(); + + /// @notice Error: Attempting to distribute zero value. + error MustDistributeMoreThanZero(); + + /// @notice Error: Attempting to unstake amount greater than the balance. + error UnstakeAmountExceedsBalance(uint256 _amountToUnstake, uint256 _currentStake); + + /// @notice Error: Distributions can only be made to accounts that have staked. + error AttemptToDistributeToNewAccount(address _account, uint256 _amount); + + /// @notice Error: The sum of all amounts to distribute did not equal msg.value of the distribute transaction. + error DistributionAmountsDoNotMatchTotal(uint256 _msgValue, uint256 _calculatedTotalDistribution); + + /// @notice Error: Call to stake for implementations that accept value require value and parameter to match. + error MismatchMsgValueAmount(uint256 _msgValue, uint256 _amount); + + /// @notice Event when an amount has been staked or when an amount is distributed to an account. + event StakeAdded(address _staker, uint256 _amountAdded, uint256 _newBalance, uint256 _timestamp); + + /// @notice Event when an amount has been unstaked. + event StakeRemoved(address _staker, uint256 _amountRemoved, uint256 _newBalance, uint256 _timestamp); + + /// @notice Event summarising a distribution. There will also be one StakeAdded event for each recipient. + event Distributed(address _distributor, uint256 _totalDistribution, uint256 _numRecipients); + + /// @notice Struct to combine an account and an amount. + struct AccountAmount { + address account; + uint256 amount; + } + + /** + * @notice Allow any account to stake more value. + * @param _amount The amount of tokens to be staked. + */ + function stake(uint256 _amount) external payable; + + /** + * @notice Allow any account to remove some or all of their own stake. + * @param _amountToUnstake Amount of stake to remove. + */ + function unstake(uint256 _amountToUnstake) external; + + /** + * @notice Accounts with DISTRIBUTE_ROLE can distribute tokens to any set of accounts. + * @param _recipientsAndAmounts An array of recipients to distribute value to and + * amounts to be distributed to each recipient. + */ + function distributeRewards(AccountAmount[] calldata _recipientsAndAmounts) external payable; + + /** + * @notice Get the balance of an account. + * @param _account The account to return the balance for. + * @return _balance The balance of the account. + */ + function getBalance(address _account) external view returns (uint256 _balance); + + /** + * @notice Determine if an account has ever staked. + * @param _account The account to determine if they have staked + * @return _everStaked True if the account has ever staked. + */ + function hasStaked(address _account) external view returns (bool _everStaked); + + /** + * @notice Get the length of the stakers array. + * @dev This will be equal to the number of staker accounts that have ever staked. + * Some of the accounts might have a zero balance, having staked and then + * unstaked. + * @return _len The length of the stakers array. + */ + function getNumStakers() external view returns (uint256 _len); + + /** + * @notice Get the staker accounts from the stakers array. + * @dev Given the stakers list could grow arbitrarily long. To prevent out of memory or out of + * gas situations due to attempting to return a very large array, this function call specifies + * the start offset and number of accounts to be return. + * NOTE: This code will cause a panic if the start offset + number to return is greater than + * the length of the array. Use getNumStakers before calling this function to determine the + * length of the array. + * @param _startOffset First offset in the stakers array to return the account number for. + * @param _numberToReturn The number of accounts to return. + * @return _stakers A subset of the stakers array. + */ + function getStakers( + uint256 _startOffset, + uint256 _numberToReturn + ) external view returns (address[] memory _stakers); + + /** + * @return The address of the staking token. + */ + function getToken() external view returns (address); + + /** + * @notice version number of the storage variable layout. + */ + function version() external view returns (uint256); + + /** + * @notice Only UPGRADE_ROLE can upgrade the contract + */ + function UPGRADE_ROLE() external pure returns(bytes32); + + /** + * @notice Only DISTRIBUTE_ROLE can call the distribute function + */ + function DISTRIBUTE_ROLE() external pure returns(bytes32); +} diff --git a/contracts/staking/StakeHolder.sol b/contracts/staking/StakeHolder.sol deleted file mode 100644 index 442eed44..00000000 --- a/contracts/staking/StakeHolder.sol +++ /dev/null @@ -1,290 +0,0 @@ -// Copyright (c) Immutable Pty Ltd 2018 - 2025 -// SPDX-License-Identifier: Apache 2 -pragma solidity >=0.8.19 <0.8.29; - -import {UUPSUpgradeable} from "openzeppelin-contracts-upgradeable-4.9.3/proxy/utils/UUPSUpgradeable.sol"; -import {AccessControlEnumerableUpgradeable} from "openzeppelin-contracts-upgradeable-4.9.3/access/AccessControlEnumerableUpgradeable.sol"; - -/// @notice Struct to combine an account and an amount. -struct AccountAmount { - address account; - uint256 amount; -} - -/** - * @title StakeHolder: allows anyone to stake any amount of native IMX and to then remove all or part of that stake. - * @dev The StakeHolder contract is designed to be upgradeable. - */ -contract StakeHolder is AccessControlEnumerableUpgradeable, UUPSUpgradeable { - /// @notice Error: Attempting to upgrade contract storage to version 0. - error CanNotUpgradeToLowerOrSameVersion(uint256 _storageVersion); - - /// @notice Error: Attempting to renounce the last role admin / default admin. - error MustHaveOneRoleAdmin(); - - /// @notice Error: Attempting to stake with zero value. - error MustStakeMoreThanZero(); - - /// @notice Error: Attempting to distribute zero value. - error MustDistributeMoreThanZero(); - - /// @notice Error: Attempting to unstake amount greater than the balance. - error UnstakeAmountExceedsBalance(uint256 _amountToUnstake, uint256 _currentStake); - - /// @notice Error: Unstake transfer failed. - error UnstakeTransferFailed(); - - /// @notice Error: The sum of all amounts to distribute did not equal msg.value of the distribute transaction. - error DistributionAmountsDoNotMatchTotal(uint256 _msgValue, uint256 _calculatedTotalDistribution); - - /// @notice Error: Distributions can only be made to accounts that have staked. - error AttemptToDistributeToNewAccount(address _account, uint256 _amount); - - /// @notice Event when an amount has been staked or when an amount is distributed to an account. - event StakeAdded(address _staker, uint256 _amountAdded, uint256 _newBalance, uint256 _timestamp); - - /// @notice Event when an amount has been unstaked. - event StakeRemoved(address _staker, uint256 _amountRemoved, uint256 _newBalance, uint256 _timestamp); - - /// @notice Event summarising a distribution. There will also be one StakeAdded event for each recipient. - event Distributed(address _distributor, uint256 _totalDistribution, uint256 _numRecipients); - - /// @notice Only UPGRADE_ROLE can upgrade the contract - bytes32 public constant UPGRADE_ROLE = bytes32("UPGRADE_ROLE"); - - /// @notice Only DISTRIBUTE_ROLE can call the distribute function - bytes32 public constant DISTRIBUTE_ROLE = bytes32("DISTRIBUTE_ROLE"); - - /// @notice Version 0 version number - uint256 private constant _VERSION0 = 0; - - /// @notice Holds staking information for a single staker. - struct StakeInfo { - /// @notice Amount of stake. - uint256 stake; - /// @notice True if this account has ever staked. - bool hasStaked; - } - - /// @notice The amount of value owned by each staker - // solhint-disable-next-line private-vars-leading-underscore - mapping(address staker => StakeInfo stakeInfo) private balances; - - /// @notice A list of all stakers who have ever staked. - /// @dev The list make contain stakers who have completely unstaked (that is, have - /// a balance of 0). This array is never re-ordered. As such, off-chain services - /// could cache the results of getStakers(). - // solhint-disable-next-line private-vars-leading-underscore - address[] private stakers; - - /// @notice version number of the storage variable layout. - uint256 public version; - - /** - * @notice Initialises the upgradeable contract, setting up admin accounts. - * @param _roleAdmin the address to grant `DEFAULT_ADMIN_ROLE` to - * @param _upgradeAdmin the address to grant `UPGRADE_ROLE` to - * @param _distributeAdmin the address to grant `DISTRIBUTE_ROLE` to - */ - function initialize(address _roleAdmin, address _upgradeAdmin, address _distributeAdmin) public initializer { - __UUPSUpgradeable_init(); - __AccessControl_init(); - _grantRole(DEFAULT_ADMIN_ROLE, _roleAdmin); - _grantRole(UPGRADE_ROLE, _upgradeAdmin); - _grantRole(DISTRIBUTE_ROLE, _distributeAdmin); - version = _VERSION0; - } - - /** - * @notice Function to be called when upgrading this contract. - * @dev Call this function as part of upgradeToAndCall(). - * This initial version of this function reverts. There is no situation - * in which it makes sense to upgrade to the V0 storage layout. - * Note that this function is permissionless. Future versions must - * compare the code version and the storage version and upgrade - * appropriately. As such, the code will revert if an attacker calls - * this function attempting a malicious upgrade. - * @ param _data ABI encoded data to be used as part of the contract storage upgrade. - */ - function upgradeStorage(bytes memory /* _data */) external virtual { - revert CanNotUpgradeToLowerOrSameVersion(version); - } - - /** - * @notice Allow any account to stake more value. - * @dev The amount being staked is the value of msg.value. - * @dev This function does not need re-entrancy guard as the add stake - * mechanism does not call out to any external function. - */ - function stake() external payable { - if (msg.value == 0) { - revert MustStakeMoreThanZero(); - } - _addStake(msg.sender, msg.value, false); - } - - /** - * @notice Allow any account to remove some or all of their own stake. - * @dev This function does not need re-entrancy guard as the state is updated - * prior to the call to the user's wallet. - * @param _amountToUnstake Amount of stake to remove. - */ - function unstake(uint256 _amountToUnstake) external { - StakeInfo storage stakeInfo = balances[msg.sender]; - uint256 currentStake = stakeInfo.stake; - if (currentStake < _amountToUnstake) { - revert UnstakeAmountExceedsBalance(_amountToUnstake, currentStake); - } - uint256 newBalance = currentStake - _amountToUnstake; - stakeInfo.stake = newBalance; - - emit StakeRemoved(msg.sender, _amountToUnstake, newBalance, block.timestamp); - - // slither-disable-next-line low-level-calls - (bool success, bytes memory returndata) = payable(msg.sender).call{value: _amountToUnstake}(""); - if (!success) { - // Look for revert reason and bubble it up if present. - // Revert reasons should contain an error selector, which is four bytes long. - if (returndata.length >= 4) { - // solhint-disable-next-line no-inline-assembly - assembly { - let returndata_size := mload(returndata) - revert(add(32, returndata), returndata_size) - } - } else { - revert UnstakeTransferFailed(); - } - } - } - - /** - * @notice Accounts with DISTRIBUTE_ROLE can distribute tokens to any set of accounts. - * @dev The total amount to distribute must match msg.value. - * This function does not need re-entrancy guard as the distribution mechanism - * does not call out to another contract. - * @param _recipientsAndAmounts An array of recipients to distribute value to and - * amounts to be distributed to each recipient. - */ - function distributeRewards( - AccountAmount[] calldata _recipientsAndAmounts - ) external payable onlyRole(DISTRIBUTE_ROLE) { - // Initial validity checks - if (msg.value == 0) { - revert MustDistributeMoreThanZero(); - } - uint256 len = _recipientsAndAmounts.length; - - // Distribute the value. - uint256 total = 0; - for (uint256 i = 0; i < len; i++) { - AccountAmount calldata accountAmount = _recipientsAndAmounts[i]; - uint256 amount = accountAmount.amount; - // Add stake, but require the acount to either currently be staking or have - // previously staked. - _addStake(accountAmount.account, amount, true); - total += amount; - } - - // Check that the total distributed matches the msg.value. - if (total != msg.value) { - revert DistributionAmountsDoNotMatchTotal(msg.value, total); - } - emit Distributed(msg.sender, msg.value, len); - } - - /** - * @notice Get the balance of an account. - * @param _account The account to return the balance for. - * @return _balance The balance of the account. - */ - function getBalance(address _account) external view returns (uint256 _balance) { - return balances[_account].stake; - } - - /** - * @notice Determine if an account has ever staked. - * @param _account The account to determine if they have staked - * @return _everStaked True if the account has ever staked. - */ - function hasStaked(address _account) external view returns (bool _everStaked) { - return balances[_account].hasStaked; - } - - /** - * @notice Get the length of the stakers array. - * @dev This will be equal to the number of staker accounts that have ever staked. - * Some of the accounts might have a zero balance, having staked and then - * unstaked. - * @return _len The length of the stakers array. - */ - function getNumStakers() external view returns (uint256 _len) { - return stakers.length; - } - - /** - * @notice Get the staker accounts from the stakers array. - * @dev Given the stakers list could grow arbitrarily long. To prevent out of memory or out of - * gas situations due to attempting to return a very large array, this function call specifies - * the start offset and number of accounts to be return. - * NOTE: This code will cause a panic if the start offset + number to return is greater than - * the length of the array. Use getNumStakers before calling this function to determine the - * length of the array. - * @param _startOffset First offset in the stakers array to return the account number for. - * @param _numberToReturn The number of accounts to return. - * @return _stakers A subset of the stakers array. - */ - function getStakers( - uint256 _startOffset, - uint256 _numberToReturn - ) external view returns (address[] memory _stakers) { - address[] memory stakerPartialArray = new address[](_numberToReturn); - for (uint256 i = 0; i < _numberToReturn; i++) { - stakerPartialArray[i] = stakers[_startOffset + i]; - } - return stakerPartialArray; - } - - /** - * @notice Add more stake to an account. - * @dev If the account has a zero balance prior to this call, add the account to the stakers array. - * @param _account Account to add stake to. - * @param _amount The amount of stake to add. - * @param _existingAccountsOnly If true, revert if the account has never been used. - */ - function _addStake(address _account, uint256 _amount, bool _existingAccountsOnly) private { - StakeInfo storage stakeInfo = balances[_account]; - uint256 currentStake = stakeInfo.stake; - if (!stakeInfo.hasStaked) { - if (_existingAccountsOnly) { - revert AttemptToDistributeToNewAccount(_account, _amount); - } - stakers.push(_account); - stakeInfo.hasStaked = true; - } - uint256 newBalance = currentStake + _amount; - stakeInfo.stake = newBalance; - emit StakeAdded(_account, _amount, newBalance, block.timestamp); - } - - // Override the _authorizeUpgrade function - // solhint-disable-next-line no-empty-blocks - function _authorizeUpgrade(address newImplementation) internal override onlyRole(UPGRADE_ROLE) {} - - /** - * @notice Prevent revoke or renounce role for the last DEFAULT_ADMIN_ROLE / the last role admin. - * @param _role The role to be renounced. - * @param _account Account to be revoked. - */ - function _revokeRole(bytes32 _role, address _account) internal override { - if (_role == DEFAULT_ADMIN_ROLE && getRoleMemberCount(_role) == 1) { - revert MustHaveOneRoleAdmin(); - } - super._revokeRole(_role, _account); - } - - /// @notice storage gap for additional variables for upgrades - // slither-disable-start unused-state - // solhint-disable-next-line var-name-mixedcase - uint256[50] private __StakeHolderGap; - // slither-disable-end unused-state -} diff --git a/contracts/staking/StakeHolderBase.sol b/contracts/staking/StakeHolderBase.sol new file mode 100644 index 00000000..7bfd7ec0 --- /dev/null +++ b/contracts/staking/StakeHolderBase.sol @@ -0,0 +1,139 @@ +// Copyright (c) Immutable Pty Ltd 2018 - 2025 +// SPDX-License-Identifier: Apache 2 +pragma solidity >=0.8.19 <0.8.29; + +import {UUPSUpgradeable} from "openzeppelin-contracts-upgradeable-4.9.3/proxy/utils/UUPSUpgradeable.sol"; +import {AccessControlEnumerableUpgradeable} from "openzeppelin-contracts-upgradeable-4.9.3/access/AccessControlEnumerableUpgradeable.sol"; +import {ReentrancyGuardUpgradeable} from "openzeppelin-contracts-upgradeable-4.9.3/security/ReentrancyGuardUpgradeable.sol"; +import {IStakeHolder} from "./IStakeHolder.sol"; +import {StakeHolderBase} from "./StakeHolderBase.sol"; + +/** + * @title StakeHolderBase: allows anyone to stake any amount of an ERC20 token and to then remove all or part of that stake. + * @dev The StakeHolderERC20 contract is designed to be upgradeable. + */ +abstract contract StakeHolderBase is IStakeHolder, AccessControlEnumerableUpgradeable, UUPSUpgradeable, ReentrancyGuardUpgradeable { + /// @notice Only UPGRADE_ROLE can upgrade the contract + bytes32 public constant UPGRADE_ROLE = bytes32("UPGRADE_ROLE"); + + /// @notice Only DISTRIBUTE_ROLE can call the distribute function + bytes32 public constant DISTRIBUTE_ROLE = bytes32("DISTRIBUTE_ROLE"); + + /// @notice Version 0 version number + uint256 internal constant _VERSION0 = 0; + + /// @notice Holds staking information for a single staker. + struct StakeInfo { + /// @notice Amount of stake. + uint256 stake; + /// @notice True if this account has ever staked. + bool hasStaked; + } + + /// @notice The amount of value owned by each staker + // solhint-disable-next-line private-vars-leading-underscore + mapping(address staker => StakeInfo stakeInfo) internal balances; + + /// @notice A list of all stakers who have ever staked. + /// @dev The list make contain stakers who have completely unstaked (that is, have + /// a balance of 0). This array is never re-ordered. As such, off-chain services + /// could cache the results of getStakers(). + // solhint-disable-next-line private-vars-leading-underscore + address[] internal stakers; + + /// @notice version number of the storage variable layout. + uint256 public version; + + /** + * @notice Initialises the upgradeable contract, setting up admin accounts. + * @param _roleAdmin the address to grant `DEFAULT_ADMIN_ROLE` to + * @param _upgradeAdmin the address to grant `UPGRADE_ROLE` to + * @param _distributeAdmin the address to grant `DISTRIBUTE_ROLE` to + */ + function __StakeHolderBase_init( + address _roleAdmin, + address _upgradeAdmin, + address _distributeAdmin + ) internal { + __UUPSUpgradeable_init(); + __AccessControl_init(); + __ReentrancyGuard_init(); + _grantRole(DEFAULT_ADMIN_ROLE, _roleAdmin); + _grantRole(UPGRADE_ROLE, _upgradeAdmin); + _grantRole(DISTRIBUTE_ROLE, _distributeAdmin); + version = _VERSION0; + } + + /** + * @notice Function to be called when upgrading this contract. + * @dev Call this function as part of upgradeToAndCall(). + * This initial version of this function reverts. There is no situation + * in which it makes sense to upgrade to the V0 storage layout. + * Note that this function is permissionless. Future versions must + * compare the code version and the storage version and upgrade + * appropriately. As such, the code will revert if an attacker calls + * this function attempting a malicious upgrade. + * @ param _data ABI encoded data to be used as part of the contract storage upgrade. + */ + function upgradeStorage(bytes memory /* _data */) external virtual { + revert CanNotUpgradeToLowerOrSameVersion(version); + } + + + /** + * @inheritdoc IStakeHolder + */ + function getBalance(address _account) external view override (IStakeHolder) returns (uint256 _balance) { + return balances[_account].stake; + } + + /** + * @inheritdoc IStakeHolder + */ + function hasStaked(address _account) external view override (IStakeHolder) returns (bool _everStaked) { + return balances[_account].hasStaked; + } + + /** + * @inheritdoc IStakeHolder + */ + function getNumStakers() external view override (IStakeHolder) returns (uint256 _len) { + return stakers.length; + } + + /** + * @inheritdoc IStakeHolder + */ + function getStakers( + uint256 _startOffset, + uint256 _numberToReturn + ) external view override (IStakeHolder) returns (address[] memory _stakers) { + address[] memory stakerPartialArray = new address[](_numberToReturn); + for (uint256 i = 0; i < _numberToReturn; i++) { + stakerPartialArray[i] = stakers[_startOffset + i]; + } + return stakerPartialArray; + } + + // Override the _authorizeUpgrade function + // solhint-disable-next-line no-empty-blocks + function _authorizeUpgrade(address newImplementation) internal override onlyRole(UPGRADE_ROLE) {} + + /** + * @notice Prevent revoke or renounce role for the last DEFAULT_ADMIN_ROLE / the last role admin. + * @param _role The role to be renounced. + * @param _account Account to be revoked. + */ + function _revokeRole(bytes32 _role, address _account) internal override { + if (_role == DEFAULT_ADMIN_ROLE && getRoleMemberCount(_role) == 1) { + revert MustHaveOneRoleAdmin(); + } + super._revokeRole(_role, _account); + } + + /// @notice storage gap for additional variables for upgrades + // slither-disable-start unused-state + // solhint-disable-next-line var-name-mixedcase + uint256[50] private __StakeHolderBaseGap; + // slither-disable-end unused-state +} diff --git a/contracts/staking/StakeHolderERC20.sol b/contracts/staking/StakeHolderERC20.sol index 0fa37b58..cff08a35 100644 --- a/contracts/staking/StakeHolderERC20.sol +++ b/contracts/staking/StakeHolderERC20.sol @@ -2,85 +2,21 @@ // SPDX-License-Identifier: Apache 2 pragma solidity >=0.8.19 <0.8.29; -import {UUPSUpgradeable} from "openzeppelin-contracts-upgradeable-4.9.3/proxy/utils/UUPSUpgradeable.sol"; -import {AccessControlEnumerableUpgradeable} from "openzeppelin-contracts-upgradeable-4.9.3/access/AccessControlEnumerableUpgradeable.sol"; +// import {UUPSUpgradeable} from "openzeppelin-contracts-upgradeable-4.9.3/proxy/utils/UUPSUpgradeable.sol"; +// import {AccessControlEnumerableUpgradeable} from "openzeppelin-contracts-upgradeable-4.9.3/access/AccessControlEnumerableUpgradeable.sol"; import {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 {ReentrancyGuardUpgradeable} from "openzeppelin-contracts-upgradeable-4.9.3/security/ReentrancyGuardUpgradeable.sol"; - -/// @notice Struct to combine an account and an amount. -struct AccountAmount { - address account; - uint256 amount; -} +import {IStakeHolder, StakeHolderBase} from "./StakeHolderBase.sol"; /** * @title StakeHolderERC20: allows anyone to stake any amount of an ERC20 token and to then remove all or part of that stake. * @dev The StakeHolderERC20 contract is designed to be upgradeable. */ -contract StakeHolderERC20 is AccessControlEnumerableUpgradeable, UUPSUpgradeable, ReentrancyGuardUpgradeable { +contract StakeHolderERC20 is StakeHolderBase { using SafeERC20Upgradeable for IERC20Upgradeable; - /// @notice Error: Attempting to upgrade contract storage to version 0. - error CanNotUpgradeToLowerOrSameVersion(uint256 _storageVersion); - - /// @notice Error: Attempting to renounce the last role admin / default admin. - error MustHaveOneRoleAdmin(); - - /// @notice Error: Attempting to stake with zero value. - error MustStakeMoreThanZero(); - - /// @notice Error: Attempting to distribute zero value. - error MustDistributeMoreThanZero(); - - /// @notice Error: Attempting to unstake amount greater than the balance. - error UnstakeAmountExceedsBalance(uint256 _amountToUnstake, uint256 _currentStake); - - /// @notice Error: Distributions can only be made to accounts that have staked. - error AttemptToDistributeToNewAccount(address _account, uint256 _amount); - - /// @notice Event when an amount has been staked or when an amount is distributed to an account. - event StakeAdded(address _staker, uint256 _amountAdded, uint256 _newBalance, uint256 _timestamp); - - /// @notice Event when an amount has been unstaked. - event StakeRemoved(address _staker, uint256 _amountRemoved, uint256 _newBalance, uint256 _timestamp); - - /// @notice Event summarising a distribution. There will also be one StakeAdded event for each recipient. - event Distributed(address _distributor, uint256 _totalDistribution, uint256 _numRecipients); - - /// @notice Only UPGRADE_ROLE can upgrade the contract - bytes32 public constant UPGRADE_ROLE = bytes32("UPGRADE_ROLE"); - - /// @notice Only DISTRIBUTE_ROLE can call the distribute function - bytes32 public constant DISTRIBUTE_ROLE = bytes32("DISTRIBUTE_ROLE"); - - /// @notice Version 0 version number - uint256 private constant _VERSION0 = 0; - - /// @notice Holds staking information for a single staker. - struct StakeInfo { - /// @notice Amount of stake. - uint256 stake; - /// @notice True if this account has ever staked. - bool hasStaked; - } - - /// @notice The amount of value owned by each staker - // solhint-disable-next-line private-vars-leading-underscore - mapping(address staker => StakeInfo stakeInfo) private balances; - /// @notice The token used for staking. - IERC20Upgradeable public token; - - /// @notice A list of all stakers who have ever staked. - /// @dev The list make contain stakers who have completely unstaked (that is, have - /// a balance of 0). This array is never re-ordered. As such, off-chain services - /// could cache the results of getStakers(). - // solhint-disable-next-line private-vars-leading-underscore - address[] private stakers; - - /// @notice version number of the storage variable layout. - uint256 public version; + IERC20Upgradeable internal token; /** * @notice Initialises the upgradeable contract, setting up admin accounts. @@ -94,36 +30,18 @@ contract StakeHolderERC20 is AccessControlEnumerableUpgradeable, UUPSUpgradeable address _distributeAdmin, address _token ) public initializer { - __UUPSUpgradeable_init(); - __AccessControl_init(); - __ReentrancyGuard_init(); - _grantRole(DEFAULT_ADMIN_ROLE, _roleAdmin); - _grantRole(UPGRADE_ROLE, _upgradeAdmin); - _grantRole(DISTRIBUTE_ROLE, _distributeAdmin); + __StakeHolderBase_init(_roleAdmin, _upgradeAdmin, _distributeAdmin); token = IERC20Upgradeable(_token); - version = _VERSION0; - } - - /** - * @notice Function to be called when upgrading this contract. - * @dev Call this function as part of upgradeToAndCall(). - * This initial version of this function reverts. There is no situation - * in which it makes sense to upgrade to the V0 storage layout. - * Note that this function is permissionless. Future versions must - * compare the code version and the storage version and upgrade - * appropriately. As such, the code will revert if an attacker calls - * this function attempting a malicious upgrade. - * @ param _data ABI encoded data to be used as part of the contract storage upgrade. - */ - function upgradeStorage(bytes memory /* _data */) external virtual { - revert CanNotUpgradeToLowerOrSameVersion(version); } /** * @notice Allow any account to stake more value. * @param _amount The amount of tokens to be staked. */ - function stake(uint256 _amount) external nonReentrant { + function stake(uint256 _amount) external payable nonReentrant { + if (msg.value != 0) { + revert NonPayable(); + } if (_amount == 0) { revert MustStakeMoreThanZero(); } @@ -156,7 +74,11 @@ contract StakeHolderERC20 is AccessControlEnumerableUpgradeable, UUPSUpgradeable */ function distributeRewards( AccountAmount[] calldata _recipientsAndAmounts - ) external nonReentrant onlyRole(DISTRIBUTE_ROLE) { + ) external payable nonReentrant onlyRole(DISTRIBUTE_ROLE) { + if (msg.value != 0) { + revert NonPayable(); + } + // Initial validity checks uint256 len = _recipientsAndAmounts.length; if (len == 0) { @@ -173,61 +95,20 @@ contract StakeHolderERC20 is AccessControlEnumerableUpgradeable, UUPSUpgradeable _addStake(accountAmount.account, amount, true); total += amount; } + if (total == 0) { + revert MustDistributeMoreThanZero(); + } token.safeTransferFrom(msg.sender, address(this), total); emit Distributed(msg.sender, total, len); } /** - * @notice Get the balance of an account. - * @param _account The account to return the balance for. - * @return _balance The balance of the account. - */ - function getBalance(address _account) external view returns (uint256 _balance) { - return balances[_account].stake; - } - - /** - * @notice Determine if an account has ever staked. - * @param _account The account to determine if they have staked - * @return _everStaked True if the account has ever staked. + * @inheritdoc IStakeHolder */ - function hasStaked(address _account) external view returns (bool _everStaked) { - return balances[_account].hasStaked; + function getToken() external view returns(address) { + return address(token); } - /** - * @notice Get the length of the stakers array. - * @dev This will be equal to the number of staker accounts that have ever staked. - * Some of the accounts might have a zero balance, having staked and then - * unstaked. - * @return _len The length of the stakers array. - */ - function getNumStakers() external view returns (uint256 _len) { - return stakers.length; - } - - /** - * @notice Get the staker accounts from the stakers array. - * @dev Given the stakers list could grow arbitrarily long. To prevent out of memory or out of - * gas situations due to attempting to return a very large array, this function call specifies - * the start offset and number of accounts to be return. - * NOTE: This code will cause a panic if the start offset + number to return is greater than - * the length of the array. Use getNumStakers before calling this function to determine the - * length of the array. - * @param _startOffset First offset in the stakers array to return the account number for. - * @param _numberToReturn The number of accounts to return. - * @return _stakers A subset of the stakers array. - */ - function getStakers( - uint256 _startOffset, - uint256 _numberToReturn - ) external view returns (address[] memory _stakers) { - address[] memory stakerPartialArray = new address[](_numberToReturn); - for (uint256 i = 0; i < _numberToReturn; i++) { - stakerPartialArray[i] = stakers[_startOffset + i]; - } - return stakerPartialArray; - } /** * @notice Add more stake to an account. @@ -251,22 +132,6 @@ contract StakeHolderERC20 is AccessControlEnumerableUpgradeable, UUPSUpgradeable emit StakeAdded(_account, _amount, newBalance, block.timestamp); } - // Override the _authorizeUpgrade function - // solhint-disable-next-line no-empty-blocks - function _authorizeUpgrade(address newImplementation) internal override onlyRole(UPGRADE_ROLE) {} - - /** - * @notice Prevent revoke or renounce role for the last DEFAULT_ADMIN_ROLE / the last role admin. - * @param _role The role to be renounced. - * @param _account Account to be revoked. - */ - function _revokeRole(bytes32 _role, address _account) internal override { - if (_role == DEFAULT_ADMIN_ROLE && getRoleMemberCount(_role) == 1) { - revert MustHaveOneRoleAdmin(); - } - super._revokeRole(_role, _account); - } - /// @notice storage gap for additional variables for upgrades // slither-disable-start unused-state // solhint-disable-next-line var-name-mixedcase diff --git a/contracts/staking/StakeHolderNative.sol b/contracts/staking/StakeHolderNative.sol new file mode 100644 index 00000000..98aa57a0 --- /dev/null +++ b/contracts/staking/StakeHolderNative.sol @@ -0,0 +1,150 @@ +// Copyright (c) Immutable Pty Ltd 2018 - 2025 +// SPDX-License-Identifier: Apache 2 +pragma solidity >=0.8.19 <0.8.29; + +// import {UUPSUpgradeable} from "openzeppelin-contracts-upgradeable-4.9.3/proxy/utils/UUPSUpgradeable.sol"; +// import {AccessControlEnumerableUpgradeable} from "openzeppelin-contracts-upgradeable-4.9.3/access/AccessControlEnumerableUpgradeable.sol"; +import {IStakeHolder, StakeHolderBase} from "./StakeHolderBase.sol"; + + +/** + * @title StakeHolder: allows anyone to stake any amount of native IMX and to then remove all or part of that stake. + * @dev The StakeHolder contract is designed to be upgradeable. + */ +contract StakeHolderNative is StakeHolderBase { + /// @notice Error: Unstake transfer failed. + error UnstakeTransferFailed(); + + /** + * @notice Initialises the upgradeable contract, setting up admin accounts. + * @param _roleAdmin the address to grant `DEFAULT_ADMIN_ROLE` to + * @param _upgradeAdmin the address to grant `UPGRADE_ROLE` to + * @param _distributeAdmin the address to grant `DISTRIBUTE_ROLE` to + */ + function initialize(address _roleAdmin, address _upgradeAdmin, address _distributeAdmin) public initializer { + __StakeHolderBase_init(_roleAdmin, _upgradeAdmin, _distributeAdmin); + } + + /** + * @notice Allow any account to stake more value. + * @dev The amount being staked is the value of msg.value. + * @dev This function does not need re-entrancy guard as the add stake + * mechanism does not call out to any external function. + */ + function stake(uint256 _amount) external payable { + if (msg.value == 0) { + revert MustStakeMoreThanZero(); + } + if (_amount != msg.value) { + revert MismatchMsgValueAmount(msg.value, _amount); + } + _addStake(msg.sender, msg.value, false); + } + + /** + * @notice Allow any account to remove some or all of their own stake. + * @dev This function does not need re-entrancy guard as the state is updated + * prior to the call to the user's wallet. + * @param _amountToUnstake Amount of stake to remove. + */ + function unstake(uint256 _amountToUnstake) external { + StakeInfo storage stakeInfo = balances[msg.sender]; + uint256 currentStake = stakeInfo.stake; + if (currentStake < _amountToUnstake) { + revert UnstakeAmountExceedsBalance(_amountToUnstake, currentStake); + } + uint256 newBalance = currentStake - _amountToUnstake; + stakeInfo.stake = newBalance; + + emit StakeRemoved(msg.sender, _amountToUnstake, newBalance, block.timestamp); + + // slither-disable-next-line low-level-calls + (bool success, bytes memory returndata) = payable(msg.sender).call{value: _amountToUnstake}(""); + if (!success) { + // Look for revert reason and bubble it up if present. + // Revert reasons should contain an error selector, which is four bytes long. + if (returndata.length >= 4) { + // solhint-disable-next-line no-inline-assembly + assembly { + let returndata_size := mload(returndata) + revert(add(32, returndata), returndata_size) + } + } else { + revert UnstakeTransferFailed(); + } + } + } + + /** + * @notice Accounts with DISTRIBUTE_ROLE can distribute tokens to any set of accounts. + * @dev The total amount to distribute must match msg.value. + * This function does not need re-entrancy guard as the distribution mechanism + * does not call out to another contract. + * @param _recipientsAndAmounts An array of recipients to distribute value to and + * amounts to be distributed to each recipient. + */ + function distributeRewards( + AccountAmount[] calldata _recipientsAndAmounts + ) external payable onlyRole(DISTRIBUTE_ROLE) { + // Initial validity checks + if (msg.value == 0) { + revert MustDistributeMoreThanZero(); + } + uint256 len = _recipientsAndAmounts.length; + + // Distribute the value. + uint256 total = 0; + for (uint256 i = 0; i < len; i++) { + AccountAmount calldata accountAmount = _recipientsAndAmounts[i]; + uint256 amount = accountAmount.amount; + // Add stake, but require the acount to either currently be staking or have + // previously staked. + _addStake(accountAmount.account, amount, true); + total += amount; + } + + // Check that the total distributed matches the msg.value. + if (total != msg.value) { + revert DistributionAmountsDoNotMatchTotal(msg.value, total); + } + emit Distributed(msg.sender, msg.value, len); + } + + + /** + * @inheritdoc IStakeHolder + */ + function getToken() external pure returns(address) { + return address(0); + } + + + /** + * @notice Add more stake to an account. + * @dev If the account has a zero balance prior to this call, add the account to the stakers array. + * @param _account Account to add stake to. + * @param _amount The amount of stake to add. + * @param _existingAccountsOnly If true, revert if the account has never been used. + */ + function _addStake(address _account, uint256 _amount, bool _existingAccountsOnly) private { + StakeInfo storage stakeInfo = balances[_account]; + uint256 currentStake = stakeInfo.stake; + if (!stakeInfo.hasStaked) { + if (_existingAccountsOnly) { + revert AttemptToDistributeToNewAccount(_account, _amount); + } + stakers.push(_account); + stakeInfo.hasStaked = true; + } + uint256 newBalance = currentStake + _amount; + stakeInfo.stake = newBalance; + emit StakeAdded(_account, _amount, newBalance, block.timestamp); + } + + + /// @notice storage gap for additional variables for upgrades + // slither-disable-start unused-state + // solhint-disable-next-line var-name-mixedcase + uint256[50] private __StakeHolderGap; + // slither-disable-end unused-state +} diff --git a/script/staking/DeployStakeHolder.sol b/script/staking/DeployStakeHolderNative.sol similarity index 91% rename from script/staking/DeployStakeHolder.sol rename to script/staking/DeployStakeHolderNative.sol index 8dde34b6..af5b4d49 100644 --- a/script/staking/DeployStakeHolder.sol +++ b/script/staking/DeployStakeHolderNative.sol @@ -4,7 +4,7 @@ pragma solidity ^0.8.20; import "forge-std/Test.sol"; import "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; -import {StakeHolder} from "../../contracts/staking/StakeHolder.sol"; +import {StakeHolderNative} from "../../contracts/staking/StakeHolderNative.sol"; /** * @title IDeployer Interface @@ -43,7 +43,7 @@ struct StakeHolderContractArgs { * @dev deploy() is the function the script should call. * For more details on deployment see ../../contracts/staking/README.md */ -contract DeployStakeHolder is Test { +contract DeployStakeHolderNative is Test { function testDeploy() external { /// @dev Fork the Immutable zkEVM testnet for this test string memory rpcURL = "https://rpc.testnet.immutable.com"; @@ -64,7 +64,7 @@ contract DeployStakeHolder is Test { }); // Run deployment against forked testnet - StakeHolder deployedContract = _deploy(deploymentArgs, stakeHolderContractArgs); + StakeHolderNative deployedContract = _deploy(deploymentArgs, stakeHolderContractArgs); assertTrue(deployedContract.hasRole(deployedContract.UPGRADE_ROLE(), stakeHolderContractArgs.upgradeAdmin), "Upgrade admin should have upgrade role"); assertTrue(deployedContract.hasRole(deployedContract.DEFAULT_ADMIN_ROLE(), stakeHolderContractArgs.roleAdmin), "Role admin should have default admin role"); @@ -91,7 +91,7 @@ contract DeployStakeHolder is Test { function _deploy(DeploymentArgs memory deploymentArgs, StakeHolderContractArgs memory stakeHolderContractArgs) internal - returns (StakeHolder stakeHolderContract) + returns (StakeHolderNative stakeHolderContract) { IDeployer ownableCreate3 = IDeployer(deploymentArgs.factory); @@ -99,7 +99,7 @@ contract DeployStakeHolder is Test { // That is: StakeHolder impl = new StakeHolder(); // Create deployment bytecode and encode constructor args bytes memory deploymentBytecode = abi.encodePacked( - type(StakeHolder).creationCode + type(StakeHolderNative).creationCode ); bytes32 saltBytes = keccak256(abi.encode(deploymentArgs.salt1)); @@ -110,7 +110,7 @@ contract DeployStakeHolder is Test { // Create init data for teh ERC1967 Proxy bytes memory initData = abi.encodeWithSelector( - StakeHolder.initialize.selector, stakeHolderContractArgs.roleAdmin, + StakeHolderNative.initialize.selector, stakeHolderContractArgs.roleAdmin, stakeHolderContractArgs.upgradeAdmin, stakeHolderContractArgs.distributeAdmin ); @@ -127,6 +127,6 @@ contract DeployStakeHolder is Test { vm.startBroadcast(deploymentArgs.signer); address stakeHolderContractAddress = ownableCreate3.deploy(deploymentBytecode, saltBytes); vm.stopBroadcast(); - stakeHolderContract = StakeHolder(stakeHolderContractAddress); + stakeHolderContract = StakeHolderNative(stakeHolderContractAddress); } } diff --git a/test/staking/README.md b/test/staking/README.md index d45cc28e..1fff458e 100644 --- a/test/staking/README.md +++ b/test/staking/README.md @@ -50,3 +50,54 @@ Operational tests (in [StakeHolderOperational.t.sol](../../contracts/staking/Sta | testDistributeToUnusedAccount | Attempt to distribute rewards to an account that has never staked. | No | Yes | | testDistributeBadAuth | Attempt to distribute rewards using an unauthorised account. | No | Yes | + +## [StakeHolderERC20.sol](../../contracts/staking/StakeHolderERC20.sol) + +Initialize testing (in [StakeHolderERC20Init.t.sol](../../contracts/staking/StakeHolderERC20Init.t.sol)): + +| Test name | Description | Happy Case | Implemented | +|---------------------------------|------------------------------------------------------------|------------|-------------| +| testGetVersion | Check version number. | Yes | Yes | +| testStakersInit | Check initial staker's array length is zero. | Yes | Yes | +| testAdmins | Check that role and upgrade admin have been set correctly. | Yes | Yes | + + +Configuration tests (in [StakeHolderERC20Config.t.sol](../../contracts/staking/StakeHolderERC20Config.t.sol)):: + +| Test name | Description | Happy Case | Implemented | +|---------------------------------|------------------------------------------------------------|------------|-------------| +| testUpgradeToV1 | Check upgrade process. | Yes | Yes | +| testUpgradeToV0 | Check upgrade to V0 fails. | No | Yes | +| testDowngradeV1ToV0 | Check downgrade from V1 to V0 fails. | No | Yes | +| testUpgradeAuthFail | Try upgrade from account that doesn't have upgrade role. | No | Yes | +| testAddRevokeRenounceRoleAdmin | Check adding, removing, and renouncing role admins. | Yes | Yes | +| testAddRevokeRenounceUpgradeAdmin | Check adding, removing, and renouncing upgrade admins. | Yes | Yes | +| testRenounceLastRoleAdmin | Check that attempting to renounce last role admin fails. | No | Yes | +| testRevokeLastRoleAdmin | Check that attempting to revoke last role admin fails. | No | Yes | +| testRoleAdminAuthFail | Attempt to add an upgrade admin from a non-role admin. | No | Yes | + + +Operational tests (in [StakeHolderERC20Operational.t.sol](../../contracts/staking/StakeHolderERC20Operational.t.sol)):: + +| Test name | Description | Happy Case | Implemented | +|--------------------------------|-------------------------------------------------------------|------------|-------------| +| testStake | Stake some value. | Yes | Yes | +| testStakeTwice | Stake some value and then some more value. | Yes | Yes | +| testStakeZeroValue | Stake with msg.value = 0. | No | Yes | +| testMultipleStakers | Check multiple entities staking works. | Yes | Yes | +| testUnstake | Check that an account can unstake all their value. | Yes | Yes | +| testUnstakeTooMuch | Attempt to unstake greater than balance. | No | Yes | +| testUnstakePartial | Check that an account can unstake part of their value. | Yes | Yes | +| testUnstakeMultiple | Unstake in multiple parts. | Yes | Yes | +| testUnstakeReentrantAttack | Attempt a reentrancy attack on unstaking. | No | Yes | +| testRestaking | Stake, unstake, restake. | Yes | Yes | +| testGetStakers | Check getStakers in various scenarios. | Yes | Yes | +| testGetStakersOutOfRange | Check getStakers for out of range request. | No | Yes | +| testDistributeRewardsOne | Distribute rewards to one account. | Yes | Yes | +| testDistributeRewardsMultiple | Distribute rewards to multiple accounts. | Yes | Yes | +| testDistributeZeroReward | Fail when distributing zero reward. | No | Yes | +| testDistributeMismatch | Fail if the total to distribute does not equal msg.value. | No | Yes | +| testDistributeToEmptyAccount | Stake, unstake, distribute rewards. | Yes | Yes | +| testDistributeToUnusedAccount | Attempt to distribute rewards to an account that has never staked. | No | Yes | +| testDistributeBadAuth | Attempt to distribute rewards using an unauthorised account. | No | Yes | + diff --git a/test/staking/StakeHolderAttackWallet.sol b/test/staking/StakeHolderAttackWallet.sol index 33538771..6180853d 100644 --- a/test/staking/StakeHolderAttackWallet.sol +++ b/test/staking/StakeHolderAttackWallet.sol @@ -2,13 +2,13 @@ // SPDX-License-Identifier: Apache 2.0 pragma solidity >=0.8.19 <0.8.29; -import {StakeHolder} from "../../contracts/staking/StakeHolder.sol"; +import {StakeHolderNative} from "../../contracts/staking/StakeHolderNative.sol"; // Wallet designed to attempt reentrancy attacks contract StakeHolderAttackWallet { - StakeHolder public stakeHolder; + StakeHolderNative public stakeHolder; constructor(address _stakeHolder) { - stakeHolder = StakeHolder(_stakeHolder); + stakeHolder = StakeHolderNative(_stakeHolder); } receive() external payable { // Assumung the call to unstake is for a "whole" number, say 1 ether, then @@ -20,7 +20,7 @@ contract StakeHolderAttackWallet { } } function stake(uint256 _amount) external { - stakeHolder.stake{value: _amount}(); + stakeHolder.stake{value: _amount}(_amount); } function unstake(uint256 _amount) external { stakeHolder.unstake(_amount); diff --git a/test/staking/StakeHolderBase.t.sol b/test/staking/StakeHolderBase.t.sol index f46afc84..0fa8fb60 100644 --- a/test/staking/StakeHolderBase.t.sol +++ b/test/staking/StakeHolderBase.t.sol @@ -6,19 +6,16 @@ pragma solidity >=0.8.19 <0.8.29; // solhint-disable-next-line no-global-import import "forge-std/Test.sol"; -import {StakeHolder} from "../../contracts/staking/StakeHolder.sol"; +import {IStakeHolder} from "../../contracts/staking/IStakeHolder.sol"; +import {StakeHolderNative} from "../../contracts/staking/StakeHolderNative.sol"; -import {ERC1967Proxy} from "openzeppelin-contracts-4.9.3/proxy/ERC1967/ERC1967Proxy.sol"; - - -contract StakeHolderBaseTest is Test { +abstract contract StakeHolderBaseTest is Test { bytes32 public defaultAdminRole; bytes32 public upgradeRole; bytes32 public distributeRole; - ERC1967Proxy public proxy; - StakeHolder public stakeHolder; + IStakeHolder public stakeHolder; address public roleAdmin; address public upgradeAdmin; @@ -29,7 +26,7 @@ contract StakeHolderBaseTest is Test { address public staker3; address public bank; - function setUp() public { + function setUp() public virtual { roleAdmin = makeAddr("RoleAdmin"); upgradeAdmin = makeAddr("UpgradeAdmin"); distributeAdmin = makeAddr("DistributeAdmin"); @@ -39,17 +36,9 @@ contract StakeHolderBaseTest is Test { staker3 = makeAddr("Staker3"); bank = makeAddr("bank"); - StakeHolder impl = new StakeHolder(); - - bytes memory initData = abi.encodeWithSelector( - StakeHolder.initialize.selector, roleAdmin, upgradeAdmin, distributeAdmin - ); - - proxy = new ERC1967Proxy(address(impl), initData); - stakeHolder = StakeHolder(address(proxy)); - - defaultAdminRole = stakeHolder.DEFAULT_ADMIN_ROLE(); - upgradeRole = stakeHolder.UPGRADE_ROLE(); - distributeRole = stakeHolder.DISTRIBUTE_ROLE(); + StakeHolderNative temp = new StakeHolderNative(); + defaultAdminRole = temp.DEFAULT_ADMIN_ROLE(); + upgradeRole = temp.UPGRADE_ROLE(); + distributeRole = temp.DISTRIBUTE_ROLE(); } } diff --git a/test/staking/StakeHolderConfig.t.sol b/test/staking/StakeHolderConfigBase.t.sol similarity index 63% rename from test/staking/StakeHolderConfig.t.sol rename to test/staking/StakeHolderConfigBase.t.sol index 5401dcd4..de4511d8 100644 --- a/test/staking/StakeHolderConfig.t.sol +++ b/test/staking/StakeHolderConfigBase.t.sol @@ -4,59 +4,55 @@ pragma solidity >=0.8.19 <0.8.29; // solhint-disable-next-line no-global-import import "forge-std/Test.sol"; -import {StakeHolder} from "../../contracts/staking/StakeHolder.sol"; +import {IStakeHolder} from "../../contracts/staking/IStakeHolder.sol"; +import {StakeHolderBase} from "../../contracts/staking/StakeHolderBase.sol"; import {StakeHolderBaseTest} from "./StakeHolderBase.t.sol"; -contract StakeHolderV2 is StakeHolder { - function upgradeStorage(bytes memory /* _data */) external override { - version = 1; - } -} -contract StakeHolderConfigTest is StakeHolderBaseTest { +abstract contract StakeHolderConfigBaseTest is StakeHolderBaseTest { function testUpgradeToV1() public { - StakeHolderV2 v2Impl = new StakeHolderV2(); - bytes memory initData = abi.encodeWithSelector(StakeHolder.upgradeStorage.selector, bytes("")); + IStakeHolder v2Impl = _deployV2(); + bytes memory initData = abi.encodeWithSelector(StakeHolderBase.upgradeStorage.selector, bytes("")); vm.prank(upgradeAdmin); - stakeHolder.upgradeToAndCall(address(v2Impl), initData); + StakeHolderBase(address(stakeHolder)).upgradeToAndCall(address(v2Impl), initData); uint256 ver = stakeHolder.version(); assertEq(ver, 1, "Upgrade did not upgrade version"); } function testUpgradeToV0() public { - StakeHolder v1Impl = new StakeHolder(); - bytes memory initData = abi.encodeWithSelector(StakeHolder.upgradeStorage.selector, bytes("")); - vm.expectRevert(abi.encodeWithSelector(StakeHolder.CanNotUpgradeToLowerOrSameVersion.selector, 0)); + IStakeHolder v1Impl = _deployV1(); + bytes memory initData = abi.encodeWithSelector(StakeHolderBase.upgradeStorage.selector, bytes("")); + vm.expectRevert(abi.encodeWithSelector(IStakeHolder.CanNotUpgradeToLowerOrSameVersion.selector, 0)); vm.prank(upgradeAdmin); - stakeHolder.upgradeToAndCall(address(v1Impl), initData); + StakeHolderBase(address(stakeHolder)).upgradeToAndCall(address(v1Impl), initData); } function testDowngradeV1ToV0() public { // Upgrade from V0 to V1 - StakeHolderV2 v2Impl = new StakeHolderV2(); - bytes memory initData = abi.encodeWithSelector(StakeHolder.upgradeStorage.selector, bytes("")); + IStakeHolder v2Impl = _deployV2(); + bytes memory initData = abi.encodeWithSelector(StakeHolderBase.upgradeStorage.selector, bytes("")); vm.prank(upgradeAdmin); - stakeHolder.upgradeToAndCall(address(v2Impl), initData); + StakeHolderBase(address(stakeHolder)).upgradeToAndCall(address(v2Impl), initData); // Attempt to downgrade from V1 to V0. - StakeHolder v1Impl = new StakeHolder(); - vm.expectRevert(abi.encodeWithSelector(StakeHolder.CanNotUpgradeToLowerOrSameVersion.selector, 1)); + IStakeHolder v1Impl = _deployV1(); + vm.expectRevert(abi.encodeWithSelector(IStakeHolder.CanNotUpgradeToLowerOrSameVersion.selector, 1)); vm.prank(upgradeAdmin); - stakeHolder.upgradeToAndCall(address(v1Impl), initData); + StakeHolderBase(address(stakeHolder)).upgradeToAndCall(address(v1Impl), initData); } function testUpgradeAuthFail() public { - StakeHolderV2 v2Impl = new StakeHolderV2(); - bytes memory initData = abi.encodeWithSelector(StakeHolder.upgradeStorage.selector, bytes("")); + IStakeHolder v2Impl = _deployV2(); + bytes memory initData = abi.encodeWithSelector(StakeHolderBase.upgradeStorage.selector, bytes("")); // Error will be of the form: // AccessControl: account 0x7fa9385be102ac3eac297483dd6233d62b3e1496 is missing role 0x555047524144455f524f4c450000000000000000000000000000000000000000 vm.expectRevert(); - stakeHolder.upgradeToAndCall(address(v2Impl), initData); + StakeHolderBase(address(stakeHolder)).upgradeToAndCall(address(v2Impl), initData); } function testAddRevokeRenounceRoleAdmin() public { - bytes32 role = stakeHolder.DEFAULT_ADMIN_ROLE(); + bytes32 role = defaultAdminRole; address newRoleAdmin = makeAddr("NewRoleAdmin"); vm.prank(roleAdmin); stakeHolder.grantRole(role, newRoleAdmin); @@ -83,25 +79,29 @@ contract StakeHolderConfigTest is StakeHolderBaseTest { } function testRenounceLastRoleAdmin() public { - bytes32 role = stakeHolder.DEFAULT_ADMIN_ROLE(); - vm.expectRevert(abi.encodeWithSelector(StakeHolder.MustHaveOneRoleAdmin.selector)); + bytes32 role = defaultAdminRole; + vm.expectRevert(abi.encodeWithSelector(IStakeHolder.MustHaveOneRoleAdmin.selector)); vm.prank(roleAdmin); stakeHolder.renounceRole(role, roleAdmin); } function testRevokeLastRoleAdmin() public { - bytes32 role = stakeHolder.DEFAULT_ADMIN_ROLE(); - vm.expectRevert(abi.encodeWithSelector(StakeHolder.MustHaveOneRoleAdmin.selector)); + bytes32 role = defaultAdminRole; + vm.expectRevert(abi.encodeWithSelector(IStakeHolder.MustHaveOneRoleAdmin.selector)); vm.prank(roleAdmin); stakeHolder.revokeRole(role, roleAdmin); } function testRoleAdminAuthFail () public { - bytes32 role = stakeHolder.DEFAULT_ADMIN_ROLE(); + bytes32 role = defaultAdminRole; address newRoleAdmin = makeAddr("NewRoleAdmin"); // Error will be of the form: // AccessControl: account 0x7fa9385be102ac3eac297483dd6233d62b3e1496 is missing role 0x555047524144455f524f4c450000000000000000000000000000000000000000 vm.expectRevert(); stakeHolder.grantRole(role, newRoleAdmin); } + + + function _deployV1() internal virtual returns(IStakeHolder); + function _deployV2() internal virtual returns(IStakeHolder); } diff --git a/test/staking/StakeHolderConfigERC20.t.sol b/test/staking/StakeHolderConfigERC20.t.sol new file mode 100644 index 00000000..95ab41a1 --- /dev/null +++ b/test/staking/StakeHolderConfigERC20.t.sol @@ -0,0 +1,41 @@ +// Copyright Immutable Pty Ltd 2018 - 2024 +// SPDX-License-Identifier: Apache 2.0 +pragma solidity >=0.8.19 <0.8.29; + +// solhint-disable-next-line no-global-import +import "forge-std/Test.sol"; +import {StakeHolderERC20} from "../../contracts/staking/StakeHolderERC20.sol"; +import {IStakeHolder} from "../../contracts/staking/IStakeHolder.sol"; +import {StakeHolderBase} from "../../contracts/staking/StakeHolderBase.sol"; +import {StakeHolderConfigBaseTest} from "./StakeHolderConfigBase.t.sol"; +import {ERC1967Proxy} from "openzeppelin-contracts-4.9.3/proxy/ERC1967/ERC1967Proxy.sol"; + +contract StakeHolderERC20V2 is StakeHolderERC20 { + function upgradeStorage(bytes memory /* _data */) external override(StakeHolderBase) { + version = 1; + } +} + +contract StakeHolderConfigERC20Test is StakeHolderConfigBaseTest { + + function setUp() public override { + super.setUp(); + + StakeHolderERC20 impl = new StakeHolderERC20(); + + bytes memory initData = abi.encodeWithSelector( + StakeHolderERC20.initialize.selector, roleAdmin, upgradeAdmin, distributeAdmin, address(0) + ); + + ERC1967Proxy proxy = new ERC1967Proxy(address(impl), initData); + stakeHolder = IStakeHolder(address(proxy)); + } + + function _deployV1() internal override returns(IStakeHolder) { + return IStakeHolder(address(new StakeHolderERC20())); + } + + function _deployV2() internal override returns(IStakeHolder) { + return IStakeHolder(address(new StakeHolderERC20V2())); + } +} \ No newline at end of file diff --git a/test/staking/StakeHolderConfigNative.t.sol b/test/staking/StakeHolderConfigNative.t.sol new file mode 100644 index 00000000..33873b14 --- /dev/null +++ b/test/staking/StakeHolderConfigNative.t.sol @@ -0,0 +1,43 @@ +// Copyright Immutable Pty Ltd 2018 - 2024 +// SPDX-License-Identifier: Apache 2.0 +pragma solidity >=0.8.19 <0.8.29; + +// solhint-disable-next-line no-global-import +import "forge-std/Test.sol"; +import {StakeHolderNative} from "../../contracts/staking/StakeHolderNative.sol"; +import {IStakeHolder} from "../../contracts/staking/IStakeHolder.sol"; +import {StakeHolderBase} from "../../contracts/staking/StakeHolderBase.sol"; +import {StakeHolderConfigBaseTest} from "./StakeHolderConfigBase.t.sol"; +import {ERC1967Proxy} from "openzeppelin-contracts-4.9.3/proxy/ERC1967/ERC1967Proxy.sol"; + +contract StakeHolderNativeV2 is StakeHolderNative { + function upgradeStorage(bytes memory /* _data */) external override(StakeHolderBase) { + version = 1; + } +} + + +contract StakeHolderConfigNativeTest is StakeHolderConfigBaseTest { + + function setUp() public override { + super.setUp(); + + StakeHolderNative impl = new StakeHolderNative(); + + bytes memory initData = abi.encodeWithSelector( + StakeHolderNative.initialize.selector, roleAdmin, upgradeAdmin, distributeAdmin + ); + + ERC1967Proxy proxy = new ERC1967Proxy(address(impl), initData); + stakeHolder = IStakeHolder(address(proxy)); + } + + function _deployV1() internal override returns(IStakeHolder) { + return IStakeHolder(address(new StakeHolderNative())); + } + + function _deployV2() internal override returns(IStakeHolder) { + return IStakeHolder(address(new StakeHolderNativeV2())); + } + +} diff --git a/test/staking/StakeHolderInit.t.sol b/test/staking/StakeHolderInitBase.t.sol similarity index 90% rename from test/staking/StakeHolderInit.t.sol rename to test/staking/StakeHolderInitBase.t.sol index 92d6c4fd..2da06e66 100644 --- a/test/staking/StakeHolderInit.t.sol +++ b/test/staking/StakeHolderInitBase.t.sol @@ -4,10 +4,9 @@ pragma solidity >=0.8.19 <0.8.29; // solhint-disable-next-line no-global-import import "forge-std/Test.sol"; -import {StakeHolder} from "../../contracts/staking/StakeHolder.sol"; import {StakeHolderBaseTest} from "./StakeHolderBase.t.sol"; -contract StakeHolderInitTest is StakeHolderBaseTest { +abstract contract StakeHolderInitBaseTest is StakeHolderBaseTest { function testGetVersion() public { uint256 ver = stakeHolder.version(); assertEq(ver, 0, "Expect initial version of storage layout to be V0"); diff --git a/test/staking/StakeHolderInitERC20.t.sol b/test/staking/StakeHolderInitERC20.t.sol new file mode 100644 index 00000000..8b99a8df --- /dev/null +++ b/test/staking/StakeHolderInitERC20.t.sol @@ -0,0 +1,27 @@ +// Copyright Immutable Pty Ltd 2018 - 2024 +// SPDX-License-Identifier: Apache 2.0 +pragma solidity >=0.8.19 <0.8.29; + +// solhint-disable-next-line no-global-import +import "forge-std/Test.sol"; +import {StakeHolderERC20} from "../../contracts/staking/StakeHolderERC20.sol"; +import {IStakeHolder} from "../../contracts/staking/IStakeHolder.sol"; +import {StakeHolderBase} from "../../contracts/staking/StakeHolderBase.sol"; +import {StakeHolderInitBaseTest} from "./StakeHolderInitBase.t.sol"; +import {ERC1967Proxy} from "openzeppelin-contracts-4.9.3/proxy/ERC1967/ERC1967Proxy.sol"; + +contract StakeHolderInitERC20Test is StakeHolderInitBaseTest { + + function setUp() public override { + super.setUp(); + + StakeHolderERC20 impl = new StakeHolderERC20(); + + bytes memory initData = abi.encodeWithSelector( + StakeHolderERC20.initialize.selector, roleAdmin, upgradeAdmin, distributeAdmin, address(0) + ); + + ERC1967Proxy proxy = new ERC1967Proxy(address(impl), initData); + stakeHolder = IStakeHolder(address(proxy)); + } +} diff --git a/test/staking/StakeHolderInitNative.t.sol b/test/staking/StakeHolderInitNative.t.sol new file mode 100644 index 00000000..1543da45 --- /dev/null +++ b/test/staking/StakeHolderInitNative.t.sol @@ -0,0 +1,27 @@ +// Copyright Immutable Pty Ltd 2018 - 2024 +// SPDX-License-Identifier: Apache 2.0 +pragma solidity >=0.8.19 <0.8.29; + +// solhint-disable-next-line no-global-import +import "forge-std/Test.sol"; +import {StakeHolderNative} from "../../contracts/staking/StakeHolderNative.sol"; +import {IStakeHolder} from "../../contracts/staking/IStakeHolder.sol"; +import {StakeHolderBase} from "../../contracts/staking/StakeHolderBase.sol"; +import {StakeHolderInitBaseTest} from "./StakeHolderInitBase.t.sol"; +import {ERC1967Proxy} from "openzeppelin-contracts-4.9.3/proxy/ERC1967/ERC1967Proxy.sol"; + +contract StakeHolderInitNativeTest is StakeHolderInitBaseTest { + + function setUp() public override { + super.setUp(); + + StakeHolderNative impl = new StakeHolderNative(); + + bytes memory initData = abi.encodeWithSelector( + StakeHolderNative.initialize.selector, roleAdmin, upgradeAdmin, distributeAdmin + ); + + ERC1967Proxy proxy = new ERC1967Proxy(address(impl), initData); + stakeHolder = IStakeHolder(address(proxy)); + } +} diff --git a/test/staking/StakeHolderOperational.t.sol b/test/staking/StakeHolderOperational.t.sol deleted file mode 100644 index 87cdf7f9..00000000 --- a/test/staking/StakeHolderOperational.t.sol +++ /dev/null @@ -1,331 +0,0 @@ -// Copyright Immutable Pty Ltd 2018 - 2024 -// SPDX-License-Identifier: Apache 2.0 -pragma solidity >=0.8.19 <0.8.29; - -// solhint-disable-next-line no-global-import -import "forge-std/Test.sol"; -import {StakeHolder, AccountAmount} from "../../contracts/staking/StakeHolder.sol"; -import {StakeHolderAttackWallet} from "./StakeHolderAttackWallet.sol"; -import {StakeHolderBaseTest} from "./StakeHolderBase.t.sol"; - -contract StakeHolderOperationalTest is StakeHolderBaseTest { - function testStake() public { - vm.deal(staker1, 100 ether); - - vm.prank(staker1); - stakeHolder.stake{value: 10 ether}(); - - assertEq(stakeHolder.getBalance(staker1), 10 ether, "Incorrect balance"); - assertTrue(stakeHolder.hasStaked(staker1), "Expect staker1 has staked"); - assertEq(stakeHolder.getNumStakers(), 1, "Incorrect number of stakers"); - address[] memory stakers = stakeHolder.getStakers(0, 1); - assertEq(stakers.length, 1, "Incorrect length returned by getStakers"); - assertEq(stakers[0], staker1, "Incorrect staker"); - } - - function testStakeTwice() public { - vm.deal(staker1, 100 ether); - - vm.prank(staker1); - stakeHolder.stake{value: 10 ether}(); - vm.prank(staker1); - stakeHolder.stake{value: 20 ether}(); - - assertEq(stakeHolder.getBalance(staker1), 30 ether, "Incorrect balance"); - assertTrue(stakeHolder.hasStaked(staker1), "Expect staker1 has staked"); - assertEq(stakeHolder.getNumStakers(), 1, "Incorrect number of stakers"); - } - - function testStakeZeroValue() public { - vm.expectRevert(abi.encodeWithSelector(StakeHolder.MustStakeMoreThanZero.selector)); - vm.prank(staker1); - stakeHolder.stake(); - } - - function testMultipleStakers() public { - vm.deal(staker1, 100 ether); - vm.deal(staker2, 100 ether); - vm.deal(staker3, 100 ether); - - vm.prank(staker1); - stakeHolder.stake{value: 10 ether}(); - vm.prank(staker2); - stakeHolder.stake{value: 20 ether}(); - vm.prank(staker3); - stakeHolder.stake{value: 30 ether}(); - - assertEq(stakeHolder.getBalance(staker1), 10 ether, "Incorrect balance1"); - assertTrue(stakeHolder.hasStaked(staker1), "Expect staker1 has staked1"); - assertEq(stakeHolder.getBalance(staker2), 20 ether, "Incorrect balance2"); - assertTrue(stakeHolder.hasStaked(staker2), "Expect staker1 has staked2"); - assertEq(stakeHolder.getBalance(staker3), 30 ether, "Incorrect balance3"); - assertTrue(stakeHolder.hasStaked(staker3), "Expect staker1 has staked3"); - - assertEq(stakeHolder.getNumStakers(), 3, "Incorrect number of stakers"); - address[] memory stakers = stakeHolder.getStakers(0, 3); - assertEq(stakers.length, 3, "Incorrect length returned by getStakers"); - assertEq(stakers[0], staker1, "Incorrect staker1"); - assertEq(stakers[1], staker2, "Incorrect staker2"); - assertEq(stakers[2], staker3, "Incorrect staker3"); - } - - function testUnstake() public { - vm.deal(staker1, 100 ether); - - vm.prank(staker1); - stakeHolder.stake{value: 10 ether}(); - vm.prank(staker1); - stakeHolder.unstake(10 ether); - - assertEq(staker1.balance, 100 ether, "Incorrect native balance"); - assertEq(stakeHolder.getBalance(staker1), 0 ether, "Incorrect balance"); - assertTrue(stakeHolder.hasStaked(staker1), "Expect staker1 has staked"); - assertEq(stakeHolder.getNumStakers(), 1, "Incorrect number of stakers"); - address[] memory stakers = stakeHolder.getStakers(0, 1); - assertEq(stakers.length, 1, "Incorrect length returned by getStakers"); - assertEq(stakers[0], staker1, "Incorrect staker"); - } - - function testUnstakeTooMuch() public { - vm.deal(staker1, 100 ether); - - vm.prank(staker1); - stakeHolder.stake{value: 10 ether}(); - vm.expectRevert(abi.encodeWithSelector(StakeHolder.UnstakeAmountExceedsBalance.selector, 11 ether, 10 ether)); - vm.prank(staker1); - stakeHolder.unstake(11 ether); - } - - function testUnstakePartial() public { - vm.deal(staker1, 100 ether); - - vm.prank(staker1); - stakeHolder.stake{value: 10 ether}(); - vm.prank(staker1); - stakeHolder.unstake(3 ether); - - assertEq(staker1.balance, 93 ether, "Incorrect native balance"); - assertEq(stakeHolder.getBalance(staker1), 7 ether, "Incorrect balance"); - } - - function testUnstakeMultiple() public { - vm.deal(staker1, 100 ether); - - vm.prank(staker1); - stakeHolder.stake{value: 10 ether}(); - vm.prank(staker1); - stakeHolder.unstake(3 ether); - vm.prank(staker1); - stakeHolder.unstake(1 ether); - - assertEq(staker1.balance, 94 ether, "Incorrect native balance"); - assertEq(stakeHolder.getBalance(staker1), 6 ether, "Incorrect balance"); - } - - function testUnstakeReentrantAttack() public { - StakeHolderAttackWallet attacker = new StakeHolderAttackWallet(address(stakeHolder)); - vm.deal(address(attacker), 100 ether); - - attacker.stake(10 ether); - // Attacker's reentracy attack will double the amount being unstaked. - // The attack fails due to attempting to withdraw more than balance (that is, 2 x 6 eth = 12) - vm.expectRevert(abi.encodeWithSelector(StakeHolder.UnstakeAmountExceedsBalance.selector, 6000000000000000001, 4 ether)); - attacker.unstake{gas: 10000000}(6 ether); - } - - function testRestaking() public { - vm.deal(staker1, 100 ether); - - vm.startPrank(staker1); - stakeHolder.stake{value: 10 ether}(); - assertEq(stakeHolder.getBalance(staker1), 10 ether, "Incorrect balance1"); - stakeHolder.unstake(10 ether); - assertEq(stakeHolder.getBalance(staker1), 0 ether, "Incorrect balance2"); - stakeHolder.stake{value: 9 ether}(); - assertEq(stakeHolder.getBalance(staker1), 9 ether, "Incorrect balance3"); - stakeHolder.stake{value: 2 ether}(); - assertEq(stakeHolder.getBalance(staker1), 11 ether, "Incorrect balance4"); - assertEq(stakeHolder.getNumStakers(), 1, "Incorrect number of stakers"); - vm.stopPrank(); - } - - function testGetStakers() public { - vm.deal(staker1, 100 ether); - vm.deal(staker2, 100 ether); - vm.deal(staker3, 100 ether); - - vm.prank(staker1); - stakeHolder.stake{value: 10 ether}(); - vm.prank(staker2); - stakeHolder.stake{value: 20 ether}(); - vm.prank(staker3); - stakeHolder.stake{value: 30 ether}(); - - address[] memory stakers = stakeHolder.getStakers(0, 1); - assertEq(stakers.length, 1, "Incorrect length returned by getStakers"); - assertEq(stakers[0], staker1, "Incorrect staker1"); - - stakers = stakeHolder.getStakers(1, 1); - assertEq(stakers.length, 1, "Incorrect length returned by getStakers"); - assertEq(stakers[0], staker2, "Incorrect staker2"); - - stakers = stakeHolder.getStakers(2, 1); - assertEq(stakers.length, 1, "Incorrect length returned by getStakers"); - assertEq(stakers[0], staker3, "Incorrect staker3"); - - stakers = stakeHolder.getStakers(1, 2); - assertEq(stakers.length, 2, "Incorrect length returned by getStakers"); - assertEq(stakers[0], staker2, "Incorrect staker2"); - assertEq(stakers[1], staker3, "Incorrect staker3"); - } - - function testGetStakersOutOfRange() public { - vm.deal(staker1, 100 ether); - vm.deal(staker2, 100 ether); - vm.deal(staker3, 100 ether); - - vm.prank(staker1); - stakeHolder.stake{value: 10 ether}(); - vm.prank(staker2); - stakeHolder.stake{value: 20 ether}(); - vm.prank(staker3); - stakeHolder.stake{value: 30 ether}(); - - vm.expectRevert(stdError.indexOOBError); - stakeHolder.getStakers(1, 3); - } - - function testDistributeRewardsOne() public { - vm.deal(staker1, 100 ether); - vm.deal(staker2, 100 ether); - vm.deal(staker3, 100 ether); - vm.deal(distributeAdmin, 100 ether); - - vm.prank(staker1); - stakeHolder.stake{value: 10 ether}(); - vm.prank(staker2); - stakeHolder.stake{value: 20 ether}(); - vm.prank(staker3); - stakeHolder.stake{value: 30 ether}(); - - // Distribute rewards to staker2 only. - AccountAmount[] memory accountsAmounts = new AccountAmount[](1); - accountsAmounts[0] = AccountAmount(staker2, 0.5 ether); - vm.prank(distributeAdmin); - stakeHolder.distributeRewards{value: 0.5 ether}(accountsAmounts); - - assertEq(stakeHolder.getBalance(staker1), 10 ether, "Incorrect balance1"); - assertEq(stakeHolder.getBalance(staker2), 20.5 ether, "Incorrect balance2"); - assertEq(stakeHolder.getBalance(staker3), 30 ether, "Incorrect balance3"); - } - - function testDistributeRewardsMultiple() public { - vm.deal(staker1, 100 ether); - vm.deal(staker2, 100 ether); - vm.deal(staker3, 100 ether); - vm.deal(distributeAdmin, 100 ether); - - vm.prank(staker1); - stakeHolder.stake{value: 10 ether}(); - vm.prank(staker2); - stakeHolder.stake{value: 20 ether}(); - vm.prank(staker3); - stakeHolder.stake{value: 30 ether}(); - - // Distribute rewards to staker2 and staker3. - AccountAmount[] memory accountsAmounts = new AccountAmount[](2); - accountsAmounts[0] = AccountAmount(staker2, 0.5 ether); - accountsAmounts[1] = AccountAmount(staker3, 1 ether); - vm.prank(distributeAdmin); - stakeHolder.distributeRewards{value: 1.5 ether}(accountsAmounts); - - assertEq(stakeHolder.getBalance(staker1), 10 ether, "Incorrect balance1"); - assertEq(stakeHolder.getBalance(staker2), 20.5 ether, "Incorrect balance2"); - assertEq(stakeHolder.getBalance(staker3), 31 ether, "Incorrect balance3"); - } - - function testDistributeZeroReward() public { - vm.deal(staker1, 100 ether); - vm.deal(distributeAdmin, 100 ether); - - vm.prank(staker1); - stakeHolder.stake{value: 10 ether}(); - - // Distribute rewards of 0 to staker1. - AccountAmount[] memory accountsAmounts = new AccountAmount[](1); - accountsAmounts[0] = AccountAmount(staker2, 0 ether); - vm.expectRevert(abi.encodeWithSelector(StakeHolder.MustDistributeMoreThanZero.selector)); - vm.prank(distributeAdmin); - stakeHolder.distributeRewards{value: 0 ether}(accountsAmounts); - } - - function testDistributeMismatch() public { - vm.deal(staker1, 100 ether); - vm.deal(staker2, 100 ether); - vm.deal(staker3, 100 ether); - vm.deal(distributeAdmin, 100 ether); - - vm.prank(staker1); - stakeHolder.stake{value: 10 ether}(); - vm.prank(staker2); - stakeHolder.stake{value: 20 ether}(); - vm.prank(staker3); - stakeHolder.stake{value: 30 ether}(); - - // Distribute rewards to staker2 and staker3. - AccountAmount[] memory accountsAmounts = new AccountAmount[](2); - accountsAmounts[0] = AccountAmount(staker2, 0.5 ether); - accountsAmounts[1] = AccountAmount(staker3, 1 ether); - vm.expectRevert(abi.encodeWithSelector(StakeHolder.DistributionAmountsDoNotMatchTotal.selector, 1 ether, 1.5 ether)); - vm.prank(distributeAdmin); - stakeHolder.distributeRewards{value: 1 ether}(accountsAmounts); - } - - function testDistributeToEmptyAccount() public { - vm.deal(staker1, 100 ether); - vm.deal(distributeAdmin, 100 ether); - - vm.prank(staker1); - stakeHolder.stake{value: 10 ether}(); - vm.prank(staker1); - stakeHolder.unstake(10 ether); - - // Distribute rewards to staker2 only. - AccountAmount[] memory accountsAmounts = new AccountAmount[](1); - accountsAmounts[0] = AccountAmount(staker1, 0.5 ether); - vm.prank(distributeAdmin); - stakeHolder.distributeRewards{value: 0.5 ether}(accountsAmounts); - - assertEq(stakeHolder.getBalance(staker1), 0.5 ether, "Incorrect balance1"); - assertTrue(stakeHolder.hasStaked(staker1), "Expect staker1 has staked"); - assertEq(stakeHolder.getNumStakers(), 1, "Incorrect number of stakers"); - } - - function testDistributeToUnusedAccount() public { - vm.deal(distributeAdmin, 100 ether); - - // Distribute rewards to staker2 only. - AccountAmount[] memory accountsAmounts = new AccountAmount[](1); - accountsAmounts[0] = AccountAmount(staker1, 0.5 ether); - vm.expectRevert(abi.encodeWithSelector(StakeHolder.AttemptToDistributeToNewAccount.selector, staker1, 0.5 ether)); - vm.prank(distributeAdmin); - stakeHolder.distributeRewards{value: 0.5 ether}(accountsAmounts); - } - - function testDistributeBadAuth() public { - vm.deal(staker1, 100 ether); - vm.deal(bank, 100 ether); - - vm.prank(staker1); - stakeHolder.stake{value: 10 ether}(); - - // Distribute rewards to staker1 only, but not from distributeAdmin - AccountAmount[] memory accountsAmounts = new AccountAmount[](1); - accountsAmounts[0] = AccountAmount(staker1, 0.5 ether); - vm.prank(bank); - // Error will be of the form: - // AccessControl: account 0x7fa9385be102ac3eac297483dd6233d62b3e1496 is missing role 0x555047524144455f524f4c450000000000000000000000000000000000000000 - vm.expectRevert(); - stakeHolder.distributeRewards{value: 0.5 ether}(accountsAmounts); - } -} diff --git a/test/staking/StakeHolderOperationalBase.t.sol b/test/staking/StakeHolderOperationalBase.t.sol new file mode 100644 index 00000000..d5cbb464 --- /dev/null +++ b/test/staking/StakeHolderOperationalBase.t.sol @@ -0,0 +1,287 @@ +// Copyright Immutable Pty Ltd 2018 - 2024 +// SPDX-License-Identifier: Apache 2.0 +pragma solidity >=0.8.19 <0.8.29; + +// solhint-disable-next-line no-global-import +import "forge-std/Test.sol"; +import {IStakeHolder} from "../../contracts/staking/IStakeHolder.sol"; +import {StakeHolderBaseTest} from "./StakeHolderBase.t.sol"; + +abstract contract StakeHolderOperationalBaseTest is StakeHolderBaseTest { + function testStake() public { + _deal(staker1, 100 ether); + _addStake(staker1, 10 ether); + assertEq(_getBalance(staker1), 90 ether, "Incorrect balance1"); + assertEq(_getBalance(address(stakeHolder)), 10 ether, "Incorrect balance2"); + + assertEq(stakeHolder.getBalance(staker1), 10 ether, "Incorrect balance3"); + assertTrue(stakeHolder.hasStaked(staker1), "Expect staker1 has staked"); + assertEq(stakeHolder.getNumStakers(), 1, "Incorrect number of stakers"); + address[] memory stakers = stakeHolder.getStakers(0, 1); + assertEq(stakers.length, 1, "Incorrect length returned by getStakers"); + assertEq(stakers[0], staker1, "Incorrect staker"); + } + + function testStakeTwice() public { + _deal(staker1, 100 ether); + + _addStake(staker1, 10 ether); + _addStake(staker1, 20 ether); + + assertEq(stakeHolder.getBalance(staker1), 30 ether, "Incorrect balance"); + assertTrue(stakeHolder.hasStaked(staker1), "Expect staker1 has staked"); + assertEq(stakeHolder.getNumStakers(), 1, "Incorrect number of stakers"); + } + + function testStakeZeroValue() public { + _addStake(staker1, 0 ether, abi.encodeWithSelector(IStakeHolder.MustStakeMoreThanZero.selector)); + } + + function testMultipleStakers() public { + _deal(staker1, 100 ether); + _deal(staker2, 100 ether); + _deal(staker3, 100 ether); + + _addStake(staker1, 10 ether); + _addStake(staker2, 20 ether); + _addStake(staker3, 30 ether); + + assertEq(stakeHolder.getBalance(staker1), 10 ether, "Incorrect balance1"); + assertTrue(stakeHolder.hasStaked(staker1), "Expect staker1 has staked1"); + assertEq(stakeHolder.getBalance(staker2), 20 ether, "Incorrect balance2"); + assertTrue(stakeHolder.hasStaked(staker2), "Expect staker1 has staked2"); + assertEq(stakeHolder.getBalance(staker3), 30 ether, "Incorrect balance3"); + assertTrue(stakeHolder.hasStaked(staker3), "Expect staker1 has staked3"); + + assertEq(stakeHolder.getNumStakers(), 3, "Incorrect number of stakers"); + address[] memory stakers = stakeHolder.getStakers(0, 3); + assertEq(stakers.length, 3, "Incorrect length returned by getStakers"); + assertEq(stakers[0], staker1, "Incorrect staker1"); + assertEq(stakers[1], staker2, "Incorrect staker2"); + assertEq(stakers[2], staker3, "Incorrect staker3"); + } + + function testUnstake() public { + _deal(staker1, 100 ether); + + _addStake(staker1, 10 ether); + vm.prank(staker1); + stakeHolder.unstake(10 ether); + + assertEq(_getBalance(staker1), 100 ether, "Incorrect native balance"); + assertEq(stakeHolder.getBalance(staker1), 0 ether, "Incorrect balance"); + assertTrue(stakeHolder.hasStaked(staker1), "Expect staker1 has staked"); + assertEq(stakeHolder.getNumStakers(), 1, "Incorrect number of stakers"); + address[] memory stakers = stakeHolder.getStakers(0, 1); + assertEq(stakers.length, 1, "Incorrect length returned by getStakers"); + assertEq(stakers[0], staker1, "Incorrect staker"); + } + + function testUnstakeTooMuch() public { + _deal(staker1, 100 ether); + + _addStake(staker1, 10 ether); + vm.expectRevert(abi.encodeWithSelector(IStakeHolder.UnstakeAmountExceedsBalance.selector, 11 ether, 10 ether)); + vm.prank(staker1); + stakeHolder.unstake(11 ether); + } + + function testUnstakePartial() public { + _deal(staker1, 100 ether); + + _addStake(staker1, 10 ether); + vm.prank(staker1); + stakeHolder.unstake(3 ether); + + assertEq(_getBalance(staker1), 93 ether, "Incorrect native balance"); + assertEq(stakeHolder.getBalance(staker1), 7 ether, "Incorrect balance"); + } + + function testUnstakeMultiple() public { + _deal(staker1, 100 ether); + + _addStake(staker1, 10 ether); + vm.prank(staker1); + stakeHolder.unstake(3 ether); + vm.prank(staker1); + stakeHolder.unstake(1 ether); + + assertEq(_getBalance(staker1), 94 ether, "Incorrect native balance"); + assertEq(stakeHolder.getBalance(staker1), 6 ether, "Incorrect balance"); + } + + function testRestaking() public { + _deal(staker1, 100 ether); + + _addStake(staker1, 10 ether); + assertEq(stakeHolder.getBalance(staker1), 10 ether, "Incorrect balance1"); + vm.prank(staker1); + stakeHolder.unstake(10 ether); + assertEq(stakeHolder.getBalance(staker1), 0 ether, "Incorrect balance2"); + _addStake(staker1, 9 ether); + assertEq(stakeHolder.getBalance(staker1), 9 ether, "Incorrect balance3"); + _addStake(staker1,2 ether); + assertEq(stakeHolder.getBalance(staker1), 11 ether, "Incorrect balance4"); + assertEq(stakeHolder.getNumStakers(), 1, "Incorrect number of stakers"); + vm.stopPrank(); + } + + function testGetStakers() public { + _deal(staker1, 100 ether); + _deal(staker2, 100 ether); + _deal(staker3, 100 ether); + + _addStake(staker1, 10 ether); + _addStake(staker2, 20 ether); + _addStake(staker3, 30 ether); + + address[] memory stakers = stakeHolder.getStakers(0, 1); + assertEq(stakers.length, 1, "Incorrect length returned by getStakers"); + assertEq(stakers[0], staker1, "Incorrect staker1"); + + stakers = stakeHolder.getStakers(1, 1); + assertEq(stakers.length, 1, "Incorrect length returned by getStakers"); + assertEq(stakers[0], staker2, "Incorrect staker2"); + + stakers = stakeHolder.getStakers(2, 1); + assertEq(stakers.length, 1, "Incorrect length returned by getStakers"); + assertEq(stakers[0], staker3, "Incorrect staker3"); + + stakers = stakeHolder.getStakers(1, 2); + assertEq(stakers.length, 2, "Incorrect length returned by getStakers"); + assertEq(stakers[0], staker2, "Incorrect staker2"); + assertEq(stakers[1], staker3, "Incorrect staker3"); + } + + function testGetStakersOutOfRange() public { + _deal(staker1, 100 ether); + _deal(staker2, 100 ether); + _deal(staker3, 100 ether); + + _addStake(staker1, 10 ether); + _addStake(staker2, 20 ether); + _addStake(staker3, 30 ether); + + vm.expectRevert(stdError.indexOOBError); + stakeHolder.getStakers(1, 3); + } + + function testDistributeRewardsOne() public { + _deal(staker1, 100 ether); + _deal(staker2, 100 ether); + _deal(staker3, 100 ether); + _deal(distributeAdmin, 100 ether); + + _addStake(staker1, 10 ether); + _addStake(staker2, 20 ether); + _addStake(staker3, 30 ether); + + // Distribute rewards to staker2 only. + IStakeHolder.AccountAmount[] memory accountsAmounts = new IStakeHolder.AccountAmount[](1); + accountsAmounts[0] = IStakeHolder.AccountAmount(staker2, 0.5 ether); + _distributeRewards(distributeAdmin, 0.5 ether, accountsAmounts); + + assertEq(stakeHolder.getBalance(staker1), 10 ether, "Incorrect balance1"); + assertEq(stakeHolder.getBalance(staker2), 20.5 ether, "Incorrect balance2"); + assertEq(stakeHolder.getBalance(staker3), 30 ether, "Incorrect balance3"); + } + + function testDistributeRewardsMultiple() public { + _deal(staker1, 100 ether); + _deal(staker2, 100 ether); + _deal(staker3, 100 ether); + _deal(distributeAdmin, 100 ether); + + _addStake(staker1, 10 ether); + _addStake(staker2, 20 ether); + _addStake(staker3, 30 ether); + + // Distribute rewards to staker2 and staker3. + IStakeHolder.AccountAmount[] memory accountsAmounts = new IStakeHolder.AccountAmount[](2); + accountsAmounts[0] = IStakeHolder.AccountAmount(staker2, 0.5 ether); + accountsAmounts[1] = IStakeHolder.AccountAmount(staker3, 1 ether); + _distributeRewards(distributeAdmin, 1.5 ether, accountsAmounts); + + assertEq(stakeHolder.getBalance(staker1), 10 ether, "Incorrect balance1"); + assertEq(stakeHolder.getBalance(staker2), 20.5 ether, "Incorrect balance2"); + assertEq(stakeHolder.getBalance(staker3), 31 ether, "Incorrect balance3"); + } + + function testDistributeZeroReward() public { + _deal(staker1, 100 ether); + _deal(distributeAdmin, 100 ether); + + _addStake(staker1, 10 ether); + + // Distribute rewards of 0 to staker1. + IStakeHolder.AccountAmount[] memory accountsAmounts = new IStakeHolder.AccountAmount[](1); + accountsAmounts[0] = IStakeHolder.AccountAmount(staker1, 0 ether); + _distributeRewards(distributeAdmin, 0 ether, accountsAmounts, + abi.encodeWithSelector(IStakeHolder.MustDistributeMoreThanZero.selector)); + } + + function testDistributeToEmptyAccount() public { + _deal(staker1, 100 ether); + _deal(distributeAdmin, 100 ether); + + uint256 amount = 10 ether; + _addStake(staker1, amount); + vm.prank(staker1); + stakeHolder.unstake(amount); + + // Distribute rewards to staker2 only. + IStakeHolder.AccountAmount[] memory accountsAmounts = new IStakeHolder.AccountAmount[](1); + accountsAmounts[0] = IStakeHolder.AccountAmount(staker1, 0.5 ether); + _distributeRewards(distributeAdmin, 0.5 ether, accountsAmounts); + + assertEq(stakeHolder.getBalance(staker1), 0.5 ether, "Incorrect balance1"); + assertTrue(stakeHolder.hasStaked(staker1), "Expect staker1 has staked"); + assertEq(stakeHolder.getNumStakers(), 1, "Incorrect number of stakers"); + } + + function testDistributeToUnusedAccount() public { + _deal(distributeAdmin, 100 ether); + + // Distribute rewards to staker2 only. + IStakeHolder.AccountAmount[] memory accountsAmounts = new IStakeHolder.AccountAmount[](1); + accountsAmounts[0] = IStakeHolder.AccountAmount(staker1, 0.5 ether); + _distributeRewards(distributeAdmin, 0.5 ether, accountsAmounts, + abi.encodeWithSelector(IStakeHolder.AttemptToDistributeToNewAccount.selector, staker1, 0.5 ether)); + } + + function testDistributeBadAuth() public { + _deal(staker1, 100 ether); + _deal(bank, 100 ether); + + _addStake(staker1, 10 ether); + + // Distribute rewards to staker1 only, but not from distributeAdmin + IStakeHolder.AccountAmount[] memory accountsAmounts = new IStakeHolder.AccountAmount[](1); + accountsAmounts[0] = IStakeHolder.AccountAmount(staker1, 0.5 ether); + _distributeRewards(bank, 0.5 ether, accountsAmounts, + abi.encodePacked("AccessControl: account 0x3448fc79c22032be61bee8d832ebc59744f5cc40 is missing role 0x444953545249425554455f524f4c450000000000000000000000000000000000")); + } + + + function _deal(address _to, uint256 _amount) internal virtual; + function _getBalance(address _staker) internal view virtual returns (uint256); + + function _addStake(address _staker, uint256 _amount) internal { + _addStake(_staker, _amount, false, bytes("")); + } + function _addStake(address _staker, uint256 _amount, bytes memory _error) internal { + _addStake(_staker, _amount, true, _error); + + } + function _addStake(address _staker, uint256 _amount, bool _hasError, bytes memory _error) internal virtual; + + + function _distributeRewards(address _distributor, uint256 _total, IStakeHolder.AccountAmount[] memory _accountAmounts) internal { + _distributeRewards(_distributor, _total, _accountAmounts, false, bytes("")); + } + function _distributeRewards(address _distributor, uint256 _total, IStakeHolder.AccountAmount[] memory _accountAmounts, bytes memory _error) internal { + _distributeRewards(_distributor, _total, _accountAmounts, true, _error); + } + function _distributeRewards(address _distributor, uint256 _total, IStakeHolder.AccountAmount[] memory _accountAmounts, + bool _hasError, bytes memory _error) internal virtual; +} diff --git a/test/staking/StakeHolderOperationalERC20.t.sol b/test/staking/StakeHolderOperationalERC20.t.sol new file mode 100644 index 00000000..c35d6b3f --- /dev/null +++ b/test/staking/StakeHolderOperationalERC20.t.sol @@ -0,0 +1,60 @@ +// Copyright Immutable Pty Ltd 2018 - 2024 +// SPDX-License-Identifier: Apache 2.0 +pragma solidity >=0.8.19 <0.8.29; + +// solhint-disable-next-line no-global-import +import "forge-std/Test.sol"; +import {StakeHolderERC20} from "../../contracts/staking/StakeHolderERC20.sol"; +import {IStakeHolder} from "../../contracts/staking/IStakeHolder.sol"; +import {StakeHolderOperationalBaseTest} from "./StakeHolderOperationalBase.t.sol"; +import {ERC1967Proxy} from "openzeppelin-contracts-4.9.3/proxy/ERC1967/ERC1967Proxy.sol"; +import {ERC20PresetFixedSupply} from "openzeppelin-contracts-4.9.3/token/ERC20/presets/ERC20PresetFixedSupply.sol"; + +contract StakeHolderOperationalERC20Test is StakeHolderOperationalBaseTest { + ERC20PresetFixedSupply erc20; + + + function setUp() public override { + super.setUp(); + + erc20 = new ERC20PresetFixedSupply("Name", "SYM", 1000 ether, bank); + + StakeHolderERC20 impl = new StakeHolderERC20(); + + bytes memory initData = abi.encodeWithSelector( + StakeHolderERC20.initialize.selector, roleAdmin, upgradeAdmin, distributeAdmin, address(erc20) + ); + + ERC1967Proxy proxy = new ERC1967Proxy(address(impl), initData); + stakeHolder = IStakeHolder(address(proxy)); + } + + function _deal(address _to, uint256 _amount) internal override { + vm.prank(bank); + erc20.transfer(_to, _amount); + } + + function _addStake(address _staker, uint256 _amount, bool _hasError, bytes memory _error) internal override { + vm.prank(_staker); + erc20.approve(address(stakeHolder), _amount); + if (_hasError) { + vm.expectRevert(_error); + } + vm.prank(_staker); + stakeHolder.stake(_amount); + } + function _distributeRewards(address _distributor, uint256 _total, IStakeHolder.AccountAmount[] memory _accountAmounts, + bool _hasError, bytes memory _error) internal override { + vm.prank(_distributor); + erc20.approve(address(stakeHolder), _total); + if (_hasError) { + vm.expectRevert(_error); + } + vm.prank(_distributor); + stakeHolder.distributeRewards(_accountAmounts); + } + function _getBalance(address _staker) internal view override returns (uint256) { + return erc20.balanceOf(_staker); + } + +} diff --git a/test/staking/StakeHolderOperationalNative.t.sol b/test/staking/StakeHolderOperationalNative.t.sol new file mode 100644 index 00000000..d81c8fd0 --- /dev/null +++ b/test/staking/StakeHolderOperationalNative.t.sol @@ -0,0 +1,79 @@ +// Copyright Immutable Pty Ltd 2018 - 2024 +// SPDX-License-Identifier: Apache 2.0 +pragma solidity >=0.8.19 <0.8.29; + +// solhint-disable-next-line no-global-import +import "forge-std/Test.sol"; +import {StakeHolderNative} from "../../contracts/staking/StakeHolderNative.sol"; +import {IStakeHolder} from "../../contracts/staking/IStakeHolder.sol"; +import {StakeHolderOperationalBaseTest} from "./StakeHolderOperationalBase.t.sol"; +import {ERC1967Proxy} from "openzeppelin-contracts-4.9.3/proxy/ERC1967/ERC1967Proxy.sol"; +import {StakeHolderAttackWallet} from "./StakeHolderAttackWallet.sol"; + +contract StakeHolderOperationalNativeTest is StakeHolderOperationalBaseTest { + + function setUp() public override { + super.setUp(); + + StakeHolderNative impl = new StakeHolderNative(); + + bytes memory initData = abi.encodeWithSelector( + StakeHolderNative.initialize.selector, roleAdmin, upgradeAdmin, distributeAdmin + ); + + ERC1967Proxy proxy = new ERC1967Proxy(address(impl), initData); + stakeHolder = IStakeHolder(address(proxy)); + } + + function testUnstakeReentrantAttack() public { + StakeHolderAttackWallet attacker = new StakeHolderAttackWallet(address(stakeHolder)); + _deal(address(attacker), 100 ether); + + attacker.stake(10 ether); + // Attacker's reentracy attack will double the amount being unstaked. + // The attack fails due to attempting to withdraw more than balance (that is, 2 x 6 eth = 12) + vm.expectRevert(abi.encodeWithSelector(IStakeHolder.UnstakeAmountExceedsBalance.selector, 6000000000000000001, 4 ether)); + attacker.unstake{gas: 10000000}(6 ether); + } + + function testDistributeMismatch() public { + _deal(staker1, 100 ether); + _deal(staker2, 100 ether); + _deal(staker3, 100 ether); + _deal(distributeAdmin, 100 ether); + + _addStake(staker1, 10 ether); + _addStake(staker2, 20 ether); + _addStake(staker3, 30 ether); + + // Distribute rewards to staker2 and staker3. + IStakeHolder.AccountAmount[] memory accountsAmounts = new IStakeHolder.AccountAmount[](2); + accountsAmounts[0] = IStakeHolder.AccountAmount(staker2, 0.5 ether); + accountsAmounts[1] = IStakeHolder.AccountAmount(staker3, 1 ether); + _distributeRewards(distributeAdmin, 1 ether, accountsAmounts, + abi.encodeWithSelector(IStakeHolder.DistributionAmountsDoNotMatchTotal.selector, 1 ether, 1.5 ether)); + } + + function _deal(address _to, uint256 _amount) internal override { + vm.deal(_to, _amount); + } + function _addStake(address _staker, uint256 _amount, bool _hasError, bytes memory _error) internal override { + if (_hasError) { + vm.expectRevert(_error); + } + vm.prank(_staker); + stakeHolder.stake{value: _amount}(_amount); + } + function _distributeRewards(address _distributor, uint256 _total, IStakeHolder.AccountAmount[] memory _accountAmounts, + bool _hasError, bytes memory _error) internal override { + if (_hasError) { + vm.expectRevert(_error); + } + vm.prank(_distributor); + stakeHolder.distributeRewards{value: _total}(_accountAmounts); + } + function _getBalance(address _staker) internal view override returns (uint256) { + return _staker.balance; + } + +} From 7b79dd7d4940108ccbb8658fb862b87aa545d20a Mon Sep 17 00:00:00 2001 From: Peter Robinson Date: Tue, 15 Apr 2025 18:25:09 +1000 Subject: [PATCH 04/39] First time delay test --- contracts/staking/README.md | 10 +- contracts/staking/StakeHolderBase.sol | 14 +-- contracts/staking/StakeHolderTimeDelay.sol | 42 +++++++ test/staking/README.md | 62 +--------- test/staking/StakeHolderConfigBase.t.sol | 14 --- test/staking/StakeHolderTimeDelayBase.t.sol | 121 +++++++++++++++++++ test/staking/StakeHolderTimeDelayERC20.t.sol | 44 +++++++ 7 files changed, 223 insertions(+), 84 deletions(-) create mode 100644 contracts/staking/StakeHolderTimeDelay.sol create mode 100644 test/staking/StakeHolderTimeDelayBase.t.sol create mode 100644 test/staking/StakeHolderTimeDelayERC20.t.sol diff --git a/contracts/staking/README.md b/contracts/staking/README.md index 1ffb0a31..b47ff281 100644 --- a/contracts/staking/README.md +++ b/contracts/staking/README.md @@ -53,8 +53,6 @@ The `stakers` array needs to be analysed to determine which accounts have staked The `StakeHolder` contract is `AccessControlEnumerableUpgradeable`, with the following minor modification: -* `_revokeRole(bytes32 _role, address _account)` has been overridden to prevent the last DEFAULT_ADMIN_ROLE (the last role admin) from either being revoked or renounced. - The `StakeHolder` contract is `UUPSUpgradeable`. Only accounts with `UPGRADE_ROLE` are authorised to upgrade the contract. ## Upgrade Concept @@ -67,3 +65,11 @@ The `upgradeStorage` function should be updated each new contract version. It sh * The value is the same as the newt version: Someone (an attacker) has called the `upgradeStorage` function after the code has been upgraded. The function should revert. * Based on the old code version and storage format indicated by the `version`, update the storage variables. Typically, upgrades only involve code changes, and require no storage variable changes. However, in some circumstances storage variables should also be updated. * Update the `version` storage variable to indicate the new code version. + +## Time Delay Upgrade and Admin + +A staking systems may wish to delay upgrade actions and the granting of additional administrative access. To do this, the only account with UPGRADE_ROLE and DEFAULT_ADMIN_ROLE roles should be an instance of Open Zeppelin's [TimelockController](https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/governance/TimelockController.sol). This ensures any upgrade proposals or proposals to add more accounts with DEFAULT_ADMIN_ROLE, UPGRADE_ROLE or DISTRIBUTE_ROLE must go through a time delay before being actioned. + +## Preventing Upgrade + +A staking system should 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. diff --git a/contracts/staking/StakeHolderBase.sol b/contracts/staking/StakeHolderBase.sol index 7bfd7ec0..1c0188a0 100644 --- a/contracts/staking/StakeHolderBase.sol +++ b/contracts/staking/StakeHolderBase.sol @@ -4,6 +4,8 @@ pragma solidity >=0.8.19 <0.8.29; import {UUPSUpgradeable} from "openzeppelin-contracts-upgradeable-4.9.3/proxy/utils/UUPSUpgradeable.sol"; import {AccessControlEnumerableUpgradeable} from "openzeppelin-contracts-upgradeable-4.9.3/access/AccessControlEnumerableUpgradeable.sol"; +import {AccessControlUpgradeable} from "openzeppelin-contracts-upgradeable-4.9.3/access/AccessControlUpgradeable.sol"; +import {IAccessControlUpgradeable} from "openzeppelin-contracts-upgradeable-4.9.3/access/IAccessControlUpgradeable.sol"; import {ReentrancyGuardUpgradeable} from "openzeppelin-contracts-upgradeable-4.9.3/security/ReentrancyGuardUpgradeable.sol"; import {IStakeHolder} from "./IStakeHolder.sol"; import {StakeHolderBase} from "./StakeHolderBase.sol"; @@ -119,18 +121,6 @@ abstract contract StakeHolderBase is IStakeHolder, AccessControlEnumerableUpgrad // solhint-disable-next-line no-empty-blocks function _authorizeUpgrade(address newImplementation) internal override onlyRole(UPGRADE_ROLE) {} - /** - * @notice Prevent revoke or renounce role for the last DEFAULT_ADMIN_ROLE / the last role admin. - * @param _role The role to be renounced. - * @param _account Account to be revoked. - */ - function _revokeRole(bytes32 _role, address _account) internal override { - if (_role == DEFAULT_ADMIN_ROLE && getRoleMemberCount(_role) == 1) { - revert MustHaveOneRoleAdmin(); - } - super._revokeRole(_role, _account); - } - /// @notice storage gap for additional variables for upgrades // slither-disable-start unused-state // solhint-disable-next-line var-name-mixedcase diff --git a/contracts/staking/StakeHolderTimeDelay.sol b/contracts/staking/StakeHolderTimeDelay.sol new file mode 100644 index 00000000..19ad9edf --- /dev/null +++ b/contracts/staking/StakeHolderTimeDelay.sol @@ -0,0 +1,42 @@ +// Copyright (c) Immutable Pty Ltd 2018 - 2025 +// SPDX-License-Identifier: Apache 2 +pragma solidity >=0.8.19 <0.8.29; + +import {TimelockController} from "openzeppelin-contracts-4.9.3/governance/TimelockController.sol"; + + +/** + * @notice StakeHolder contracts can use this function to enforce a time delay for admin actions. + * @dev Typically, staking systems would use this contract as the only account with UPGRADE_ROLE + * and DEFAULT_ADMIN_ROLE roles. This ensures any upgrade proposals or proposals to add more + * accounts with UPGRADE_ROLE or DISTRIBUTE_ROLE must go through a time delay before being actioned. + * A staking system could choose to have no account with DEFAULT_ADMIN_ROLE, thus ensuring no additional + * acccounts are granted UPGRADE_ROLE. A staking system could choose to have no account with UPGRADE_ROLE, + * or DEFAULT_ADMIN_ROLE thus ensuring the StakeHolder contract can not be upgraded. + */ +contract StakeHolderTimeDelay is TimelockController { + /// @notice Change the delay is not allowed. + error UpdateDelayNotAlllowed(); + + + /** + * @dev Initializes the contract with the following parameters: + * + * - `minDelay`: initial minimum delay for operations + * - `proposers`: accounts to be granted proposer and canceller roles + * - `executors`: accounts to be granted executor role + * + * Pass in address(0) as the optional admin. This means that changes to proposers and + * executors use the time delay. + */ + constructor(uint256 minDelay, address[] memory proposers, address[] memory executors) + TimelockController(minDelay, proposers, executors, address(0)) { + } + + /** + * @notice Do not allow delay to be changed. + */ + function updateDelay(uint256 /* newDelay */) external pure override { + revert UpdateDelayNotAlllowed(); + } +} diff --git a/test/staking/README.md b/test/staking/README.md index 1fff458e..d8c1a499 100644 --- a/test/staking/README.md +++ b/test/staking/README.md @@ -1,59 +1,10 @@ # Test Plan for Staking contracts -## [StakeHolder.sol](../../contracts/staking/StakeHolder.sol) +## [IStakeHolder.sol](../../contracts/staking/IStakeHolder.sol) -Initialize testing (in [StakeHolderInit.t.sol](../../contracts/staking/StakeHolderInit.t.sol)): - -| Test name | Description | Happy Case | Implemented | -|---------------------------------|------------------------------------------------------------|------------|-------------| -| testGetVersion | Check version number. | Yes | Yes | -| testStakersInit | Check initial staker's array length is zero. | Yes | Yes | -| testAdmins | Check that role and upgrade admin have been set correctly. | Yes | Yes | +[StakeHolderNative.sol](../../contracts/staking/StakeHolderNative.sol) and [StakeHolderERC20.sol](../../contracts/staking/StakeHolderERC20.sol) use common base tests. - -Configuration tests (in [StakeHolderConfig.t.sol](../../contracts/staking/StakeHolderConfig.t.sol)):: - -| Test name | Description | Happy Case | Implemented | -|---------------------------------|------------------------------------------------------------|------------|-------------| -| testUpgradeToV1 | Check upgrade process. | Yes | Yes | -| testUpgradeToV0 | Check upgrade to V0 fails. | No | Yes | -| testDowngradeV1ToV0 | Check downgrade from V1 to V0 fails. | No | Yes | -| testUpgradeAuthFail | Try upgrade from account that doesn't have upgrade role. | No | Yes | -| testAddRevokeRenounceRoleAdmin | Check adding, removing, and renouncing role admins. | Yes | Yes | -| testAddRevokeRenounceUpgradeAdmin | Check adding, removing, and renouncing upgrade admins. | Yes | Yes | -| testRenounceLastRoleAdmin | Check that attempting to renounce last role admin fails. | No | Yes | -| testRevokeLastRoleAdmin | Check that attempting to revoke last role admin fails. | No | Yes | -| testRoleAdminAuthFail | Attempt to add an upgrade admin from a non-role admin. | No | Yes | - - -Operational tests (in [StakeHolderOperational.t.sol](../../contracts/staking/StakeHolderOperational.t.sol)):: - -| Test name | Description | Happy Case | Implemented | -|--------------------------------|-------------------------------------------------------------|------------|-------------| -| testStake | Stake some value. | Yes | Yes | -| testStakeTwice | Stake some value and then some more value. | Yes | Yes | -| testStakeZeroValue | Stake with msg.value = 0. | No | Yes | -| testMultipleStakers | Check multiple entities staking works. | Yes | Yes | -| testUnstake | Check that an account can unstake all their value. | Yes | Yes | -| testUnstakeTooMuch | Attempt to unstake greater than balance. | No | Yes | -| testUnstakePartial | Check that an account can unstake part of their value. | Yes | Yes | -| testUnstakeMultiple | Unstake in multiple parts. | Yes | Yes | -| testUnstakeReentrantAttack | Attempt a reentrancy attack on unstaking. | No | Yes | -| testRestaking | Stake, unstake, restake. | Yes | Yes | -| testGetStakers | Check getStakers in various scenarios. | Yes | Yes | -| testGetStakersOutOfRange | Check getStakers for out of range request. | No | Yes | -| testDistributeRewardsOne | Distribute rewards to one account. | Yes | Yes | -| testDistributeRewardsMultiple | Distribute rewards to multiple accounts. | Yes | Yes | -| testDistributeZeroReward | Fail when distributing zero reward. | No | Yes | -| testDistributeMismatch | Fail if the total to distribute does not equal msg.value. | No | Yes | -| testDistributeToEmptyAccount | Stake, unstake, distribute rewards. | Yes | Yes | -| testDistributeToUnusedAccount | Attempt to distribute rewards to an account that has never staked. | No | Yes | -| testDistributeBadAuth | Attempt to distribute rewards using an unauthorised account. | No | Yes | - - -## [StakeHolderERC20.sol](../../contracts/staking/StakeHolderERC20.sol) - -Initialize testing (in [StakeHolderERC20Init.t.sol](../../contracts/staking/StakeHolderERC20Init.t.sol)): +Initialize testing (in [StakeHolderInitBase.t.sol](../../contracts/staking/StakeHolderInitBase.t.sol)): | Test name | Description | Happy Case | Implemented | |---------------------------------|------------------------------------------------------------|------------|-------------| @@ -62,7 +13,7 @@ Initialize testing (in [StakeHolderERC20Init.t.sol](../../contracts/staking/Stak | testAdmins | Check that role and upgrade admin have been set correctly. | Yes | Yes | -Configuration tests (in [StakeHolderERC20Config.t.sol](../../contracts/staking/StakeHolderERC20Config.t.sol)):: +Configuration tests (in [StakeHolderConfigBase.t.sol](../../contracts/staking/StakeHolderConfigBase.t.sol)):: | Test name | Description | Happy Case | Implemented | |---------------------------------|------------------------------------------------------------|------------|-------------| @@ -72,12 +23,10 @@ Configuration tests (in [StakeHolderERC20Config.t.sol](../../contracts/staking/S | testUpgradeAuthFail | Try upgrade from account that doesn't have upgrade role. | No | Yes | | testAddRevokeRenounceRoleAdmin | Check adding, removing, and renouncing role admins. | Yes | Yes | | testAddRevokeRenounceUpgradeAdmin | Check adding, removing, and renouncing upgrade admins. | Yes | Yes | -| testRenounceLastRoleAdmin | Check that attempting to renounce last role admin fails. | No | Yes | -| testRevokeLastRoleAdmin | Check that attempting to revoke last role admin fails. | No | Yes | | testRoleAdminAuthFail | Attempt to add an upgrade admin from a non-role admin. | No | Yes | -Operational tests (in [StakeHolderERC20Operational.t.sol](../../contracts/staking/StakeHolderERC20Operational.t.sol)):: +Operational tests (in [StakeHolderOperationalBase.t.sol](../../contracts/staking/StakeHolderOperationalBase.t.sol)):: | Test name | Description | Happy Case | Implemented | |--------------------------------|-------------------------------------------------------------|------------|-------------| @@ -101,3 +50,4 @@ Operational tests (in [StakeHolderERC20Operational.t.sol](../../contracts/stakin | testDistributeToUnusedAccount | Attempt to distribute rewards to an account that has never staked. | No | Yes | | testDistributeBadAuth | Attempt to distribute rewards using an unauthorised account. | No | Yes | + diff --git a/test/staking/StakeHolderConfigBase.t.sol b/test/staking/StakeHolderConfigBase.t.sol index de4511d8..7cf5ef9a 100644 --- a/test/staking/StakeHolderConfigBase.t.sol +++ b/test/staking/StakeHolderConfigBase.t.sol @@ -78,20 +78,6 @@ abstract contract StakeHolderConfigBaseTest is StakeHolderBaseTest { assertFalse(stakeHolder.hasRole(role, upgradeAdmin), "Upgrade admin should not have role"); } - function testRenounceLastRoleAdmin() public { - bytes32 role = defaultAdminRole; - vm.expectRevert(abi.encodeWithSelector(IStakeHolder.MustHaveOneRoleAdmin.selector)); - vm.prank(roleAdmin); - stakeHolder.renounceRole(role, roleAdmin); - } - - function testRevokeLastRoleAdmin() public { - bytes32 role = defaultAdminRole; - vm.expectRevert(abi.encodeWithSelector(IStakeHolder.MustHaveOneRoleAdmin.selector)); - vm.prank(roleAdmin); - stakeHolder.revokeRole(role, roleAdmin); - } - function testRoleAdminAuthFail () public { bytes32 role = defaultAdminRole; address newRoleAdmin = makeAddr("NewRoleAdmin"); diff --git a/test/staking/StakeHolderTimeDelayBase.t.sol b/test/staking/StakeHolderTimeDelayBase.t.sol new file mode 100644 index 00000000..1750616d --- /dev/null +++ b/test/staking/StakeHolderTimeDelayBase.t.sol @@ -0,0 +1,121 @@ +// Copyright Immutable Pty Ltd 2018 - 2024 +// SPDX-License-Identifier: Apache 2.0 +pragma solidity >=0.8.19 <0.8.29; + +// solhint-disable-next-line no-global-import +import "forge-std/Test.sol"; +import {IStakeHolder} from "../../contracts/staking/IStakeHolder.sol"; +import {StakeHolderBaseTest} from "./StakeHolderBase.t.sol"; +import {TimelockController} from "openzeppelin-contracts-4.9.3/governance/TimelockController.sol"; +import {UUPSUpgradeable} from "openzeppelin-contracts-upgradeable-4.9.3/proxy/utils/UUPSUpgradeable.sol"; +import {StakeHolderBase} from "../../contracts/staking/StakeHolderBase.sol"; + + +abstract contract StakeHolderTimeDelayBaseTest is StakeHolderBaseTest { + TimelockController stakeHolderTimeDelay; + + uint256 delay = 604800; // 604800 seconds = 1 week + + address adminProposer; + address adminExecutor; + + function setUp() public virtual override { + super.setUp(); + + adminProposer = makeAddr("adminProposer"); + adminExecutor = makeAddr("adminExecutor"); + + address[] memory proposers = new address[](1); + proposers[0] = adminProposer; + address[] memory executors = new address[](1); + executors[0] = adminExecutor; + + stakeHolderTimeDelay = new TimelockController(delay, proposers, executors, address(0)); + } + + + function testTimeLockControllerDeployment() public { + assertEq(stakeHolderTimeDelay.getMinDelay(), delay, "Incorrect time delay"); + } + + function testUpgrade() public { + IStakeHolder v2Impl = _deployV2(); + + bytes memory initData = abi.encodeWithSelector(StakeHolderBase.upgradeStorage.selector, bytes("")); + bytes memory upgradeCall = abi.encodeWithSelector( + UUPSUpgradeable.upgradeToAndCall.selector, address(v2Impl), initData); + + address target = address(stakeHolder); + uint256 value = 0; + bytes memory data = upgradeCall; + bytes32 predecessor = bytes32(0); + bytes32 salt = bytes32(uint256(1)); + uint256 theDelay = delay; + + uint256 timeNow = block.timestamp; + + vm.prank(adminProposer); + stakeHolderTimeDelay.schedule( + target, value, data, predecessor, salt, theDelay); + + vm.warp(timeNow + delay); + + vm.prank(adminExecutor); + stakeHolderTimeDelay.execute(target, value, data, predecessor, salt); + + uint256 ver = stakeHolder.version(); + assertEq(ver, 1, "Upgrade did not upgrade version"); + } + + function testTooShortDelay() public { + IStakeHolder v2Impl = _deployV2(); + + bytes memory initData = abi.encodeWithSelector(StakeHolderBase.upgradeStorage.selector, bytes("")); + bytes memory upgradeCall = abi.encodeWithSelector( + UUPSUpgradeable.upgradeToAndCall.selector, address(v2Impl), initData); + + address target = address(stakeHolder); + uint256 value = 0; + bytes memory data = upgradeCall; + bytes32 predecessor = bytes32(0); + bytes32 salt = bytes32(uint256(1)); + uint256 theDelay = delay - 1; // Too small + + vm.expectRevert(abi.encodePacked("TimelockController: insufficient delay")); + vm.prank(adminProposer); + stakeHolderTimeDelay.schedule( + target, value, data, predecessor, salt, theDelay); + } + + function testExecuteEarly() public { + IStakeHolder v2Impl = _deployV2(); + + bytes memory initData = abi.encodeWithSelector(StakeHolderBase.upgradeStorage.selector, bytes("")); + bytes memory upgradeCall = abi.encodeWithSelector( + UUPSUpgradeable.upgradeToAndCall.selector, address(v2Impl), initData); + + address target = address(stakeHolder); + uint256 value = 0; + bytes memory data = upgradeCall; + bytes32 predecessor = bytes32(0); + bytes32 salt = bytes32(uint256(1)); + uint256 theDelay = delay; + + uint256 timeNow = block.timestamp; + + vm.prank(adminProposer); + stakeHolderTimeDelay.schedule( + target, value, data, predecessor, salt, theDelay); + + vm.expectRevert(abi.encodePacked("TimelockController: operation is not ready")); + vm.warp(timeNow + delay - 1); // Too early + + vm.prank(adminExecutor); + stakeHolderTimeDelay.execute(target, value, data, predecessor, salt); + } + + + + + function _deployV2() internal virtual returns(IStakeHolder); +} diff --git a/test/staking/StakeHolderTimeDelayERC20.t.sol b/test/staking/StakeHolderTimeDelayERC20.t.sol new file mode 100644 index 00000000..ff88dd32 --- /dev/null +++ b/test/staking/StakeHolderTimeDelayERC20.t.sol @@ -0,0 +1,44 @@ +// Copyright Immutable Pty Ltd 2018 - 2024 +// SPDX-License-Identifier: Apache 2.0 +pragma solidity >=0.8.19 <0.8.29; + +// solhint-disable-next-line no-global-import +import "forge-std/Test.sol"; +import {StakeHolderERC20} from "../../contracts/staking/StakeHolderERC20.sol"; +import {IStakeHolder} from "../../contracts/staking/IStakeHolder.sol"; +import {StakeHolderTimeDelayBaseTest} from "./StakeHolderTimeDelayBase.t.sol"; +import {ERC1967Proxy} from "openzeppelin-contracts-4.9.3/proxy/ERC1967/ERC1967Proxy.sol"; +import {ERC20PresetFixedSupply} from "openzeppelin-contracts-4.9.3/token/ERC20/presets/ERC20PresetFixedSupply.sol"; +import {StakeHolderBase} from "../../contracts/staking/StakeHolderBase.sol"; + +contract StakeHolderERC20V2 is StakeHolderERC20 { + function upgradeStorage(bytes memory /* _data */) external override(StakeHolderBase) { + version = 1; + } +} + + +contract StakeHolderTimeDelayERC20Test is StakeHolderTimeDelayBaseTest { + ERC20PresetFixedSupply erc20; + + function setUp() public override { + super.setUp(); + + erc20 = new ERC20PresetFixedSupply("Name", "SYM", 1000 ether, bank); + + StakeHolderERC20 impl = new StakeHolderERC20(); + + bytes memory initData = abi.encodeWithSelector( + StakeHolderERC20.initialize.selector, address(stakeHolderTimeDelay), address(stakeHolderTimeDelay), + distributeAdmin, address(erc20) + ); + + ERC1967Proxy proxy = new ERC1967Proxy(address(impl), initData); + stakeHolder = IStakeHolder(address(proxy)); + } + + function _deployV2() internal override returns(IStakeHolder) { + return IStakeHolder(address(new StakeHolderERC20V2())); + } + +} From 9b30ad5aa95d37eab0c2025560805061ca54a9a2 Mon Sep 17 00:00:00 2001 From: Peter Robinson Date: Tue, 15 Apr 2025 18:28:40 +1000 Subject: [PATCH 05/39] Remove time lock contract --- contracts/staking/StakeHolderTimeDelay.sol | 42 ---------------------- 1 file changed, 42 deletions(-) delete mode 100644 contracts/staking/StakeHolderTimeDelay.sol diff --git a/contracts/staking/StakeHolderTimeDelay.sol b/contracts/staking/StakeHolderTimeDelay.sol deleted file mode 100644 index 19ad9edf..00000000 --- a/contracts/staking/StakeHolderTimeDelay.sol +++ /dev/null @@ -1,42 +0,0 @@ -// Copyright (c) Immutable Pty Ltd 2018 - 2025 -// SPDX-License-Identifier: Apache 2 -pragma solidity >=0.8.19 <0.8.29; - -import {TimelockController} from "openzeppelin-contracts-4.9.3/governance/TimelockController.sol"; - - -/** - * @notice StakeHolder contracts can use this function to enforce a time delay for admin actions. - * @dev Typically, staking systems would use this contract as the only account with UPGRADE_ROLE - * and DEFAULT_ADMIN_ROLE roles. This ensures any upgrade proposals or proposals to add more - * accounts with UPGRADE_ROLE or DISTRIBUTE_ROLE must go through a time delay before being actioned. - * A staking system could choose to have no account with DEFAULT_ADMIN_ROLE, thus ensuring no additional - * acccounts are granted UPGRADE_ROLE. A staking system could choose to have no account with UPGRADE_ROLE, - * or DEFAULT_ADMIN_ROLE thus ensuring the StakeHolder contract can not be upgraded. - */ -contract StakeHolderTimeDelay is TimelockController { - /// @notice Change the delay is not allowed. - error UpdateDelayNotAlllowed(); - - - /** - * @dev Initializes the contract with the following parameters: - * - * - `minDelay`: initial minimum delay for operations - * - `proposers`: accounts to be granted proposer and canceller roles - * - `executors`: accounts to be granted executor role - * - * Pass in address(0) as the optional admin. This means that changes to proposers and - * executors use the time delay. - */ - constructor(uint256 minDelay, address[] memory proposers, address[] memory executors) - TimelockController(minDelay, proposers, executors, address(0)) { - } - - /** - * @notice Do not allow delay to be changed. - */ - function updateDelay(uint256 /* newDelay */) external pure override { - revert UpdateDelayNotAlllowed(); - } -} From 7c803d08ba092de89256280db663b8368b3ee0b7 Mon Sep 17 00:00:00 2001 From: Peter Robinson Date: Wed, 16 Apr 2025 09:24:04 +1000 Subject: [PATCH 06/39] Make stake native nonReentrant --- contracts/staking/StakeHolderNative.sol | 6 +++--- test/staking/StakeHolderOperationalNative.t.sol | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/contracts/staking/StakeHolderNative.sol b/contracts/staking/StakeHolderNative.sol index 98aa57a0..9ae16c22 100644 --- a/contracts/staking/StakeHolderNative.sol +++ b/contracts/staking/StakeHolderNative.sol @@ -31,7 +31,7 @@ contract StakeHolderNative is StakeHolderBase { * @dev This function does not need re-entrancy guard as the add stake * mechanism does not call out to any external function. */ - function stake(uint256 _amount) external payable { + function stake(uint256 _amount) external payable nonReentrant { if (msg.value == 0) { revert MustStakeMoreThanZero(); } @@ -47,7 +47,7 @@ contract StakeHolderNative is StakeHolderBase { * prior to the call to the user's wallet. * @param _amountToUnstake Amount of stake to remove. */ - function unstake(uint256 _amountToUnstake) external { + function unstake(uint256 _amountToUnstake) external nonReentrant { StakeInfo storage stakeInfo = balances[msg.sender]; uint256 currentStake = stakeInfo.stake; if (currentStake < _amountToUnstake) { @@ -85,7 +85,7 @@ contract StakeHolderNative is StakeHolderBase { */ function distributeRewards( AccountAmount[] calldata _recipientsAndAmounts - ) external payable onlyRole(DISTRIBUTE_ROLE) { + ) external payable onlyRole(DISTRIBUTE_ROLE) nonReentrant { // Initial validity checks if (msg.value == 0) { revert MustDistributeMoreThanZero(); diff --git a/test/staking/StakeHolderOperationalNative.t.sol b/test/staking/StakeHolderOperationalNative.t.sol index d81c8fd0..199dc400 100644 --- a/test/staking/StakeHolderOperationalNative.t.sol +++ b/test/staking/StakeHolderOperationalNative.t.sol @@ -32,7 +32,7 @@ contract StakeHolderOperationalNativeTest is StakeHolderOperationalBaseTest { attacker.stake(10 ether); // Attacker's reentracy attack will double the amount being unstaked. // The attack fails due to attempting to withdraw more than balance (that is, 2 x 6 eth = 12) - vm.expectRevert(abi.encodeWithSelector(IStakeHolder.UnstakeAmountExceedsBalance.selector, 6000000000000000001, 4 ether)); + vm.expectRevert(abi.encodePacked("ReentrancyGuard: reentrant call")); attacker.unstake{gas: 10000000}(6 ether); } From 5e6c113731e997479bce9000587aed2f2fc98a01 Mon Sep 17 00:00:00 2001 From: Peter Robinson Date: Wed, 16 Apr 2025 10:29:31 +1000 Subject: [PATCH 07/39] Simplify code --- contracts/staking/IStakeHolder.sol | 3 - contracts/staking/StakeHolderBase.sol | 92 ++++++++++++++++ contracts/staking/StakeHolderERC20.sol | 91 ++-------------- contracts/staking/StakeHolderNative.sol | 103 ++---------------- .../StakeHolderOperationalNative.t.sol | 2 +- 5 files changed, 114 insertions(+), 177 deletions(-) diff --git a/contracts/staking/IStakeHolder.sol b/contracts/staking/IStakeHolder.sol index ff3ca60d..97a1cff5 100644 --- a/contracts/staking/IStakeHolder.sol +++ b/contracts/staking/IStakeHolder.sol @@ -30,9 +30,6 @@ interface IStakeHolder is IAccessControlEnumerableUpgradeable { /// @notice Error: Distributions can only be made to accounts that have staked. error AttemptToDistributeToNewAccount(address _account, uint256 _amount); - /// @notice Error: The sum of all amounts to distribute did not equal msg.value of the distribute transaction. - error DistributionAmountsDoNotMatchTotal(uint256 _msgValue, uint256 _calculatedTotalDistribution); - /// @notice Error: Call to stake for implementations that accept value require value and parameter to match. error MismatchMsgValueAmount(uint256 _msgValue, uint256 _amount); diff --git a/contracts/staking/StakeHolderBase.sol b/contracts/staking/StakeHolderBase.sol index 1c0188a0..a02ea8cc 100644 --- a/contracts/staking/StakeHolderBase.sol +++ b/contracts/staking/StakeHolderBase.sol @@ -81,6 +81,60 @@ abstract contract StakeHolderBase is IStakeHolder, AccessControlEnumerableUpgrad revert CanNotUpgradeToLowerOrSameVersion(version); } + /** + * @notice Allow any account to stake more value. + * @param _amount The amount of tokens to be staked. + */ + function stake(uint256 _amount) external payable nonReentrant { + if (_amount == 0) { + revert MustStakeMoreThanZero(); + } + _checksAndTransfer(_amount); + _addStake(msg.sender, _amount, false); + } + + + /** + * @inheritdoc IStakeHolder + */ + function unstake(uint256 _amountToUnstake) external nonReentrant { + StakeInfo storage stakeInfo = balances[msg.sender]; + uint256 currentStake = stakeInfo.stake; + if (currentStake < _amountToUnstake) { + revert UnstakeAmountExceedsBalance(_amountToUnstake, currentStake); + } + uint256 newBalance = currentStake - _amountToUnstake; + stakeInfo.stake = newBalance; + + emit StakeRemoved(msg.sender, _amountToUnstake, newBalance, block.timestamp); + + _sendValue(msg.sender, _amountToUnstake); + } + + + /** + * @inheritdoc IStakeHolder + */ + function distributeRewards( + AccountAmount[] calldata _recipientsAndAmounts + ) external payable nonReentrant onlyRole(DISTRIBUTE_ROLE) { + // Distribute the value. + uint256 total = 0; + uint256 len = _recipientsAndAmounts.length; + for (uint256 i = 0; i < len; i++) { + AccountAmount calldata accountAmount = _recipientsAndAmounts[i]; + uint256 amount = accountAmount.amount; + // Add stake, but require the acount to either currently be staking or have + // previously staked. + _addStake(accountAmount.account, amount, true); + total += amount; + } + if (total == 0) { + revert MustDistributeMoreThanZero(); + } + _checksAndTransfer(total); + emit Distributed(msg.sender, total, len); + } /** * @inheritdoc IStakeHolder @@ -117,6 +171,44 @@ abstract contract StakeHolderBase is IStakeHolder, AccessControlEnumerableUpgrad return stakerPartialArray; } + + /** + * @notice Add more stake to an account. + * @dev If the account has a zero balance prior to this call, add the account to the stakers array. + * @param _account Account to add stake to. + * @param _amount The amount of stake to add. + * @param _existingAccountsOnly If true, revert if the account has never been used. + */ + function _addStake(address _account, uint256 _amount, bool _existingAccountsOnly) internal { + StakeInfo storage stakeInfo = balances[_account]; + uint256 currentStake = stakeInfo.stake; + if (!stakeInfo.hasStaked) { + if (_existingAccountsOnly) { + revert AttemptToDistributeToNewAccount(_account, _amount); + } + stakers.push(_account); + stakeInfo.hasStaked = true; + } + uint256 newBalance = currentStake + _amount; + stakeInfo.stake = newBalance; + emit StakeAdded(_account, _amount, newBalance, block.timestamp); + } + + /** + * @notice Send value to an account. + * @param _to The account to sent value to. + * @param _amount The quantity to send. + */ + function _sendValue(address _to, uint256 _amount) internal virtual; + + + /** + * @notice Complete validity checks and for ERC20 variant transfer tokens. + * @param _amount Amount to be transferred. + */ + function _checksAndTransfer(uint256 _amount) internal virtual; + + // Override the _authorizeUpgrade function // solhint-disable-next-line no-empty-blocks function _authorizeUpgrade(address newImplementation) internal override onlyRole(UPGRADE_ROLE) {} diff --git a/contracts/staking/StakeHolderERC20.sol b/contracts/staking/StakeHolderERC20.sol index cff08a35..c4b4edac 100644 --- a/contracts/staking/StakeHolderERC20.sol +++ b/contracts/staking/StakeHolderERC20.sol @@ -35,103 +35,30 @@ contract StakeHolderERC20 is StakeHolderBase { } /** - * @notice Allow any account to stake more value. - * @param _amount The amount of tokens to be staked. + * @inheritdoc IStakeHolder */ - function stake(uint256 _amount) external payable nonReentrant { - if (msg.value != 0) { - revert NonPayable(); - } - if (_amount == 0) { - revert MustStakeMoreThanZero(); - } - token.safeTransferFrom(msg.sender, address(this), _amount); - _addStake(msg.sender, _amount, false); + function getToken() external view returns(address) { + return address(token); } /** - * @notice Allow any account to remove some or all of their own stake. - * @param _amountToUnstake Amount of stake to remove. + * @inheritdoc StakeHolderBase */ - function unstake(uint256 _amountToUnstake) external nonReentrant { - StakeInfo storage stakeInfo = balances[msg.sender]; - uint256 currentStake = stakeInfo.stake; - if (currentStake < _amountToUnstake) { - revert UnstakeAmountExceedsBalance(_amountToUnstake, currentStake); - } - uint256 newBalance = currentStake - _amountToUnstake; - stakeInfo.stake = newBalance; - - emit StakeRemoved(msg.sender, _amountToUnstake, newBalance, block.timestamp); - - token.safeTransfer(msg.sender, _amountToUnstake); + function _sendValue(address _to, uint256 _amount) internal override { + token.safeTransfer(_to, _amount); } /** - * @notice Accounts with DISTRIBUTE_ROLE can distribute tokens to any set of accounts. - * @param _recipientsAndAmounts An array of recipients to distribute value to and - * amounts to be distributed to each recipient. + * @inheritdoc StakeHolderBase */ - function distributeRewards( - AccountAmount[] calldata _recipientsAndAmounts - ) external payable nonReentrant onlyRole(DISTRIBUTE_ROLE) { + function _checksAndTransfer(uint256 _amount) internal override { if (msg.value != 0) { revert NonPayable(); } - - // Initial validity checks - uint256 len = _recipientsAndAmounts.length; - if (len == 0) { - revert MustDistributeMoreThanZero(); - } - - // Distribute the value. - uint256 total = 0; - for (uint256 i = 0; i < len; i++) { - AccountAmount calldata accountAmount = _recipientsAndAmounts[i]; - uint256 amount = accountAmount.amount; - // Add stake, but require the acount to either currently be staking or have - // previously staked. - _addStake(accountAmount.account, amount, true); - total += amount; - } - if (total == 0) { - revert MustDistributeMoreThanZero(); - } - token.safeTransferFrom(msg.sender, address(this), total); - emit Distributed(msg.sender, total, len); - } - - /** - * @inheritdoc IStakeHolder - */ - function getToken() external view returns(address) { - return address(token); + token.safeTransferFrom(msg.sender, address(this), _amount); } - /** - * @notice Add more stake to an account. - * @dev If the account has a zero balance prior to this call, add the account to the stakers array. - * @param _account Account to add stake to. - * @param _amount The amount of stake to add. - * @param _existingAccountsOnly If true, revert if the account has never been used. - */ - function _addStake(address _account, uint256 _amount, bool _existingAccountsOnly) private { - StakeInfo storage stakeInfo = balances[_account]; - uint256 currentStake = stakeInfo.stake; - if (!stakeInfo.hasStaked) { - if (_existingAccountsOnly) { - revert AttemptToDistributeToNewAccount(_account, _amount); - } - stakers.push(_account); - stakeInfo.hasStaked = true; - } - uint256 newBalance = currentStake + _amount; - stakeInfo.stake = newBalance; - emit StakeAdded(_account, _amount, newBalance, block.timestamp); - } - /// @notice storage gap for additional variables for upgrades // slither-disable-start unused-state // solhint-disable-next-line var-name-mixedcase diff --git a/contracts/staking/StakeHolderNative.sol b/contracts/staking/StakeHolderNative.sol index 9ae16c22..12594b04 100644 --- a/contracts/staking/StakeHolderNative.sol +++ b/contracts/staking/StakeHolderNative.sol @@ -26,40 +26,18 @@ contract StakeHolderNative is StakeHolderBase { } /** - * @notice Allow any account to stake more value. - * @dev The amount being staked is the value of msg.value. - * @dev This function does not need re-entrancy guard as the add stake - * mechanism does not call out to any external function. + * @inheritdoc IStakeHolder */ - function stake(uint256 _amount) external payable nonReentrant { - if (msg.value == 0) { - revert MustStakeMoreThanZero(); - } - if (_amount != msg.value) { - revert MismatchMsgValueAmount(msg.value, _amount); - } - _addStake(msg.sender, msg.value, false); + function getToken() external pure returns(address) { + return address(0); } /** - * @notice Allow any account to remove some or all of their own stake. - * @dev This function does not need re-entrancy guard as the state is updated - * prior to the call to the user's wallet. - * @param _amountToUnstake Amount of stake to remove. + * @inheritdoc StakeHolderBase */ - function unstake(uint256 _amountToUnstake) external nonReentrant { - StakeInfo storage stakeInfo = balances[msg.sender]; - uint256 currentStake = stakeInfo.stake; - if (currentStake < _amountToUnstake) { - revert UnstakeAmountExceedsBalance(_amountToUnstake, currentStake); - } - uint256 newBalance = currentStake - _amountToUnstake; - stakeInfo.stake = newBalance; - - emit StakeRemoved(msg.sender, _amountToUnstake, newBalance, block.timestamp); - + function _sendValue(address _to, uint256 _amount) internal override { // slither-disable-next-line low-level-calls - (bool success, bytes memory returndata) = payable(msg.sender).call{value: _amountToUnstake}(""); + (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. @@ -76,75 +54,18 @@ contract StakeHolderNative is StakeHolderBase { } /** - * @notice Accounts with DISTRIBUTE_ROLE can distribute tokens to any set of accounts. - * @dev The total amount to distribute must match msg.value. - * This function does not need re-entrancy guard as the distribution mechanism - * does not call out to another contract. - * @param _recipientsAndAmounts An array of recipients to distribute value to and - * amounts to be distributed to each recipient. - */ - function distributeRewards( - AccountAmount[] calldata _recipientsAndAmounts - ) external payable onlyRole(DISTRIBUTE_ROLE) nonReentrant { - // Initial validity checks - if (msg.value == 0) { - revert MustDistributeMoreThanZero(); - } - uint256 len = _recipientsAndAmounts.length; - - // Distribute the value. - uint256 total = 0; - for (uint256 i = 0; i < len; i++) { - AccountAmount calldata accountAmount = _recipientsAndAmounts[i]; - uint256 amount = accountAmount.amount; - // Add stake, but require the acount to either currently be staking or have - // previously staked. - _addStake(accountAmount.account, amount, true); - total += amount; - } - - // Check that the total distributed matches the msg.value. - if (total != msg.value) { - revert DistributionAmountsDoNotMatchTotal(msg.value, total); - } - emit Distributed(msg.sender, msg.value, len); - } - - - /** - * @inheritdoc IStakeHolder - */ - function getToken() external pure returns(address) { - return address(0); - } - - - /** - * @notice Add more stake to an account. - * @dev If the account has a zero balance prior to this call, add the account to the stakers array. - * @param _account Account to add stake to. - * @param _amount The amount of stake to add. - * @param _existingAccountsOnly If true, revert if the account has never been used. + * @inheritdoc StakeHolderBase */ - function _addStake(address _account, uint256 _amount, bool _existingAccountsOnly) private { - StakeInfo storage stakeInfo = balances[_account]; - uint256 currentStake = stakeInfo.stake; - if (!stakeInfo.hasStaked) { - if (_existingAccountsOnly) { - revert AttemptToDistributeToNewAccount(_account, _amount); - } - stakers.push(_account); - stakeInfo.hasStaked = true; + function _checksAndTransfer(uint256 _amount) internal override { + // Check that the amount matches the msg.value. + if (_amount != msg.value) { + revert MismatchMsgValueAmount(msg.value, _amount); } - uint256 newBalance = currentStake + _amount; - stakeInfo.stake = newBalance; - emit StakeAdded(_account, _amount, newBalance, block.timestamp); } - /// @notice storage gap for additional variables for upgrades // slither-disable-start unused-state // solhint-disable-next-line var-name-mixedcase - uint256[50] private __StakeHolderGap; + uint256[50] private __StakeHolderNativeGap; // slither-disable-end unused-state } diff --git a/test/staking/StakeHolderOperationalNative.t.sol b/test/staking/StakeHolderOperationalNative.t.sol index 199dc400..6fcf50a2 100644 --- a/test/staking/StakeHolderOperationalNative.t.sol +++ b/test/staking/StakeHolderOperationalNative.t.sol @@ -51,7 +51,7 @@ contract StakeHolderOperationalNativeTest is StakeHolderOperationalBaseTest { accountsAmounts[0] = IStakeHolder.AccountAmount(staker2, 0.5 ether); accountsAmounts[1] = IStakeHolder.AccountAmount(staker3, 1 ether); _distributeRewards(distributeAdmin, 1 ether, accountsAmounts, - abi.encodeWithSelector(IStakeHolder.DistributionAmountsDoNotMatchTotal.selector, 1 ether, 1.5 ether)); + abi.encodeWithSelector(IStakeHolder.MismatchMsgValueAmount.selector, 1 ether, 1.5 ether)); } function _deal(address _to, uint256 _amount) internal override { From 33732fa5fc3688669567a3df0f83c95cb0be8061 Mon Sep 17 00:00:00 2001 From: Peter Robinson Date: Wed, 16 Apr 2025 14:09:02 +1000 Subject: [PATCH 08/39] Improve documentation --- contracts/staking/README.md | 24 ++++++++++++++++++------ contracts/staking/staking.png | Bin 0 -> 26406 bytes 2 files changed, 18 insertions(+), 6 deletions(-) create mode 100644 contracts/staking/staking.png diff --git a/contracts/staking/README.md b/contracts/staking/README.md index b47ff281..cdeb92a2 100644 --- a/contracts/staking/README.md +++ b/contracts/staking/README.md @@ -1,6 +1,20 @@ # Staking -The Immutable zkEVM staking system consists of the Staking Holder contract. This contract holds staked native IMX. Any account (EOA or contract) can stake any amount at any time. An account can remove all or some of their stake at any time. The contract has the facility to distribute rewards to stakers. +The Immutable zkEVM staking system allows any account (EOA or contract) to stake any amount 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 system consists of a set of contracts show in the diagram below. + +![Staking System](./staking.png) + +`IStakeHolder.sol` is the interface that all staking implementations comply with. + +`StakeHolderBase.sol` is the base contract that all staking implementation use. + +`StakeHolderERC20.sol` allows an ERC20 token to be used as the staking currency. + +`StakeHolderNative.sol` uses the native token, IMX, to be used as the staking currency. + +`TimelockController.sol` can be used with the staking contracts to provide a one week delay between upgrade or other admin changes are proposed and when they are executed. ## Immutable Contract Addresses @@ -36,11 +50,11 @@ Optionally, you can also specify `--ledger` or `--trezor` for hardware deploymen # Usage -To stake, any account should call `stake()`, passing in the amount to be staked as the msg.value. +To stake, any account should call `stake(uint256 _amount)`. For the native IMX variant, the amount to be staked must be passed in as the msg.value. To unstake, the account that previously staked should call, `unstake(uint256 _amountToUnstake)`. -Accounts that have DISTRIBUTE_ROLE that wish to distribute rewards should call, `distributeRewards(AccountAmount[] calldata _recipientsAndAmounts)`. The `AccountAmount` structure consists of recipient address and amount to distribute pairs. Distributions can only be made to accounts that have previously or are currently staking. The amount to be distributed must be passed in as msg.value and must equal to the sum of the amounts specified in the `_recipientsAndAmounts` array. +Accounts that have DISTRIBUTE_ROLE that wish to distribute rewards should call, `distributeRewards(AccountAmount[] calldata _recipientsAndAmounts)`. The `AccountAmount` structure consists of recipient address and amount to distribute pairs. Distributions can only be made to accounts that have previously or are currently staking. For the native IMX variant, the amount to be distributed must be passed in as msg.value and must equal to the sum of the amounts specified in the `_recipientsAndAmounts` array. The `stakers` array needs to be analysed to determine which accounts have staked and how much. The following functions provide access to this data structure: @@ -51,9 +65,7 @@ The `stakers` array needs to be analysed to determine which accounts have staked # Administration Notes -The `StakeHolder` contract is `AccessControlEnumerableUpgradeable`, with the following minor modification: - -The `StakeHolder` contract is `UUPSUpgradeable`. Only accounts with `UPGRADE_ROLE` are authorised to upgrade the contract. +The `StakeHolderBase` contract is `AccessControlEnumerableUpgradeable`. The `StakeHolder` contract is `UUPSUpgradeable`. Only accounts with `UPGRADE_ROLE` are authorised to upgrade the contract. ## Upgrade Concept diff --git a/contracts/staking/staking.png b/contracts/staking/staking.png new file mode 100644 index 0000000000000000000000000000000000000000..d4290b4d4f743f145c205aa79be398d0f316992b GIT binary patch literal 26406 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 Date: Thu, 17 Apr 2025 12:04:43 +1000 Subject: [PATCH 09/39] Update script --- contracts/staking/IStakeHolder.sol | 4 +- contracts/staking/StakeHolderBase.sol | 28 +- contracts/staking/StakeHolderERC20.sol | 3 +- contracts/staking/StakeHolderNative.sol | 5 +- script/staking/DeployStakeHolderNative.sol | 132 -------- script/staking/StakeHolderScript.t.sol | 377 +++++++++++++++++++++ 6 files changed, 394 insertions(+), 155 deletions(-) delete mode 100644 script/staking/DeployStakeHolderNative.sol create mode 100644 script/staking/StakeHolderScript.t.sol diff --git a/contracts/staking/IStakeHolder.sol b/contracts/staking/IStakeHolder.sol index 97a1cff5..c42274bb 100644 --- a/contracts/staking/IStakeHolder.sol +++ b/contracts/staking/IStakeHolder.sol @@ -120,10 +120,10 @@ interface IStakeHolder is IAccessControlEnumerableUpgradeable { /** * @notice Only UPGRADE_ROLE can upgrade the contract */ - function UPGRADE_ROLE() external pure returns(bytes32); + function UPGRADE_ROLE() external pure returns (bytes32); /** * @notice Only DISTRIBUTE_ROLE can call the distribute function */ - function DISTRIBUTE_ROLE() external pure returns(bytes32); + function DISTRIBUTE_ROLE() external pure returns (bytes32); } diff --git a/contracts/staking/StakeHolderBase.sol b/contracts/staking/StakeHolderBase.sol index a02ea8cc..dd65b4d7 100644 --- a/contracts/staking/StakeHolderBase.sol +++ b/contracts/staking/StakeHolderBase.sol @@ -14,7 +14,12 @@ import {StakeHolderBase} from "./StakeHolderBase.sol"; * @title StakeHolderBase: allows anyone to stake any amount of an ERC20 token and to then remove all or part of that stake. * @dev The StakeHolderERC20 contract is designed to be upgradeable. */ -abstract contract StakeHolderBase is IStakeHolder, AccessControlEnumerableUpgradeable, UUPSUpgradeable, ReentrancyGuardUpgradeable { +abstract contract StakeHolderBase is + IStakeHolder, + AccessControlEnumerableUpgradeable, + UUPSUpgradeable, + ReentrancyGuardUpgradeable +{ /// @notice Only UPGRADE_ROLE can upgrade the contract bytes32 public constant UPGRADE_ROLE = bytes32("UPGRADE_ROLE"); @@ -52,11 +57,7 @@ abstract contract StakeHolderBase is IStakeHolder, AccessControlEnumerableUpgrad * @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 { + function __StakeHolderBase_init(address _roleAdmin, address _upgradeAdmin, address _distributeAdmin) internal { __UUPSUpgradeable_init(); __AccessControl_init(); __ReentrancyGuard_init(); @@ -93,7 +94,6 @@ abstract contract StakeHolderBase is IStakeHolder, AccessControlEnumerableUpgrad _addStake(msg.sender, _amount, false); } - /** * @inheritdoc IStakeHolder */ @@ -111,8 +111,7 @@ abstract contract StakeHolderBase is IStakeHolder, AccessControlEnumerableUpgrad _sendValue(msg.sender, _amountToUnstake); } - - /** + /** * @inheritdoc IStakeHolder */ function distributeRewards( @@ -139,21 +138,21 @@ abstract contract StakeHolderBase is IStakeHolder, AccessControlEnumerableUpgrad /** * @inheritdoc IStakeHolder */ - function getBalance(address _account) external view override (IStakeHolder) returns (uint256 _balance) { + function getBalance(address _account) external view override(IStakeHolder) returns (uint256 _balance) { return balances[_account].stake; } /** * @inheritdoc IStakeHolder */ - function hasStaked(address _account) external view override (IStakeHolder) returns (bool _everStaked) { + function hasStaked(address _account) external view override(IStakeHolder) returns (bool _everStaked) { return balances[_account].hasStaked; } /** * @inheritdoc IStakeHolder */ - function getNumStakers() external view override (IStakeHolder) returns (uint256 _len) { + function getNumStakers() external view override(IStakeHolder) returns (uint256 _len) { return stakers.length; } @@ -163,7 +162,7 @@ abstract contract StakeHolderBase is IStakeHolder, AccessControlEnumerableUpgrad function getStakers( uint256 _startOffset, uint256 _numberToReturn - ) external view override (IStakeHolder) returns (address[] memory _stakers) { + ) external view override(IStakeHolder) returns (address[] memory _stakers) { address[] memory stakerPartialArray = new address[](_numberToReturn); for (uint256 i = 0; i < _numberToReturn; i++) { stakerPartialArray[i] = stakers[_startOffset + i]; @@ -171,7 +170,6 @@ abstract contract StakeHolderBase is IStakeHolder, AccessControlEnumerableUpgrad return stakerPartialArray; } - /** * @notice Add more stake to an account. * @dev If the account has a zero balance prior to this call, add the account to the stakers array. @@ -201,14 +199,12 @@ abstract contract StakeHolderBase is IStakeHolder, AccessControlEnumerableUpgrad */ function _sendValue(address _to, uint256 _amount) internal virtual; - /** * @notice Complete validity checks and for ERC20 variant transfer tokens. * @param _amount Amount to be transferred. */ function _checksAndTransfer(uint256 _amount) internal virtual; - // Override the _authorizeUpgrade function // solhint-disable-next-line no-empty-blocks function _authorizeUpgrade(address newImplementation) internal override onlyRole(UPGRADE_ROLE) {} diff --git a/contracts/staking/StakeHolderERC20.sol b/contracts/staking/StakeHolderERC20.sol index c4b4edac..6f4cc3ae 100644 --- a/contracts/staking/StakeHolderERC20.sol +++ b/contracts/staking/StakeHolderERC20.sol @@ -37,7 +37,7 @@ contract StakeHolderERC20 is StakeHolderBase { /** * @inheritdoc IStakeHolder */ - function getToken() external view returns(address) { + function getToken() external view returns (address) { return address(token); } @@ -58,7 +58,6 @@ contract StakeHolderERC20 is StakeHolderBase { 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 diff --git a/contracts/staking/StakeHolderNative.sol b/contracts/staking/StakeHolderNative.sol index 12594b04..6e036ad4 100644 --- a/contracts/staking/StakeHolderNative.sol +++ b/contracts/staking/StakeHolderNative.sol @@ -6,13 +6,12 @@ pragma solidity >=0.8.19 <0.8.29; // import {AccessControlEnumerableUpgradeable} from "openzeppelin-contracts-upgradeable-4.9.3/access/AccessControlEnumerableUpgradeable.sol"; import {IStakeHolder, StakeHolderBase} from "./StakeHolderBase.sol"; - /** * @title StakeHolder: allows anyone to stake any amount of native IMX and to then remove all or part of that stake. * @dev The StakeHolder contract is designed to be upgradeable. */ contract StakeHolderNative is StakeHolderBase { - /// @notice Error: Unstake transfer failed. + /// @notice Error: Unstake transfer failed. error UnstakeTransferFailed(); /** @@ -28,7 +27,7 @@ contract StakeHolderNative is StakeHolderBase { /** * @inheritdoc IStakeHolder */ - function getToken() external pure returns(address) { + function getToken() external pure returns (address) { return address(0); } diff --git a/script/staking/DeployStakeHolderNative.sol b/script/staking/DeployStakeHolderNative.sol deleted file mode 100644 index af5b4d49..00000000 --- a/script/staking/DeployStakeHolderNative.sol +++ /dev/null @@ -1,132 +0,0 @@ -// Copyright (c) Immutable Pty Ltd 2018 - 2023 -// SPDX-License-Identifier: Apache-2.0 -pragma solidity ^0.8.20; - -import "forge-std/Test.sol"; -import "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; -import {StakeHolderNative} from "../../contracts/staking/StakeHolderNative.sol"; - -/** - * @title IDeployer Interface - * @notice This interface defines the contract responsible for deploying and optionally initializing new contracts - * via a specified deployment method. - * @dev Credit to axelarnetwork https://github.com/axelarnetwork/axelar-gmp-sdk-solidity/blob/main/contracts/interfaces/IDeployer.sol - */ -interface IDeployer { - function deploy(bytes memory bytecode, bytes32 salt) external payable returns (address deployedAddress_); - function deployAndInit(bytes memory bytecode, bytes32 salt, bytes calldata init) - external - payable - returns (address deployedAddress_); - function deployedAddress(bytes calldata bytecode, address sender, bytes32 salt) - external - view - returns (address deployedAddress_); -} - -struct DeploymentArgs { - address signer; - address factory; - string salt1; - string salt2; -} - -struct StakeHolderContractArgs { - address roleAdmin; - address upgradeAdmin; - address distributeAdmin; -} - -/** - * @notice Deployment script and test code for the deployment script. - * @dev testDeploy is the test. - * @dev deploy() is the function the script should call. - * For more details on deployment see ../../contracts/staking/README.md - */ -contract DeployStakeHolderNative is Test { - function testDeploy() external { - /// @dev Fork the Immutable zkEVM testnet for this test - string memory rpcURL = "https://rpc.testnet.immutable.com"; - vm.createSelectFork(rpcURL); - - /// @dev These are Immutable zkEVM testnet values where necessary - DeploymentArgs memory deploymentArgs = DeploymentArgs({ - signer: 0xdDA0d9448Ebe3eA43aFecE5Fa6401F5795c19333, - factory: 0x37a59A845Bb6eD2034098af8738fbFFB9D589610, - salt1: "salt1", - salt2: "salt2" - }); - - StakeHolderContractArgs memory stakeHolderContractArgs = StakeHolderContractArgs({ - roleAdmin: makeAddr("role"), - upgradeAdmin: makeAddr("upgrade"), - distributeAdmin: makeAddr("distribute") - }); - - // Run deployment against forked testnet - StakeHolderNative deployedContract = _deploy(deploymentArgs, stakeHolderContractArgs); - - assertTrue(deployedContract.hasRole(deployedContract.UPGRADE_ROLE(), stakeHolderContractArgs.upgradeAdmin), "Upgrade admin should have upgrade role"); - assertTrue(deployedContract.hasRole(deployedContract.DEFAULT_ADMIN_ROLE(), stakeHolderContractArgs.roleAdmin), "Role admin should have default admin role"); - // The DEFAULT_ADMIN_ROLE should be revoked from the deployer account - assertFalse(deployedContract.hasRole(deployedContract.DEFAULT_ADMIN_ROLE(), deploymentArgs.signer), "msg.sender should not be an admin"); - } - - function deploy() external { - address signer = vm.envAddress("DEPLOYER_ADDRESS"); - address factory = vm.envAddress("OWNABLE_CREATE3_FACTORY_ADDRESS"); - address roleAdmin = vm.envAddress("ROLE_ADMIN"); - address upgradeAdmin = vm.envAddress("UPGRADE_ADMIN"); - address distributeAdmin = vm.envAddress("DISTRIBUTE_ADMIN"); - string memory salt1 = vm.envString("IMPL_SALT"); - string memory salt2 = vm.envString("PROXY_SALT"); - - DeploymentArgs memory deploymentArgs = DeploymentArgs({signer: signer, factory: factory, salt1: salt1, salt2: salt2}); - - StakeHolderContractArgs memory stakeHolderContractArgs = - StakeHolderContractArgs({roleAdmin: roleAdmin, upgradeAdmin: upgradeAdmin, distributeAdmin: distributeAdmin}); - - _deploy(deploymentArgs, stakeHolderContractArgs); - } - - function _deploy(DeploymentArgs memory deploymentArgs, StakeHolderContractArgs memory stakeHolderContractArgs) - internal - returns (StakeHolderNative stakeHolderContract) - { - IDeployer ownableCreate3 = IDeployer(deploymentArgs.factory); - - // Deploy StakeHolder via the Ownable Create3 factory. - // That is: StakeHolder impl = new StakeHolder(); - // Create deployment bytecode and encode constructor args - bytes memory deploymentBytecode = abi.encodePacked( - type(StakeHolderNative).creationCode - ); - bytes32 saltBytes = keccak256(abi.encode(deploymentArgs.salt1)); - - /// @dev Deploy the contract via the Ownable CREATE3 factory - vm.startBroadcast(deploymentArgs.signer); - address stakeHolderImplAddress = ownableCreate3.deploy(deploymentBytecode, saltBytes); - vm.stopBroadcast(); - - // Create init data for teh ERC1967 Proxy - bytes memory initData = abi.encodeWithSelector( - StakeHolderNative.initialize.selector, stakeHolderContractArgs.roleAdmin, - stakeHolderContractArgs.upgradeAdmin, stakeHolderContractArgs.distributeAdmin - ); - - // Deploy ERC1967Proxy via the Ownable Create3 factory. - // That is: ERC1967Proxy proxy = new ERC1967Proxy(address(impl), initData); - // Create deployment bytecode and encode constructor args - deploymentBytecode = abi.encodePacked( - type(ERC1967Proxy).creationCode, - abi.encode(stakeHolderImplAddress, initData) - ); - saltBytes = keccak256(abi.encode(deploymentArgs.salt2)); - - /// @dev Deploy the contract via the Ownable CREATE3 factory - vm.startBroadcast(deploymentArgs.signer); - address stakeHolderContractAddress = ownableCreate3.deploy(deploymentBytecode, saltBytes); - vm.stopBroadcast(); - stakeHolderContract = StakeHolderNative(stakeHolderContractAddress); - } -} diff --git a/script/staking/StakeHolderScript.t.sol b/script/staking/StakeHolderScript.t.sol new file mode 100644 index 00000000..89549602 --- /dev/null +++ b/script/staking/StakeHolderScript.t.sol @@ -0,0 +1,377 @@ +// Copyright (c) Immutable Pty Ltd 2018 - 2023 +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.20; + +import "forge-std/Test.sol"; +import "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import "@openzeppelin/contracts/governance/TimelockController.sol"; +import {ERC20PresetFixedSupply} from "openzeppelin-contracts-4.9.3/token/ERC20/presets/ERC20PresetFixedSupply.sol"; +import {IERC20} from "openzeppelin-contracts-4.9.3/token/ERC20/IERC20.sol"; + +import {IStakeHolder} from "../../contracts/staking/IStakeHolder.sol"; +import {StakeHolderERC20} from "../../contracts/staking/StakeHolderERC20.sol"; + +/** + * @title IDeployer Interface + * @notice This interface defines the contract responsible for deploying and optionally initializing new contracts + * via a specified deployment method. + * @dev Credit to axelarnetwork https://github.com/axelarnetwork/axelar-gmp-sdk-solidity/blob/main/contracts/interfaces/IDeployer.sol + */ +interface IDeployer { + function deploy(bytes memory bytecode, bytes32 salt) external payable returns (address deployedAddress_); + function deployAndInit(bytes memory bytecode, bytes32 salt, bytes calldata init) + external + payable + returns (address deployedAddress_); + function deployedAddress(bytes calldata bytecode, address sender, bytes32 salt) + external + view + returns (address deployedAddress_); +} + +// Args needed for compex deployment using CREATE3 and a TimelockController +struct ComplexDeploymentArgs { + address signer; + address factory; + string salt; +} +struct ComplexStakeHolderContractArgs { + address distributeAdmin; + address token; +} +struct ComplexTimelockContractArgs { + uint256 timeDelayInSeconds; + address proposerAdmin; + address executorAdmin; +} + + +// Args needed for simple deployment +struct SimpleDeploymentArgs { + address deployer; +} +struct SimpleStakeHolderContractArgs { + address roleAdmin; + address upgradeAdmin; + address distributeAdmin; + address token; +} + + + +/** + * @notice Deployment script and test code for the deployment script. + * @dev testDeploy is the test. + * @dev deploy() is the function the script should call. + * For more details on deployment see ../../contracts/staking/README.md + */ +contract StakeHolderScript is Test { + /** + * Deploy StakeHolderERC20 using Create3, with the TimelockController. + */ + function deployComplex() external { + address signer = vm.envAddress("DEPLOYER_ADDRESS"); + address factory = vm.envAddress("OWNABLE_CREATE3_FACTORY_ADDRESS"); + address distributeAdmin = vm.envAddress("DISTRIBUTE_ADMIN"); + address token = vm.envAddress("ERC20_STAKING_TOKEN"); + uint256 timeDelayInSeconds = vm.envUint("TIMELOCK_DELAY_SECONDS"); + address proposerAdmin = vm.envAddress("TIMELOCK_PROPOSER_ADMIN"); + address executorAdmin = vm.envAddress("TIMELOCK_EXECUTOR_ADMIN"); + string memory salt = vm.envString("SALT"); + + ComplexDeploymentArgs memory deploymentArgs = ComplexDeploymentArgs({signer: signer, factory: factory, salt: salt}); + + ComplexStakeHolderContractArgs memory stakeHolderArgs = + ComplexStakeHolderContractArgs({distributeAdmin: distributeAdmin, token: token}); + + ComplexTimelockContractArgs memory timelockArgs = + ComplexTimelockContractArgs({timeDelayInSeconds: timeDelayInSeconds, proposerAdmin: proposerAdmin, executorAdmin: executorAdmin}); + _deployComplex(deploymentArgs, stakeHolderArgs, timelockArgs); + } + + /** + * Deploy StakeHolderERC20 using an EOA. + */ + function deploySimple() external { + address deployer = vm.envAddress("DEPLOYER_ADDRESS"); + address roleAdmin = vm.envAddress("ROLE_ADMIN"); + address upgradeAdmin = vm.envAddress("UPGRADE_ADMIN"); + address distributeAdmin = vm.envAddress("DISTRIBUTE_ADMIN"); + address token = vm.envAddress("ERC20_STAKING_TOKEN"); + + SimpleDeploymentArgs memory deploymentArgs = SimpleDeploymentArgs({deployer: deployer}); + + SimpleStakeHolderContractArgs memory stakeHolderArgs = + SimpleStakeHolderContractArgs({ + roleAdmin: roleAdmin, upgradeAdmin: upgradeAdmin, + distributeAdmin: distributeAdmin, token: token}); + _deploySimple(deploymentArgs, stakeHolderArgs); + } + + function stake() external { + address stakeHolder = vm.envAddress("STAKE_HOLDER_CONTRACT"); + address staker = vm.envAddress("STAKER_ADDRESS"); + uint256 amount = vm.envUint("STAKER_AMOUNT"); + _stake(IStakeHolder(stakeHolder), staker, amount); + } + + function unstake() external { + address stakeHolder = vm.envAddress("STAKE_HOLDER_CONTRACT"); + address staker = vm.envAddress("STAKER_ADDRESS"); + uint256 amount = vm.envUint("STAKER_AMOUNT"); + _unstake(IStakeHolder(stakeHolder), staker, amount); + } + + + /** + * Deploy StakeHolderERC20 using Create3, with the TimelockController. + */ + function _deployComplex( + ComplexDeploymentArgs memory deploymentArgs, + ComplexStakeHolderContractArgs memory stakeHolderArgs, + ComplexTimelockContractArgs memory timelockArgs) + private + returns (StakeHolderERC20 stakeHolderContract, TimelockController timelockController) + { + IDeployer ownableCreate3 = IDeployer(deploymentArgs.factory); + + bytes32 salt1 = keccak256(abi.encode(deploymentArgs.salt)); + bytes32 salt2 = keccak256(abi.encode(salt1)); + bytes32 salt3 = keccak256(abi.encode(salt2)); + + // Deploy TimelockController via the Ownable Create3 factory. + address timelockAddress; + bytes memory deploymentBytecode; + { + address[] memory proposers = new address[](1); + proposers[0] = timelockArgs.proposerAdmin; + address[] memory executors = new address[](1); + executors[0] = timelockArgs.executorAdmin; + // Create deployment bytecode and encode constructor args + deploymentBytecode = abi.encodePacked( + type(TimelockController).creationCode, + abi.encode( + timelockArgs.timeDelayInSeconds, + proposers, + executors, + address(0) + ) + ); + /// @dev Deploy the contract via the Ownable CREATE3 factory + vm.startBroadcast(deploymentArgs.signer); + timelockAddress = ownableCreate3.deploy(deploymentBytecode, salt1); + vm.stopBroadcast(); + } + + + // Deploy StakeHolderERC20 via the Ownable Create3 factory. + // Create deployment bytecode and encode constructor args + deploymentBytecode = abi.encodePacked( + type(StakeHolderERC20).creationCode + ); + /// @dev Deploy the contract via the Ownable CREATE3 factory + vm.startBroadcast(deploymentArgs.signer); + address stakeHolderImplAddress = ownableCreate3.deploy(deploymentBytecode, salt2); + vm.stopBroadcast(); + + // Deploy ERC1967Proxy via the Ownable Create3 factory. + // Create init data for the ERC1967 Proxy + bytes memory initData = abi.encodeWithSelector( + StakeHolderERC20.initialize.selector, + timelockAddress, // roleAdmin + timelockAddress, // upgradeAdmin + stakeHolderArgs.distributeAdmin, + stakeHolderArgs.token + ); + // Create deployment bytecode and encode constructor args + deploymentBytecode = abi.encodePacked( + type(ERC1967Proxy).creationCode, + abi.encode(stakeHolderImplAddress, initData) + ); + /// @dev Deploy the contract via the Ownable CREATE3 factory + vm.startBroadcast(deploymentArgs.signer); + address stakeHolderContractAddress = ownableCreate3.deploy(deploymentBytecode, salt3); + vm.stopBroadcast(); + + stakeHolderContract = StakeHolderERC20(stakeHolderContractAddress); + timelockController = TimelockController(payable(timelockAddress)); + } + + /** + * Deploy StakeHolderERC20 using an EOA and no time lock. + */ + function _deploySimple( + SimpleDeploymentArgs memory deploymentArgs, + SimpleStakeHolderContractArgs memory stakeHolderArgs) + private + returns (StakeHolderERC20 stakeHolderContract) { + + bytes memory initData = abi.encodeWithSelector( + StakeHolderERC20.initialize.selector, + stakeHolderArgs.roleAdmin, + stakeHolderArgs.upgradeAdmin, + stakeHolderArgs.distributeAdmin, + stakeHolderArgs.token); + + vm.startBroadcast(deploymentArgs.deployer); + StakeHolderERC20 impl = new StakeHolderERC20(); + vm.stopBroadcast(); + vm.startBroadcast(deploymentArgs.deployer); + ERC1967Proxy proxy = new ERC1967Proxy(address(impl), initData); + vm.stopBroadcast(); + + stakeHolderContract = StakeHolderERC20(address(proxy)); + } + + function _stake(IStakeHolder _stakeHolder, address _staker, uint256 _amount) private { + address tokenAddress = _stakeHolder.getToken(); + IERC20 erc20 = IERC20(tokenAddress); + + uint256 bal = erc20.balanceOf(_staker); + if (bal < _amount) { + revert("Insufficient balance"); + } + + vm.startBroadcast(_staker); + erc20.approve(address(_stakeHolder), _amount); + _stakeHolder.stake(_amount); + vm.stopBroadcast(); + } + + function _unstake(IStakeHolder _stakeHolder, address _staker, uint256 _amount) private { + vm.startBroadcast(_staker); + _stakeHolder.unstake(_amount); + vm.stopBroadcast(); + } + + + function testComplex() external { + /// @dev Fork the Immutable zkEVM testnet for this test + string memory rpcURL = "https://rpc.testnet.immutable.com"; + vm.createSelectFork(rpcURL); + + address bank = makeAddr("bank"); + vm.startBroadcast(bank); + ERC20PresetFixedSupply erc20 = + new ERC20PresetFixedSupply("Name", "SYM", 1000 ether, bank); + vm.stopBroadcast(); + + /// @dev These are Immutable zkEVM testnet values where necessary + address immTestNetCreate3 = 0x37a59A845Bb6eD2034098af8738fbFFB9D589610; + ComplexDeploymentArgs memory deploymentArgs = ComplexDeploymentArgs({ + signer: 0xdDA0d9448Ebe3eA43aFecE5Fa6401F5795c19333, + factory: immTestNetCreate3, + salt: "salt" + }); + + address distributeAdmin = makeAddr("distribute"); + ComplexStakeHolderContractArgs memory stakeHolderArgs = + ComplexStakeHolderContractArgs({ + distributeAdmin: distributeAdmin, + token: address(erc20) + }); + + uint256 delay = 604800; // 604800 seconds = 1 week + address proposer = makeAddr("proposer"); + address executor = makeAddr("executor"); + + ComplexTimelockContractArgs memory timelockArgs = + ComplexTimelockContractArgs({ + timeDelayInSeconds: delay, + proposerAdmin: proposer, + executorAdmin: executor + }); + + // Run deployment against forked testnet + StakeHolderERC20 stakeHolder; + TimelockController timelockController; + (stakeHolder, timelockController) = + _deployComplex(deploymentArgs, stakeHolderArgs, timelockArgs); + + _commonTest(true, IStakeHolder(stakeHolder), address(timelockController), + bank, immTestNetCreate3, address(0), address(0), distributeAdmin); + + assertTrue(timelockController.hasRole(timelockController.PROPOSER_ROLE(), proposer), "Proposer not set correcrly"); + assertTrue(timelockController.hasRole(timelockController.EXECUTOR_ROLE(), executor), "Executor not set correcrly"); + assertEq(timelockController.getMinDelay(), delay, "Delay not set correctly"); + } + + function testSimple() external { + /// @dev Fork the Immutable zkEVM testnet for this test + string memory rpcURL = "https://rpc.testnet.immutable.com"; + vm.createSelectFork(rpcURL); + + address deployer = makeAddr("deployer"); + address bank = makeAddr("bank"); + + vm.startBroadcast(deployer); + ERC20PresetFixedSupply erc20 = + new ERC20PresetFixedSupply("Name", "SYM", 1000 ether, bank); + vm.stopBroadcast(); + + /// @dev These are Immutable zkEVM testnet values where necessary + SimpleDeploymentArgs memory deploymentArgs = SimpleDeploymentArgs({ + deployer: deployer + }); + + address roleAdmin = makeAddr("role"); + address upgradeAdmin = makeAddr("upgrade"); + address distributeAdmin = makeAddr("distribute"); + + SimpleStakeHolderContractArgs memory stakeHolderContractArgs = + SimpleStakeHolderContractArgs({ + roleAdmin: roleAdmin, + upgradeAdmin: upgradeAdmin, + distributeAdmin: distributeAdmin, + token: address(erc20) + }); + + // Run deployment against forked testnet + StakeHolderERC20 stakeHolder = _deploySimple(deploymentArgs, stakeHolderContractArgs); + + _commonTest(false, IStakeHolder(stakeHolder), address(0), + bank, deployer, roleAdmin, upgradeAdmin, distributeAdmin); + } + + function _commonTest( + bool _isComplex, + IStakeHolder _stakeHolder, + address _timelockControl, + address _bank, + address _deployer, + address _roleAdmin, + address _upgradeAdmin, + address _distributeAdmin + ) private { + address roleAdmin = _isComplex ? _timelockControl : _roleAdmin; + address upgradeAdmin = _isComplex ? _timelockControl : _upgradeAdmin; + + address tokenAddress = _stakeHolder.getToken(); + IERC20 erc20 = IERC20(tokenAddress); + + // Post deployment checks + { + StakeHolderERC20 temp = new StakeHolderERC20(); + bytes32 defaultAdminRole = temp.DEFAULT_ADMIN_ROLE(); + assertTrue(_stakeHolder.hasRole(_stakeHolder.UPGRADE_ROLE(), upgradeAdmin), "Upgrade admin should have upgrade role"); + assertTrue(_stakeHolder.hasRole(defaultAdminRole, roleAdmin), "Role admin should have default admin role"); + assertTrue(_stakeHolder.hasRole(_stakeHolder.DISTRIBUTE_ROLE(), _distributeAdmin), "Distribute admin should have distribute role"); + // The DEFAULT_ADMIN_ROLE should be revoked from the deployer account + assertFalse(_stakeHolder.hasRole(defaultAdminRole, _deployer), "msg.sender should not be an admin"); + } + + address user1 = makeAddr("user1"); + vm.startBroadcast(_bank); + erc20.transfer(user1, 100 ether); + vm.stopBroadcast(); + + _stake(_stakeHolder, user1, 10 ether); + + assertEq(erc20.balanceOf(user1), 90 ether, "User1 balance after stake"); + assertEq(erc20.balanceOf(address(_stakeHolder)), 10 ether, "StakeHolder balance after stake"); + + _unstake(_stakeHolder, user1, 7 ether); + assertEq(erc20.balanceOf(user1), 97 ether, "User1 balance after unstake"); + assertEq(erc20.balanceOf(address(_stakeHolder)), 3 ether, "StakeHolder balance after unstake"); + } +} From b433db3be8d789d9099a99b753dba0979fd97795 Mon Sep 17 00:00:00 2001 From: Peter Robinson Date: Thu, 17 Apr 2025 13:51:06 +1000 Subject: [PATCH 10/39] Resolve slither warning --- contracts/staking/StakeHolderNative.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contracts/staking/StakeHolderNative.sol b/contracts/staking/StakeHolderNative.sol index 6e036ad4..263b5232 100644 --- a/contracts/staking/StakeHolderNative.sol +++ b/contracts/staking/StakeHolderNative.sol @@ -35,7 +35,7 @@ contract StakeHolderNative is StakeHolderBase { * @inheritdoc StakeHolderBase */ function _sendValue(address _to, uint256 _amount) internal override { - // slither-disable-next-line low-level-calls + // 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. From fbf1bd6d8d83843ac6311981d60c829f916a42dc Mon Sep 17 00:00:00 2001 From: Peter Robinson Date: Thu, 17 Apr 2025 17:02:56 +1000 Subject: [PATCH 11/39] Added initial scripts --- .env.example | 5 ++-- contracts/staking/README.md | 4 +-- script/staking/common.sh | 50 +++++++++++++++++++++++++++++++++ script/staking/deployComplex.sh | 41 +++++++++++++++++++++++++++ script/staking/deploySimple.sh | 40 ++++++++++++++++++++++++++ 5 files changed, 135 insertions(+), 5 deletions(-) create mode 100644 script/staking/common.sh create mode 100644 script/staking/deployComplex.sh create mode 100644 script/staking/deploySimple.sh diff --git a/.env.example b/.env.example index cfbc9940..2866cbbf 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,3 @@ -ETHERSCAN_API_KEY= -SEPOLIA_URL=https://eth-sepolia.g.alchemy.com/v2/ -MAINNET_URL=https://eth-mainnet.g.alchemy.com/v2/ +BLOCKSCOUT_APIKEY= PRIVATE_KEY= +LEDGER_HD_PATH= diff --git a/contracts/staking/README.md b/contracts/staking/README.md index cdeb92a2..9049e1ee 100644 --- a/contracts/staking/README.md +++ b/contracts/staking/README.md @@ -37,12 +37,12 @@ Contract threat models and audits: **Deploy and verify using CREATE3 factory contract:** -This repo includes a script for deploying via a CREATE3 factory contract. The script is defined as a test contract as per the examples [here](https://book.getfoundry.sh/reference/forge/forge-script#examples) and can be found in `./script/staking/DeployStakeHolder.sol`. +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.t.sol`. See the `.env.example` for required environment variables. ```sh -forge script script/staking/DeployStakeHolder.sol --tc DeployStakeHolder --sig "deploy()" -vvv --rpc-url {rpc-url} --broadcast --verifier-url https://explorer.immutable.com/api --verifier blockscout --verify --gas-price 10000000000 +forge script script/staking/DeployStakeHolder.t.sol --tc DeployStakeHolder --sig "deploy()" -vvv --rpc-url {rpc-url} --broadcast --verifier-url https://explorer.immutable.com/api --verifier blockscout --verify --gas-price 10000000000 ``` Optionally, you can also specify `--ledger` or `--trezor` for hardware deployments. See docs [here](https://book.getfoundry.sh/reference/forge/forge-script#wallet-options---hardware-wallet). diff --git a/script/staking/common.sh b/script/staking/common.sh new file mode 100644 index 00000000..46900ef4 --- /dev/null +++ b/script/staking/common.sh @@ -0,0 +1,50 @@ +#!/bin/bash +# Load the .env file if it exists +if [ -f .env ] +then + set -a; source .env; set +a +fi + +if [[ $useMainNet -eq 1 ]] +then + echo Immutable zkEVM Mainnet Configuration + RPC=https://rpc.immutable.com + BLOCKSCOUT_URI=https://explorer.immutable.com/api? + USEMAINNET=true +else + echo Immutable zkEVM Testnet Configuration + RPC=https://rpc.testnet.immutable.com + BLOCKSCOUT_URI=https://explorer.testnet.immutable.com/api? + USEMAINNET=false +fi +if [ -z "${BLOCKSCOUT_APIKEY}" ]; then + echo "Error: BLOCKSCOUT_APIKEY environment variable is not set" + exit 1 +fi + +if [[ $useLedger -eq 1 ]] +then + echo " with Ledger Hardware Wallet" + if [ -z "${LEDGER_HD_PATH}" ]; then + echo "Error: LEDGER_HD_PATH environment variable is not set" + exit 1 + fi +else + echo " with a raw private key" + if [ -z "${PRIVATE_KEY}" ]; then + echo "Error: PRIVATE_KEY environment variable is not set" + exit 1 + fi +fi + + +echo "Configuration" +echo " RPC: $RPC" +echo " BLOCKSCOUT_APIKEY: $BLOCKSCOUT_APIKEY" +echo " BLOCKSCOUT_URI: $BLOCKSCOUT_URI" +if [[ $useLedger -eq 1 ]] +then + echo LEDGER_HD_PATH: $LEDGER_HD_PATH +else + echo " PRIVATE_KEY: " # $PRIVATE_KEY +fi diff --git a/script/staking/deployComplex.sh b/script/staking/deployComplex.sh new file mode 100644 index 00000000..ac0112fe --- /dev/null +++ b/script/staking/deployComplex.sh @@ -0,0 +1,41 @@ +#!/bin/bash +# useMainNet: 1 for mainnet, 0 for testnet +useMainNet=0 +# useLedger: 1 for ledger, 0 for private key +useLedger=0 +# Set-up variables +source $(dirname "$0")/common.sh + + +# NOTE WELL --------------------------------------------- +# Add resume option if the script fails part way through: +# --resume \ +# NOTE WELL --------------------------------------------- +if [[ $useLedger -eq 1 ]] +then + forge script --rpc-url $RPC \ + --priority-gas-price 10000000000 \ + --with-gas-price 10000000100 \ + -vvv \ + --broadcast \ + --verify \ + --verifier blockscout \ + --verifier-url $BLOCKSCOUT_URI$BLOCKSCOUT_APIKEY \ + --sig "deployComplex()" \ + --ledger \ + --hd-paths "$LEDGER_HD_PATH" \ + script/staking/StakeHolderScript.t.sol:StakeHolderScript +else + forge script --rpc-url $RPC \ + --priority-gas-price 10000000000 \ + --with-gas-price 10000000100 \ + -vvv \ + --broadcast \ + --verify \ + --verifier blockscout \ + --verifier-url $BLOCKSCOUT_URI$BLOCKSCOUT_APIKEY \ + --sig "deployComplex()" \ + --private-key $PRIVATE_KEY \ + script/staking/StakeHolderScript.t.sol:StakeHolderScript +fi + diff --git a/script/staking/deploySimple.sh b/script/staking/deploySimple.sh new file mode 100644 index 00000000..62dd7b08 --- /dev/null +++ b/script/staking/deploySimple.sh @@ -0,0 +1,40 @@ +#!/bin/bash +# useMainNet: 1 for mainnet, 0 for testnet +useMainNet=0 +# useLedger: 1 for ledger, 0 for private key +useLedger=0 +# Set-up variables +source $(dirname "$0")/common.sh + + +# NOTE WELL --------------------------------------------- +# Add resume option if the script fails part way through: +# --resume \ +# NOTE WELL --------------------------------------------- +if [[ $useLedger -eq 1 ]] +then + forge script --rpc-url $RPC \ + --priority-gas-price 10000000000 \ + --with-gas-price 10000000100 \ + -vvv \ + --broadcast \ + --verify \ + --verifier blockscout \ + --verifier-url $BLOCKSCOUT_URI$BLOCKSCOUT_APIKEY \ + --sig "deploySimple()" \ + --ledger \ + --hd-paths "$LEDGER_HD_PATH" \ + script/staking/StakeHolderScript.t.sol:StakeHolderScript +else + forge script --rpc-url $RPC \ + --priority-gas-price 10000000000 \ + --with-gas-price 10000000100 \ + -vvv \ + --broadcast \ + --verify \ + --verifier blockscout \ + --verifier-url $BLOCKSCOUT_URI$BLOCKSCOUT_APIKEY \ + --sig "deploySimple()" \ + --private-key $PRIVATE_KEY \ + script/staking/StakeHolderScript.t.sol:StakeHolderScript +fi From 512a5cbb05e78cef013bba06f8288fdacf89bb35 Mon Sep 17 00:00:00 2001 From: Peter Robinson Date: Tue, 22 Apr 2025 15:45:33 +1000 Subject: [PATCH 12/39] Added stake and unstake --- script/staking/StakeHolderScript.t.sol | 23 +++++++++++++++ script/staking/common.sh | 41 ++++++++++++++++++++++++++ script/staking/deployComplex.sh | 37 +++-------------------- script/staking/deployDeployer.sh | 12 ++++++++ script/staking/deploySimple.sh | 36 +++------------------- script/staking/stake.sh | 12 ++++++++ script/staking/unstake.sh | 12 ++++++++ 7 files changed, 108 insertions(+), 65 deletions(-) create mode 100644 script/staking/deployDeployer.sh create mode 100644 script/staking/stake.sh create mode 100644 script/staking/unstake.sh diff --git a/script/staking/StakeHolderScript.t.sol b/script/staking/StakeHolderScript.t.sol index 89549602..42845857 100644 --- a/script/staking/StakeHolderScript.t.sol +++ b/script/staking/StakeHolderScript.t.sol @@ -10,6 +10,7 @@ import {IERC20} from "openzeppelin-contracts-4.9.3/token/ERC20/IERC20.sol"; import {IStakeHolder} from "../../contracts/staking/IStakeHolder.sol"; import {StakeHolderERC20} from "../../contracts/staking/StakeHolderERC20.sol"; +import {OwnableCreate3Deployer} from "../../contracts/deployer/create3/OwnableCreate3Deployer.sol"; /** * @title IDeployer Interface @@ -66,6 +67,15 @@ struct SimpleStakeHolderContractArgs { * For more details on deployment see ../../contracts/staking/README.md */ contract StakeHolderScript is Test { + + /** + * Deploy the OwnableCreate3Deployer needed for the complex deployment. + */ + function deployDeployer() external { + address signer = vm.envAddress("DEPLOYER_ADDRESS"); + _deployDeployer(signer); + } + /** * Deploy StakeHolderERC20 using Create3, with the TimelockController. */ @@ -123,6 +133,17 @@ contract StakeHolderScript is Test { } + + /** + * Deploy the OwnableCreate3Deployer contract. Set the owner to the + * contract deployer. + */ + function _deployDeployer(address _deployer) private { + vm.startBroadcast(_deployer); + new OwnableCreate3Deployer(_deployer); + vm.stopBroadcast(); + } + /** * Deploy StakeHolderERC20 using Create3, with the TimelockController. */ @@ -228,6 +249,8 @@ contract StakeHolderScript is Test { IERC20 erc20 = IERC20(tokenAddress); uint256 bal = erc20.balanceOf(_staker); + console.log("Balance is: %x", bal); + console.log("Amount is: %x", bal); if (bal < _amount) { revert("Insufficient balance"); } diff --git a/script/staking/common.sh b/script/staking/common.sh index 46900ef4..0af34b72 100644 --- a/script/staking/common.sh +++ b/script/staking/common.sh @@ -37,6 +37,12 @@ else fi fi +if [ -z "${FUNCTION_TO_EXECUTE}" ]; then + echo "Error: FUNCTION_TO_EXECUTE variable is not set" + exit 1 +fi + + echo "Configuration" echo " RPC: $RPC" @@ -48,3 +54,38 @@ then else echo " PRIVATE_KEY: " # $PRIVATE_KEY fi +echo "Function to execute: $FUNCTION_TO_EXECUTE" + + + +# NOTE WELL --------------------------------------------- +# Add resume option if the script fails part way through: +# --resume \ +# NOTE WELL --------------------------------------------- +if [[ $useLedger -eq 1 ]] +then + forge script --rpc-url $RPC \ + --priority-gas-price 10000000000 \ + --with-gas-price 10000000100 \ + -vvv \ + --broadcast \ + --verify \ + --verifier blockscout \ + --verifier-url $BLOCKSCOUT_URI$BLOCKSCOUT_APIKEY \ + --sig "$FUNCTION_TO_EXECUTE" \ + --ledger \ + --hd-paths "$LEDGER_HD_PATH" \ + script/staking/StakeHolderScript.t.sol:StakeHolderScript +else + forge script --rpc-url $RPC \ + --priority-gas-price 10000000000 \ + --with-gas-price 10000000100 \ + -vvv \ + --broadcast \ + --verify \ + --verifier blockscout \ + --verifier-url $BLOCKSCOUT_URI$BLOCKSCOUT_APIKEY \ + --sig "$FUNCTION_TO_EXECUTE" \ + --private-key $PRIVATE_KEY \ + script/staking/StakeHolderScript.t.sol:StakeHolderScript +fi diff --git a/script/staking/deployComplex.sh b/script/staking/deployComplex.sh index ac0112fe..622a7994 100644 --- a/script/staking/deployComplex.sh +++ b/script/staking/deployComplex.sh @@ -3,39 +3,10 @@ useMainNet=0 # useLedger: 1 for ledger, 0 for private key useLedger=0 -# Set-up variables -source $(dirname "$0")/common.sh +FUNCTION_TO_EXECUTE='deployComplex()' + +# Set-up variables and execute forge +source $(dirname "$0")/common.sh -# NOTE WELL --------------------------------------------- -# Add resume option if the script fails part way through: -# --resume \ -# NOTE WELL --------------------------------------------- -if [[ $useLedger -eq 1 ]] -then - forge script --rpc-url $RPC \ - --priority-gas-price 10000000000 \ - --with-gas-price 10000000100 \ - -vvv \ - --broadcast \ - --verify \ - --verifier blockscout \ - --verifier-url $BLOCKSCOUT_URI$BLOCKSCOUT_APIKEY \ - --sig "deployComplex()" \ - --ledger \ - --hd-paths "$LEDGER_HD_PATH" \ - script/staking/StakeHolderScript.t.sol:StakeHolderScript -else - forge script --rpc-url $RPC \ - --priority-gas-price 10000000000 \ - --with-gas-price 10000000100 \ - -vvv \ - --broadcast \ - --verify \ - --verifier blockscout \ - --verifier-url $BLOCKSCOUT_URI$BLOCKSCOUT_APIKEY \ - --sig "deployComplex()" \ - --private-key $PRIVATE_KEY \ - script/staking/StakeHolderScript.t.sol:StakeHolderScript -fi diff --git a/script/staking/deployDeployer.sh b/script/staking/deployDeployer.sh new file mode 100644 index 00000000..41975b52 --- /dev/null +++ b/script/staking/deployDeployer.sh @@ -0,0 +1,12 @@ +#!/bin/bash +# useMainNet: 1 for mainnet, 0 for testnet +useMainNet=0 +# useLedger: 1 for ledger, 0 for private key +useLedger=0 + +FUNCTION_TO_EXECUTE='deployDeployer()' + +# Set-up variables and execute forge +source $(dirname "$0")/common.sh + + diff --git a/script/staking/deploySimple.sh b/script/staking/deploySimple.sh index 62dd7b08..6cfb2442 100644 --- a/script/staking/deploySimple.sh +++ b/script/staking/deploySimple.sh @@ -3,38 +3,10 @@ useMainNet=0 # useLedger: 1 for ledger, 0 for private key useLedger=0 -# Set-up variables + +FUNCTION_TO_EXECUTE='deploySimple()' + +# Set-up variables and execute forge source $(dirname "$0")/common.sh -# NOTE WELL --------------------------------------------- -# Add resume option if the script fails part way through: -# --resume \ -# NOTE WELL --------------------------------------------- -if [[ $useLedger -eq 1 ]] -then - forge script --rpc-url $RPC \ - --priority-gas-price 10000000000 \ - --with-gas-price 10000000100 \ - -vvv \ - --broadcast \ - --verify \ - --verifier blockscout \ - --verifier-url $BLOCKSCOUT_URI$BLOCKSCOUT_APIKEY \ - --sig "deploySimple()" \ - --ledger \ - --hd-paths "$LEDGER_HD_PATH" \ - script/staking/StakeHolderScript.t.sol:StakeHolderScript -else - forge script --rpc-url $RPC \ - --priority-gas-price 10000000000 \ - --with-gas-price 10000000100 \ - -vvv \ - --broadcast \ - --verify \ - --verifier blockscout \ - --verifier-url $BLOCKSCOUT_URI$BLOCKSCOUT_APIKEY \ - --sig "deploySimple()" \ - --private-key $PRIVATE_KEY \ - script/staking/StakeHolderScript.t.sol:StakeHolderScript -fi diff --git a/script/staking/stake.sh b/script/staking/stake.sh new file mode 100644 index 00000000..2114efff --- /dev/null +++ b/script/staking/stake.sh @@ -0,0 +1,12 @@ +#!/bin/bash +# useMainNet: 1 for mainnet, 0 for testnet +useMainNet=0 +# useLedger: 1 for ledger, 0 for private key +useLedger=0 + +FUNCTION_TO_EXECUTE='stake()' + +# Set-up variables and execute forge +source $(dirname "$0")/common.sh + + diff --git a/script/staking/unstake.sh b/script/staking/unstake.sh new file mode 100644 index 00000000..23c98687 --- /dev/null +++ b/script/staking/unstake.sh @@ -0,0 +1,12 @@ +#!/bin/bash +# useMainNet: 1 for mainnet, 0 for testnet +useMainNet=0 +# useLedger: 1 for ledger, 0 for private key +useLedger=0 + +FUNCTION_TO_EXECUTE='unstake()' + +# Set-up variables and execute forge +source $(dirname "$0")/common.sh + + From 67ca971bb214176d8e9481ffb4df33c85f0f6d8a Mon Sep 17 00:00:00 2001 From: Peter Robinson Date: Wed, 23 Apr 2025 13:41:25 +1000 Subject: [PATCH 13/39] Improve documentation and scripts --- contracts/deployer/README.md | 2 - contracts/staking/README.md | 34 +++++++------- contracts/staking/staking-architecture.png | Bin 0 -> 60601 bytes contracts/staking/staking.png | Bin 26406 -> 0 bytes script/staking/README.md | 52 +++++++++++++++++++++ script/staking/common.sh | 42 ++++++++--------- script/staking/deployComplex.sh | 6 --- script/staking/deployDeployer.sh | 6 --- script/staking/deploySimple.sh | 6 --- script/staking/stake.sh | 6 --- script/staking/unstake.sh | 6 --- 11 files changed, 90 insertions(+), 70 deletions(-) create mode 100644 contracts/staking/staking-architecture.png delete mode 100644 contracts/staking/staking.png create mode 100644 script/staking/README.md diff --git a/contracts/deployer/README.md b/contracts/deployer/README.md index f6647200..20374a6a 100644 --- a/contracts/deployer/README.md +++ b/contracts/deployer/README.md @@ -1,7 +1,5 @@ # Contract Deployers -At present, use of the Contract Deployers described below is limited to Immutable. - This directory provides two types of contract deployers: CREATE2 and CREATE3. Both deployer types facilitate contract deployment to predictable addresses, independent of the deployer account’s nonce. The deployers offer a more reliable alternative to using a Nonce Reserver Key (a key that is only used for deploying contracts, and has specific nonces reserved for deploying specific contracts), particularly across different chains. These factories can also be utilized for contracts that don't necessarily need predictable addresses. The advantage of this method, compared to using a deployer key in conjunction with a deployment factory contract, is that it can enable better standardisation and simplification of deployment processes and enables the rotation of the deployer key without impacting the consistency of the perceived deployer address for contracts. Deployments via these factories can only be performed by the owner of the factory. diff --git a/contracts/staking/README.md b/contracts/staking/README.md index 9049e1ee..23448cee 100644 --- a/contracts/staking/README.md +++ b/contracts/staking/README.md @@ -1,23 +1,31 @@ # Staking -The Immutable zkEVM staking system allows any account (EOA or contract) to stake any amount at any time. An account can remove all or some of their stake at any time. The contract has the facility to distribute rewards to stakers. +The Immutable zkEVM staking system allows any account (EOA or contract) to stake any amount of a token at any time. An account can remove all or some of their stake at any time. The contract has the facility to distribute rewards to stakers. + +The staking contracts are upgradeable and operate via a proxy contract. They use the [Universal Upgradeable Proxy Standard (UUPS)](https://eips.ethereum.org/EIPS/eip-1822) upgrade pattern, whether the authorisation and access control for upgrade resides within the application contract (the staking contract). The system consists of a set of contracts show in the diagram below. -![Staking System](./staking.png) +![Staking Architecture](./staking-architecture.png) `IStakeHolder.sol` is the interface that all staking implementations comply with. -`StakeHolderBase.sol` is the base contract that all staking implementation use. +`StakeHolderBase.sol` is the abstract base contract that all staking implementation use. `StakeHolderERC20.sol` allows an ERC20 token to be used as the staking currency. `StakeHolderNative.sol` uses the native token, IMX, to be used as the staking currency. -`TimelockController.sol` can be used with the staking contracts to provide a one week delay between upgrade or other admin changes are proposed and when they are executed. +`ERC1967Proxy.sol` is a proxy contract. All calls to StakeHolder contracts go via the ERC1967Proxy contract. + +`TimelockController.sol` can be used with the staking contracts to provide a one week delay between upgrade or other admin changes are proposed and when they are executed. See below for information on how to configure the time lock controller. + +`OwnableCreate3Deployer.sol` ensures contracts are deployed to the same addresses across chains. The use of this contract is optional. See [deployment scripts](../../script/staking/README.md) for more information. ## Immutable Contract Addresses +StakeHolderERC20.sol configured with IMX as the staking token: + | Environment/Network | Deployment Address | Commit Hash | |--------------------------|--------------------|-------------| | Immutable zkEVM Testnet | Not deployed yet | -| @@ -35,21 +43,13 @@ Contract threat models and audits: # Deployment -**Deploy and verify using CREATE3 factory contract:** - -This repo includes a script for deploying via a CREATE3 factory contract. The script is defined as a test contract as per the examples [here](https://book.getfoundry.sh/reference/forge/forge-script#examples) and can be found in `./script/staking/DeployStakeHolder.t.sol`. - -See the `.env.example` for required environment variables. - -```sh -forge script script/staking/DeployStakeHolder.t.sol --tc DeployStakeHolder --sig "deploy()" -vvv --rpc-url {rpc-url} --broadcast --verifier-url https://explorer.immutable.com/api --verifier blockscout --verify --gas-price 10000000000 -``` - -Optionally, you can also specify `--ledger` or `--trezor` for hardware deployments. See docs [here](https://book.getfoundry.sh/reference/forge/forge-script#wallet-options---hardware-wallet). +See [deployment scripts](../../script/staking/README.md). # Usage +For StakeHolderERC20, the ERC20 staking token must be specified when the contract is being initialised. The token can not be changed. + To stake, any account should call `stake(uint256 _amount)`. For the native IMX variant, the amount to be staked must be passed in as the msg.value. To unstake, the account that previously staked should call, `unstake(uint256 _amountToUnstake)`. @@ -65,7 +65,7 @@ The `stakers` array needs to be analysed to determine which accounts have staked # Administration Notes -The `StakeHolderBase` contract is `AccessControlEnumerableUpgradeable`. The `StakeHolder` contract is `UUPSUpgradeable`. Only accounts with `UPGRADE_ROLE` are authorised to upgrade the contract. +The `StakeHolderBase` contract is `AccessControlEnumerableUpgradeable`. The `StakeHolderERC20` and `StakeHolderNative` contracts are `UUPSUpgradeable`. Only accounts with `UPGRADE_ROLE` are authorised to upgrade the contract. ## Upgrade Concept @@ -84,4 +84,4 @@ A staking systems may wish to delay upgrade actions and the granting of addition ## Preventing Upgrade -A staking system should 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. +A staking system could choose to have no account with DEFAULT_ADMIN_ROLE to to prevent additional accounts being granted UPGRADE_ROLE role. The system could have no acccounts with UPGRADE_ROLE, thus preventing upgrade. The system could configure this from start-up by passing in `address(0)` as the `roleAdmin` and `upgradeAdmin` to the constructor. Alternative, the `revokeRole` function can be used to revoke the roles from accounts. diff --git a/contracts/staking/staking-architecture.png b/contracts/staking/staking-architecture.png new file mode 100644 index 0000000000000000000000000000000000000000..f408bc44c94dc56ee2efe861824699ec2f3f6057 GIT binary patch literal 60601 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νqERR`@w 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" # $PRIVATE_KEY fi @@ -62,9 +63,8 @@ echo "Function to execute: $FUNCTION_TO_EXECUTE" # Add resume option if the script fails part way through: # --resume \ # NOTE WELL --------------------------------------------- -if [[ $useLedger -eq 1 ]] -then - forge script --rpc-url $RPC \ +if [ "${HARDWARE_WALLET}" = "ledger" ] || [ "${HARDWARE_WALLET}" = "trezor" ]; then + forge script --rpc-url $IMMUTABLE_RPC \ --priority-gas-price 10000000000 \ --with-gas-price 10000000100 \ -vvv \ @@ -73,11 +73,11 @@ then --verifier blockscout \ --verifier-url $BLOCKSCOUT_URI$BLOCKSCOUT_APIKEY \ --sig "$FUNCTION_TO_EXECUTE" \ - --ledger \ - --hd-paths "$LEDGER_HD_PATH" \ + --$HARDWARE_WALLET \ + --hd-paths "$HD_PATH" \ script/staking/StakeHolderScript.t.sol:StakeHolderScript else - forge script --rpc-url $RPC \ + forge script --rpc-url $IMMUTABLE_RPC \ --priority-gas-price 10000000000 \ --with-gas-price 10000000100 \ -vvv \ diff --git a/script/staking/deployComplex.sh b/script/staking/deployComplex.sh index 622a7994..6df9142c 100644 --- a/script/staking/deployComplex.sh +++ b/script/staking/deployComplex.sh @@ -1,11 +1,5 @@ #!/bin/bash -# useMainNet: 1 for mainnet, 0 for testnet -useMainNet=0 -# useLedger: 1 for ledger, 0 for private key -useLedger=0 - FUNCTION_TO_EXECUTE='deployComplex()' - # Set-up variables and execute forge source $(dirname "$0")/common.sh diff --git a/script/staking/deployDeployer.sh b/script/staking/deployDeployer.sh index 41975b52..65173489 100644 --- a/script/staking/deployDeployer.sh +++ b/script/staking/deployDeployer.sh @@ -1,11 +1,5 @@ #!/bin/bash -# useMainNet: 1 for mainnet, 0 for testnet -useMainNet=0 -# useLedger: 1 for ledger, 0 for private key -useLedger=0 - FUNCTION_TO_EXECUTE='deployDeployer()' - # Set-up variables and execute forge source $(dirname "$0")/common.sh diff --git a/script/staking/deploySimple.sh b/script/staking/deploySimple.sh index 6cfb2442..7f971cb0 100644 --- a/script/staking/deploySimple.sh +++ b/script/staking/deploySimple.sh @@ -1,11 +1,5 @@ #!/bin/bash -# useMainNet: 1 for mainnet, 0 for testnet -useMainNet=0 -# useLedger: 1 for ledger, 0 for private key -useLedger=0 - FUNCTION_TO_EXECUTE='deploySimple()' - # Set-up variables and execute forge source $(dirname "$0")/common.sh diff --git a/script/staking/stake.sh b/script/staking/stake.sh index 2114efff..96e4a0f9 100644 --- a/script/staking/stake.sh +++ b/script/staking/stake.sh @@ -1,11 +1,5 @@ #!/bin/bash -# useMainNet: 1 for mainnet, 0 for testnet -useMainNet=0 -# useLedger: 1 for ledger, 0 for private key -useLedger=0 - FUNCTION_TO_EXECUTE='stake()' - # Set-up variables and execute forge source $(dirname "$0")/common.sh diff --git a/script/staking/unstake.sh b/script/staking/unstake.sh index 23c98687..9c7485cc 100644 --- a/script/staking/unstake.sh +++ b/script/staking/unstake.sh @@ -1,11 +1,5 @@ #!/bin/bash -# useMainNet: 1 for mainnet, 0 for testnet -useMainNet=0 -# useLedger: 1 for ledger, 0 for private key -useLedger=0 - FUNCTION_TO_EXECUTE='unstake()' - # Set-up variables and execute forge source $(dirname "$0")/common.sh From fe367a3eb8fedd7599f232c8bdc94c3e8771e25f Mon Sep 17 00:00:00 2001 From: Peter Robinson Date: Wed, 23 Apr 2025 15:07:56 +1000 Subject: [PATCH 14/39] Initial version of threat model --- .../202504-threat-model-stake-holder.md | 262 ++++++++++++++++++ contracts/staking/README.md | 2 +- contracts/staking/StakeHolderERC20.sol | 2 - contracts/staking/StakeHolderNative.sol | 2 - script/staking/StakeHolderScript.t.sol | 4 +- 5 files changed, 265 insertions(+), 7 deletions(-) create mode 100644 audits/staking/202504-threat-model-stake-holder.md diff --git a/audits/staking/202504-threat-model-stake-holder.md b/audits/staking/202504-threat-model-stake-holder.md new file mode 100644 index 00000000..aabeabf2 --- /dev/null +++ b/audits/staking/202504-threat-model-stake-holder.md @@ -0,0 +1,262 @@ +# Stake Holder Threat Model + +## Introduction + +This threat model document for the [StakeHolderERC20 and StakeHolderNative](../../contracts/staking/README.md) contracts has been created in preparation for internal audit. + +## Rationale + +Immutable operates a system whereby people can place Wrapped 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. + + +## Threat Model Scope + +The threat model is limited to the stake holder Solidity files at GitHash [`67ca971bb214176d8e9481ffb4df33c85f0f6d8a`](https://github.com/immutable/contracts/tree/67ca971bb214176d8e9481ffb4df33c85f0f6d8a/contracts/staking): + +* [StakeHolder.sol](https://github.com/immutable/contracts/blob/fd982abc49884af41e05f18349b13edc9eefbc1e/contracts/staking/StakeHolder.sol) + +* [IStakeHolder.sol](https://github.com/immutable/contracts/blob/67ca971bb214176d8e9481ffb4df33c85f0f6d8a/contracts/staking/IStakeHolder.sol) is the interface that all staking implementations comply with. +* [StakeHolderBase.sol](https://github.com/immutable/contracts/blob/67ca971bb214176d8e9481ffb4df33c85f0f6d8a/contracts/staking/StakeHolderBase.sol) is the abstract base contract that all staking implementation use. +* [StakeHolderERC20.sol](https://github.com/immutable/contracts/blob/67ca971bb214176d8e9481ffb4df33c85f0f6d8a/contracts/staking/StakeHolderERC20.sol) allows an ERC20 token to be used as the staking currency. +* [StakeHolderNative.sol](https://github.com/immutable/contracts/blob/67ca971bb214176d8e9481ffb4df33c85f0f6d8a/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 upgrade or other admin changes are proposed and when they are executed. + + +## Background + +See the [README](https://github.com/immutable/contracts/tree/67ca971bb214176d8e9481ffb4df33c85f0f6d8a/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. `StakeHolderERC20` and `StakeHolderNative` have identical functions with the exception of the `initialize` function. `StakeHolderERC20` uses the `initialize` function that has four parameters and `StakeHolderNative` uses the `initialize` function with three parameters. + +``` +forge inspect StakeHolderERC20 methods +forge inspect StakeHolderNative methods +``` + +Functions that *change* state: + +| Name | Function Selector | Access Control | +| --------------------------------------- | ----------------- | ------------------- | +| `distributeRewards((address,uint256)[])`| 00cfb539 | Permissionless | +| `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 | +| `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` on StakeHolderERC20 and StakeHolderNative contracts + +The `DEFAULT_ADMIN_ROLE` is the role is granted to the `roleAdmin` specified in the `initialize` function of the `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` on StakeHolderERC20 and StakeHolderNative contracts + +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` on StakeHolderERC20 and StakeHolderNative contracts + +An account with `DISTRIBUTE_ROLE` can: + +* Call the `distributeRewards` function to distribute rewards. +* 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 StakeHolderERC20 + +The table was constructed by using the command described below, and analysing the source code. + +``` +forge inspect StakeHolderERC20 storage +``` + +| Name | Type | Slot | Offset | Bytes | Source File | +| --------------------------------- | -------------------------------------------------------------- | ---- | ------ | ----- | ----------- | +| \_initialized | uint8 | 0 | 0 | 1 | OpenZeppelin Contracts v4.9.3: proxy/utils/Initializable.sol | +| \_initializing | bool | 0 | 1 | 1 | OpenZeppelin Contracts v4.9.3: proxy/utils/Initializable.sol | +| \_\_gap | uint256[50] | 1 | 0 | 1600 | OpenZeppelin Contracts v4.9.3: utils/Context.sol | +| \_\_gap | uint256[50] | 51 | 0 | 1600 | OpenZeppelin Contracts v4.9.3: utils/introspection/ERC165.sol | +| \_roles | mapping(bytes32 => struct AccessControlUpgradeable.RoleData) | 101 | 0 | 32 | OpenZeppelin Contracts v4.9.3: access/AccessControlUpgradeable.sol | +| \_\_gap | uint256[49] | 102 | 0 | 1568 | OpenZeppelin Contracts v4.9.3: access/AccessControlUpgradeable.sol | +| \_roleMembers | mapping(bytes32 => struct EnumerableSetUpgradeable.AddressSet) | 151 | 0 | 32 | OpenZeppelin Contracts v4.9.3: access/AccessControlEnumerableUpgradeable.sol | +| \_\_gap | uint256[49] | 152 | 0 | 1568 | OpenZeppelin Contracts v4.9.3: access/AccessControlEnumerableUpgradeable.sol | +| \_\_gap | uint256[50] | 201 | 0 | 1600 | OpenZeppelin Contracts v4.9.3: proxy/ERC1967/ERC1967Upgrade.sol | +| \_\_gap | uint256[50] | 251 | 0 | 1600 | OpenZeppelin Contracts v4.9.3: proxy/utils/UUPSUpgradeable.sol | +| \_\status | uint256 | 301 | 0 | 32 | OpenZeppelin Contracts v4.9.3: security/ReentrancyGuardUpgradeable.sol | +| \_\_gap | uint256[49] | 302 | 0 | 1568 | OpenZeppelin Contracts v4.9.3: security/ReentrancyGuardUpgradeable.sol | +| balances | mapping(address => struct StakeHolder.StakeInfo) | 351 | 0 | 32 | StakeHolderBase.sol | +| stakers | address[] | 352 | 0 | 32 | StakeHolderBase.sol | +| version | uint256 | 353 | 0 | 32 | StakeHolderBase.sol | +| \_\_StakeHolderBaseGap | uint256[50] | 354 | 0 | 1600 | StakeHolderBase.sol | +| token | contract IERC20Upgradeable | 404 | 0 | 20 | StakeHolderERC20.sol | +| \_\_StakeHolderERC20Gap | uint256[50] | 405 | 0 | 1600 | StakeHolderERC20.sol | + + +#### Upgrade and Storage Slots for StakeHolderNative + +The table was constructed by using the command described below, and analysing the source code. + +``` +forge inspect StakeHolderNative storage +``` + +| Name | Type | Slot | Offset | Bytes | Source File | +| --------------------------------- | -------------------------------------------------------------- | ---- | ------ | ----- | ----------- | +| \_initialized | uint8 | 0 | 0 | 1 | OpenZeppelin Contracts v4.9.3: proxy/utils/Initializable.sol | +| \_initializing | bool | 0 | 1 | 1 | OpenZeppelin Contracts v4.9.3: proxy/utils/Initializable.sol | +| \_\_gap | uint256[50] | 1 | 0 | 1600 | OpenZeppelin Contracts v4.9.3: utils/Context.sol | +| \_\_gap | uint256[50] | 51 | 0 | 1600 | OpenZeppelin Contracts v4.9.3: utils/introspection/ERC165.sol | +| \_roles | mapping(bytes32 => struct AccessControlUpgradeable.RoleData) | 101 | 0 | 32 | OpenZeppelin Contracts v4.9.3: access/AccessControlUpgradeable.sol | +| \_\_gap | uint256[49] | 102 | 0 | 1568 | OpenZeppelin Contracts v4.9.3: access/AccessControlUpgradeable.sol | +| \_roleMembers | mapping(bytes32 => struct EnumerableSetUpgradeable.AddressSet) | 151 | 0 | 32 | OpenZeppelin Contracts v4.9.3: access/AccessControlEnumerableUpgradeable.sol | +| \_\_gap | uint256[49] | 152 | 0 | 1568 | OpenZeppelin Contracts v4.9.3: access/AccessControlEnumerableUpgradeable.sol | +| \_\_gap | uint256[50] | 201 | 0 | 1600 | OpenZeppelin Contracts v4.9.3: proxy/ERC1967/ERC1967Upgrade.sol | +| \_\_gap | uint256[50] | 251 | 0 | 1600 | OpenZeppelin Contracts v4.9.3: proxy/utils/UUPSUpgradeable.sol | +| \_\status | uint256 | 301 | 0 | 32 | OpenZeppelin Contracts v4.9.3: security/ReentrancyGuardUpgradeable.sol | +| \_\_gap | uint256[49] | 302 | 0 | 1568 | OpenZeppelin Contracts v4.9.3: security/ReentrancyGuardUpgradeable.sol | +| balances | mapping(address => struct StakeHolder.StakeInfo) | 351 | 0 | 32 | StakeHolderBase.sol | +| stakers | address[] | 352 | 0 | 32 | StakeHolderBase.sol | +| version | uint256 | 353 | 0 | 32 | StakeHolderBase.sol | +| \_\_StakeHolderBaseGap | uint256[50] | 354 | 0 | 1600 | StakeHolderBase.sol | +| \_\_StakeHolderERC20Gap | uint256[50] | 404 | 0 | 1600 | StakeHolderNative.sol | + + +### Timelock Controller Bypass + +To ensure time delay upgrades are enforced, the `StakeHolderERC20` or `StakeHolderNative` contracts should have the only account with `UPGRADE_ROLE` and `DEFAULT_ADMIN_ROLE` roles should be an instance of Open Zeppelin's [TimelockController](https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/governance/TimelockController.sol). This ensures any upgrade proposals or proposals to add more accounts with `DEFAULT_ADMIN_ROLE`, `UPGRADE_ROLE` or `DISTRIBUTE_ROLE` must go through a time delay before being actioned. The account with `DEFAULT_ADMIN_ROLE` could choose to renounce this role to ensure the `TimelockController` can not be bypassed at a later date by having a compromised account with `DEFAULT_ADMIN_ROLE` adding addtional 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/README.md b/contracts/staking/README.md index 23448cee..707a99f3 100644 --- a/contracts/staking/README.md +++ b/contracts/staking/README.md @@ -80,7 +80,7 @@ The `upgradeStorage` function should be updated each new contract version. It sh ## Time Delay Upgrade and Admin -A staking systems may wish to delay upgrade actions and the granting of additional administrative access. To do this, the only account with UPGRADE_ROLE and DEFAULT_ADMIN_ROLE roles should be an instance of Open Zeppelin's [TimelockController](https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/governance/TimelockController.sol). This ensures any upgrade proposals or proposals to add more accounts with DEFAULT_ADMIN_ROLE, UPGRADE_ROLE or DISTRIBUTE_ROLE must go through a time delay before being actioned. +A staking systems may wish to delay upgrade actions and the granting of additional administrative access. To do this, the only account with UPGRADE_ROLE and DEFAULT_ADMIN_ROLE roles should be an instance of Open Zeppelin's [TimelockController](https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/governance/TimelockController.sol). This ensures any upgrade proposals or proposals to add more accounts with `DEFAULT_ADMIN_ROLE`, `UPGRADE_ROLE` or `DISTRIBUTE_ROLE` must go through a time delay before being actioned. The account with `DEFAULT_ADMIN_ROLE` could choose to renounce this role to ensure the `TimelockController` can not be bypassed at a later date by having a compromised account with `DEFAULT_ADMIN_ROLE` adding addtional accounts with `UPGRADE_ROLE`. ## Preventing Upgrade diff --git a/contracts/staking/StakeHolderERC20.sol b/contracts/staking/StakeHolderERC20.sol index 6f4cc3ae..cca01d1a 100644 --- a/contracts/staking/StakeHolderERC20.sol +++ b/contracts/staking/StakeHolderERC20.sol @@ -2,8 +2,6 @@ // SPDX-License-Identifier: Apache 2 pragma solidity >=0.8.19 <0.8.29; -// import {UUPSUpgradeable} from "openzeppelin-contracts-upgradeable-4.9.3/proxy/utils/UUPSUpgradeable.sol"; -// import {AccessControlEnumerableUpgradeable} from "openzeppelin-contracts-upgradeable-4.9.3/access/AccessControlEnumerableUpgradeable.sol"; import {IERC20Upgradeable} from "openzeppelin-contracts-upgradeable-4.9.3/token/ERC20/IERC20Upgradeable.sol"; import {SafeERC20Upgradeable} from "openzeppelin-contracts-upgradeable-4.9.3/token/ERC20/utils/SafeERC20Upgradeable.sol"; import {IStakeHolder, StakeHolderBase} from "./StakeHolderBase.sol"; diff --git a/contracts/staking/StakeHolderNative.sol b/contracts/staking/StakeHolderNative.sol index 263b5232..930b6ff5 100644 --- a/contracts/staking/StakeHolderNative.sol +++ b/contracts/staking/StakeHolderNative.sol @@ -2,8 +2,6 @@ // SPDX-License-Identifier: Apache 2 pragma solidity >=0.8.19 <0.8.29; -// import {UUPSUpgradeable} from "openzeppelin-contracts-upgradeable-4.9.3/proxy/utils/UUPSUpgradeable.sol"; -// import {AccessControlEnumerableUpgradeable} from "openzeppelin-contracts-upgradeable-4.9.3/access/AccessControlEnumerableUpgradeable.sol"; import {IStakeHolder, StakeHolderBase} from "./StakeHolderBase.sol"; /** diff --git a/script/staking/StakeHolderScript.t.sol b/script/staking/StakeHolderScript.t.sol index 42845857..eb12639b 100644 --- a/script/staking/StakeHolderScript.t.sol +++ b/script/staking/StakeHolderScript.t.sol @@ -3,8 +3,8 @@ pragma solidity ^0.8.20; import "forge-std/Test.sol"; -import "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; -import "@openzeppelin/contracts/governance/TimelockController.sol"; +import {ERC1967Proxy} from "openzeppelin-contracts-4.9.3/proxy/ERC1967/ERC1967Proxy.sol"; +import {TimelockController} from "openzeppelin-contracts-4.9.3/governance/TimelockController.sol"; import {ERC20PresetFixedSupply} from "openzeppelin-contracts-4.9.3/token/ERC20/presets/ERC20PresetFixedSupply.sol"; import {IERC20} from "openzeppelin-contracts-4.9.3/token/ERC20/IERC20.sol"; From d824673a2ff105b0b6b06dcea14b0f0038fa328c Mon Sep 17 00:00:00 2001 From: Peter Robinson Date: Wed, 23 Apr 2025 15:11:03 +1000 Subject: [PATCH 15/39] Fix typos --- contracts/payment-splitter/PaymentSplitter.sol | 2 +- contracts/staking/StakeHolderBase.sol | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/contracts/payment-splitter/PaymentSplitter.sol b/contracts/payment-splitter/PaymentSplitter.sol index f07c8800..b322f03a 100644 --- a/contracts/payment-splitter/PaymentSplitter.sol +++ b/contracts/payment-splitter/PaymentSplitter.sol @@ -65,7 +65,7 @@ contract PaymentSplitter is AccessControlEnumerable, IPaymentSplitterErrors, Ree /** * @notice Payable fallback method to receive IMX. The IMX received will be logged with {PaymentReceived} events. * this contract has no other payable method, all IMX receives will be tracked by the events emitted by this event - * ERC20 receives will not be tracked by this contract but tranfers events will be emitted by the erc20 contracts themselves. + * ERC20 receives will not be tracked by this contract but transfers events will be emitted by the erc20 contracts themselves. */ receive() external payable virtual { emit PaymentReceived(_msgSender(), msg.value); diff --git a/contracts/staking/StakeHolderBase.sol b/contracts/staking/StakeHolderBase.sol index dd65b4d7..72116900 100644 --- a/contracts/staking/StakeHolderBase.sol +++ b/contracts/staking/StakeHolderBase.sol @@ -123,7 +123,7 @@ abstract contract StakeHolderBase is for (uint256 i = 0; i < len; i++) { AccountAmount calldata accountAmount = _recipientsAndAmounts[i]; uint256 amount = accountAmount.amount; - // Add stake, but require the acount to either currently be staking or have + // Add stake, but require the account to either currently be staking or have // previously staked. _addStake(accountAmount.account, amount, true); total += amount; From 9fe9b745aeabd83a8830267261ecbadbcd150d6f Mon Sep 17 00:00:00 2001 From: Peter Robinson Date: Wed, 23 Apr 2025 15:39:16 +1000 Subject: [PATCH 16/39] Improve testing --- .env.example | 8 +++++++- test/staking/StakeHolderOperationalERC20.t.sol | 13 +++++++++++++ test/staking/StakeHolderOperationalNative.t.sol | 8 ++++++++ 3 files changed, 28 insertions(+), 1 deletion(-) diff --git a/.env.example b/.env.example index 2866cbbf..1a191cd5 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,9 @@ +IMMUTABLE_NETWORK=0 BLOCKSCOUT_APIKEY= PRIVATE_KEY= -LEDGER_HD_PATH= +HD_PATH="m/44'/60'/0'/0/0" +DEPLOYER_ADDRESS= +ROLE_ADMIN= +UPGRADE_ADMIN= +DISTRIBUTE_ADMIN= +ERC20_STAKING_TOKEN= Date: Wed, 23 Apr 2025 15:47:09 +1000 Subject: [PATCH 17/39] Change install command for slither --- .github/workflows/test.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5c21fbd0..c17bfc4d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -75,7 +75,8 @@ jobs: - name: Checkout Code uses: actions/checkout@v3 - name: Install Slither - run: sudo pip3 install slither-analyzer + run: sudo python3 -m pip install slither-analyzer + # Install command now failing: sudo pip3 install slither-analyzer - name: Show Slither Version run: slither --version - name: Install Foundry From 357956c3e97ca5efb5a0a4c35cccab98c86aceba Mon Sep 17 00:00:00 2001 From: Peter Robinson Date: Wed, 23 Apr 2025 16:00:52 +1000 Subject: [PATCH 18/39] Attempt to fix Debian package issue with Slither install --- .github/workflows/test.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c17bfc4d..383df949 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -74,9 +74,10 @@ jobs: steps: - name: Checkout Code uses: actions/checkout@v3 + - name: Uninstall Debian package that slither needs to uninstall + run: pip uninstall typing_extensions - name: Install Slither - run: sudo python3 -m pip install slither-analyzer - # Install command now failing: sudo pip3 install slither-analyzer + run: sudo pip3 install slither-analyzer - name: Show Slither Version run: slither --version - name: Install Foundry From 6dfb97aa80817ad6c0298da8c414383a3a05dfdd Mon Sep 17 00:00:00 2001 From: Peter Robinson Date: Wed, 23 Apr 2025 16:03:44 +1000 Subject: [PATCH 19/39] Second attempt to fix Debian package issue with Slither install --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 383df949..db7fba87 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -75,7 +75,7 @@ jobs: - name: Checkout Code uses: actions/checkout@v3 - name: Uninstall Debian package that slither needs to uninstall - run: pip uninstall typing_extensions + run: sudo apt remove python3-typing-extensions - name: Install Slither run: sudo pip3 install slither-analyzer - name: Show Slither Version From 39a719c77f7c7c4269a5f74a2791d8c0861b9c96 Mon Sep 17 00:00:00 2001 From: Peter Robinson Date: Wed, 23 Apr 2025 16:15:20 +1000 Subject: [PATCH 20/39] Disabled slither check --- .../zones/immutable-signed-zone/v2/ImmutableSignedZoneV2.sol | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/contracts/trading/seaport/zones/immutable-signed-zone/v2/ImmutableSignedZoneV2.sol b/contracts/trading/seaport/zones/immutable-signed-zone/v2/ImmutableSignedZoneV2.sol index 01f03efc..1f750122 100644 --- a/contracts/trading/seaport/zones/immutable-signed-zone/v2/ImmutableSignedZoneV2.sol +++ b/contracts/trading/seaport/zones/immutable-signed-zone/v2/ImmutableSignedZoneV2.sol @@ -568,6 +568,10 @@ contract ImmutableSignedZoneV2 is uint256 scalingFactorDenominator ) internal pure returns (bytes32) { uint256 numberOfItems = receivedItems.length; + // Slither has added an additional checker. Rather than update this in-production code, + // disable the check for this line. The receivedItemsHash variable will be assigned a + // default value, so the code works. + // @slither-disable-next-line uninitialized-local bytes memory receivedItemsHash; for (uint256 i; i < numberOfItems; i++) { From bf327c7abdadd48fd51ae632500510ac2b07b5f0 Mon Sep 17 00:00:00 2001 From: Peter Robinson Date: Thu, 24 Apr 2025 08:59:34 +1000 Subject: [PATCH 21/39] Explicity initialise to empty bytes to fix static analysis check --- .../immutable-signed-zone/v2/ImmutableSignedZoneV2.sol | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/contracts/trading/seaport/zones/immutable-signed-zone/v2/ImmutableSignedZoneV2.sol b/contracts/trading/seaport/zones/immutable-signed-zone/v2/ImmutableSignedZoneV2.sol index 1f750122..29631173 100644 --- a/contracts/trading/seaport/zones/immutable-signed-zone/v2/ImmutableSignedZoneV2.sol +++ b/contracts/trading/seaport/zones/immutable-signed-zone/v2/ImmutableSignedZoneV2.sol @@ -568,11 +568,7 @@ contract ImmutableSignedZoneV2 is uint256 scalingFactorDenominator ) internal pure returns (bytes32) { uint256 numberOfItems = receivedItems.length; - // Slither has added an additional checker. Rather than update this in-production code, - // disable the check for this line. The receivedItemsHash variable will be assigned a - // default value, so the code works. - // @slither-disable-next-line uninitialized-local - bytes memory receivedItemsHash; + bytes memory receivedItemsHash = new bytes(0); // Explicitly initialize to empty bytes for (uint256 i; i < numberOfItems; i++) { receivedItemsHash = abi.encodePacked( From dd8c2fa11682096bf76b4fd23f41dd19ffc51505 Mon Sep 17 00:00:00 2001 From: Peter Robinson Date: Thu, 24 Apr 2025 09:08:25 +1000 Subject: [PATCH 22/39] Update githash for target of threat model --- audits/staking/202504-threat-model-stake-holder.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/audits/staking/202504-threat-model-stake-holder.md b/audits/staking/202504-threat-model-stake-holder.md index aabeabf2..9fdc29b2 100644 --- a/audits/staking/202504-threat-model-stake-holder.md +++ b/audits/staking/202504-threat-model-stake-holder.md @@ -13,14 +13,14 @@ The StakeHolderERC20 contract can be used for any staking system that uses ERC20 ## Threat Model Scope -The threat model is limited to the stake holder Solidity files at GitHash [`67ca971bb214176d8e9481ffb4df33c85f0f6d8a`](https://github.com/immutable/contracts/tree/67ca971bb214176d8e9481ffb4df33c85f0f6d8a/contracts/staking): +The threat model is limited to the stake holder Solidity files at GitHash [`bf327c7abdadd48fd51ae632500510ac2b07b5f0`](https://github.com/immutable/contracts/tree/bf327c7abdadd48fd51ae632500510ac2b07b5f0/contracts/staking): -* [StakeHolder.sol](https://github.com/immutable/contracts/blob/fd982abc49884af41e05f18349b13edc9eefbc1e/contracts/staking/StakeHolder.sol) +* [StakeHolder.sol](https://github.com/immutable/contracts/blob/bf327c7abdadd48fd51ae632500510ac2b07b5f0/contracts/staking/StakeHolder.sol) -* [IStakeHolder.sol](https://github.com/immutable/contracts/blob/67ca971bb214176d8e9481ffb4df33c85f0f6d8a/contracts/staking/IStakeHolder.sol) is the interface that all staking implementations comply with. -* [StakeHolderBase.sol](https://github.com/immutable/contracts/blob/67ca971bb214176d8e9481ffb4df33c85f0f6d8a/contracts/staking/StakeHolderBase.sol) is the abstract base contract that all staking implementation use. -* [StakeHolderERC20.sol](https://github.com/immutable/contracts/blob/67ca971bb214176d8e9481ffb4df33c85f0f6d8a/contracts/staking/StakeHolderERC20.sol) allows an ERC20 token to be used as the staking currency. -* [StakeHolderNative.sol](https://github.com/immutable/contracts/blob/67ca971bb214176d8e9481ffb4df33c85f0f6d8a/contracts/staking/StakeHolderNative.sol) uses the native token, IMX, to be used as the staking currency. +* [IStakeHolder.sol](https://github.com/immutable/contracts/blob/bf327c7abdadd48fd51ae632500510ac2b07b5f0/contracts/staking/IStakeHolder.sol) is the interface that all staking implementations comply with. +* [StakeHolderBase.sol](https://github.com/immutable/contracts/blob/bf327c7abdadd48fd51ae632500510ac2b07b5f0/contracts/staking/StakeHolderBase.sol) is the abstract base contract that all staking implementation use. +* [StakeHolderERC20.sol](https://github.com/immutable/contracts/blob/bf327c7abdadd48fd51ae632500510ac2b07b5f0/contracts/staking/StakeHolderERC20.sol) allows an ERC20 token to be used as the staking currency. +* [StakeHolderNative.sol](https://github.com/immutable/contracts/blob/bf327c7abdadd48fd51ae632500510ac2b07b5f0/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: @@ -29,7 +29,7 @@ Additionally, this threat model analyses whether the documentation for the time ## Background -See the [README](https://github.com/immutable/contracts/tree/67ca971bb214176d8e9481ffb4df33c85f0f6d8a/contracts/staking/README.md) file for information about the usage and design of the stake holder contract system. +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 From 07761ad12644b698b131ccb83b74844088ab9c35 Mon Sep 17 00:00:00 2001 From: Peter Robinson Date: Thu, 24 Apr 2025 09:24:20 +1000 Subject: [PATCH 23/39] Fix typos in threat model --- audits/staking/202504-threat-model-stake-holder.md | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/audits/staking/202504-threat-model-stake-holder.md b/audits/staking/202504-threat-model-stake-holder.md index 9fdc29b2..059d1701 100644 --- a/audits/staking/202504-threat-model-stake-holder.md +++ b/audits/staking/202504-threat-model-stake-holder.md @@ -2,7 +2,7 @@ ## Introduction -This threat model document for the [StakeHolderERC20 and StakeHolderNative](../../contracts/staking/README.md) contracts has been created in preparation for internal audit. +This threat model document for the [StakeHolderERC20 and StakeHolderNative](../../contracts/staking/README.md) contracts has been created in preparation for an internal audit. ## Rationale @@ -15,8 +15,6 @@ The StakeHolderERC20 contract can be used for any staking system that uses ERC20 The threat model is limited to the stake holder Solidity files at GitHash [`bf327c7abdadd48fd51ae632500510ac2b07b5f0`](https://github.com/immutable/contracts/tree/bf327c7abdadd48fd51ae632500510ac2b07b5f0/contracts/staking): -* [StakeHolder.sol](https://github.com/immutable/contracts/blob/bf327c7abdadd48fd51ae632500510ac2b07b5f0/contracts/staking/StakeHolder.sol) - * [IStakeHolder.sol](https://github.com/immutable/contracts/blob/bf327c7abdadd48fd51ae632500510ac2b07b5f0/contracts/staking/IStakeHolder.sol) is the interface that all staking implementations comply with. * [StakeHolderBase.sol](https://github.com/immutable/contracts/blob/bf327c7abdadd48fd51ae632500510ac2b07b5f0/contracts/staking/StakeHolderBase.sol) is the abstract base contract that all staking implementation use. * [StakeHolderERC20.sol](https://github.com/immutable/contracts/blob/bf327c7abdadd48fd51ae632500510ac2b07b5f0/contracts/staking/StakeHolderERC20.sol) allows an ERC20 token to be used as the staking currency. @@ -24,7 +22,7 @@ The threat model is limited to the stake holder Solidity files at GitHash [`bf32 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 upgrade or other admin changes are proposed and when they are executed. +* [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 @@ -37,7 +35,7 @@ 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. +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 @@ -156,7 +154,7 @@ forge inspect StakeHolderERC20 storage | \_\_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 | +| \_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 | @@ -192,7 +190,7 @@ forge inspect StakeHolderNative storage | stakers | address[] | 352 | 0 | 32 | StakeHolderBase.sol | | version | uint256 | 353 | 0 | 32 | StakeHolderBase.sol | | \_\_StakeHolderBaseGap | uint256[50] | 354 | 0 | 1600 | StakeHolderBase.sol | -| \_\_StakeHolderERC20Gap | uint256[50] | 404 | 0 | 1600 | StakeHolderNative.sol | +| \_\_StakeHolderNativeGap | uint256[50] | 404 | 0 | 1600 | StakeHolderNative.sol | ### Timelock Controller Bypass From ce919bccc24c89d539b56ac3f6a7f73e47c1e7c7 Mon Sep 17 00:00:00 2001 From: Peter Robinson Date: Thu, 24 Apr 2025 09:27:33 +1000 Subject: [PATCH 24/39] Fix spelling and grammar issues --- audits/staking/202504-threat-model-stake-holder.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/audits/staking/202504-threat-model-stake-holder.md b/audits/staking/202504-threat-model-stake-holder.md index 059d1701..d5309ba6 100644 --- a/audits/staking/202504-threat-model-stake-holder.md +++ b/audits/staking/202504-threat-model-stake-holder.md @@ -105,7 +105,7 @@ Accounts with administrative privileges could be used by attackers to facilitate #### Accounts with `DEFAULT_ADMIN_ROLE` on StakeHolderERC20 and StakeHolderNative contracts -The `DEFAULT_ADMIN_ROLE` is the role is granted to the `roleAdmin` specified in the `initialize` function of the `StakeHolderERC20` and `StakeHolderNative` contracts. Accounts with the `DEFAULT_ADMIN_ROLE` can: +The `DEFAULT_ADMIN_ROLE` is the role that is granted to the `roleAdmin` specified in the `initialize` function of the `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`. @@ -195,7 +195,7 @@ forge inspect StakeHolderNative 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 addtional accounts with `UPGRADE_ROLE`. +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: From 858d647e1a79d184b53548b73b164b23e1d94c0e Mon Sep 17 00:00:00 2001 From: Peter Robinson Date: Thu, 24 Apr 2025 13:36:45 +1000 Subject: [PATCH 25/39] Fix typos --- contracts/staking/README.md | 5 +++-- test/staking/README.md | 6 +++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/contracts/staking/README.md b/contracts/staking/README.md index 707a99f3..3e88b598 100644 --- a/contracts/staking/README.md +++ b/contracts/staking/README.md @@ -2,7 +2,7 @@ The Immutable zkEVM staking system allows any account (EOA or contract) to stake any amount of a token at any time. An account can remove all or some of their stake at any time. The contract has the facility to distribute rewards to stakers. -The staking contracts are upgradeable and operate via a proxy contract. They use the [Universal Upgradeable Proxy Standard (UUPS)](https://eips.ethereum.org/EIPS/eip-1822) upgrade pattern, whether the authorisation and access control for upgrade resides within the application contract (the staking contract). +The staking contracts are upgradeable and operate via a proxy contract. They use the [Universal Upgradeable Proxy Standard (UUPS)](https://eips.ethereum.org/EIPS/eip-1822) upgrade pattern, where the access control for upgrade resides within the application contract (the staking contract). The system consists of a set of contracts show in the diagram below. @@ -18,7 +18,7 @@ The system consists of a set of contracts show in the diagram below. `ERC1967Proxy.sol` is a proxy contract. All calls to StakeHolder contracts go via the ERC1967Proxy contract. -`TimelockController.sol` can be used with the staking contracts to provide a one week delay between upgrade or other admin changes are proposed and when they are executed. See below for information on how to configure the time lock controller. +`TimelockController.sol` can be used with the staking contracts to provide a one week delay between when upgrade or other admin changes are proposed and when they are executed. See below for information on how to configure the time lock controller. `OwnableCreate3Deployer.sol` ensures contracts are deployed to the same addresses across chains. The use of this contract is optional. See [deployment scripts](../../script/staking/README.md) for more information. @@ -38,6 +38,7 @@ Contract threat models and audits: | Description | Date |Version Audited | Link to Report | |---------------------------|------------------|-----------------|----------------| | Threat model | Oct 21, 2024 | [`fd982abc49884af41e05f18349b13edc9eefbc1e`](https://github.com/immutable/contracts/blob/fd982abc49884af41e05f18349b13edc9eefbc1e/contracts/staking/README.md) | [202410-threat-model-stake-holder.md](../../audits/staking/202410-threat-model-stake-holder.md) | +| Threat model | April 24, 2025 | [`bf327c7abdadd48fd51ae632500510ac2b07b5f0`](https://github.com/immutable/contracts/blob/bf327c7abdadd48fd51ae632500510ac2b07b5f0/contracts/staking/README.md) | [202504-threat-model-stake-holder.md](../../audits/staking/202504-threat-model-stake-holder.md) | diff --git a/test/staking/README.md b/test/staking/README.md index d8c1a499..57db53f1 100644 --- a/test/staking/README.md +++ b/test/staking/README.md @@ -4,7 +4,7 @@ [StakeHolderNative.sol](../../contracts/staking/StakeHolderNative.sol) and [StakeHolderERC20.sol](../../contracts/staking/StakeHolderERC20.sol) use common base tests. -Initialize testing (in [StakeHolderInitBase.t.sol](../../contracts/staking/StakeHolderInitBase.t.sol)): +Initialize testing (in [StakeHolderInitBase.t.sol](./StakeHolderInitBase.t.sol)): | Test name | Description | Happy Case | Implemented | |---------------------------------|------------------------------------------------------------|------------|-------------| @@ -13,7 +13,7 @@ Initialize testing (in [StakeHolderInitBase.t.sol](../../contracts/staking/Stake | testAdmins | Check that role and upgrade admin have been set correctly. | Yes | Yes | -Configuration tests (in [StakeHolderConfigBase.t.sol](../../contracts/staking/StakeHolderConfigBase.t.sol)):: +Configuration tests (in [StakeHolderConfigBase.t.sol](./StakeHolderConfigBase.t.sol)): | Test name | Description | Happy Case | Implemented | |---------------------------------|------------------------------------------------------------|------------|-------------| @@ -26,7 +26,7 @@ Configuration tests (in [StakeHolderConfigBase.t.sol](../../contracts/staking/St | testRoleAdminAuthFail | Attempt to add an upgrade admin from a non-role admin. | No | Yes | -Operational tests (in [StakeHolderOperationalBase.t.sol](../../contracts/staking/StakeHolderOperationalBase.t.sol)):: +Operational tests (in [StakeHolderOperationalBase.t.sol](./StakeHolderOperationalBase.t.sol)): | Test name | Description | Happy Case | Implemented | |--------------------------------|-------------------------------------------------------------|------------|-------------| From 6736de17d4b77452c3889071a20699f37153570a Mon Sep 17 00:00:00 2001 From: Peter Robinson Date: Tue, 29 Apr 2025 07:35:13 +1000 Subject: [PATCH 26/39] All tests working --- contracts/staking/IWIMX.sol | 36 ++++++ contracts/staking/StakeHolderBase.sol | 2 +- contracts/staking/StakeHolderERC20.sol | 13 ++- contracts/staking/StakeHolderWIMX.sol | 103 ++++++++++++++++++ contracts/staking/WIMX.sol | 99 +++++++++++++++++ test/staking/StakeHolderConfigWIMX.t.sol | 42 +++++++ test/staking/StakeHolderInitWIMX.t.sol | 28 +++++ test/staking/StakeHolderOperationalBase.t.sol | 13 ++- .../staking/StakeHolderOperationalERC20.t.sol | 6 +- .../StakeHolderOperationalNative.t.sol | 5 +- test/staking/StakeHolderOperationalWIMX.t.sol | 99 +++++++++++++++++ 11 files changed, 435 insertions(+), 11 deletions(-) create mode 100644 contracts/staking/IWIMX.sol create mode 100644 contracts/staking/StakeHolderWIMX.sol create mode 100644 contracts/staking/WIMX.sol create mode 100644 test/staking/StakeHolderConfigWIMX.t.sol create mode 100644 test/staking/StakeHolderInitWIMX.t.sol create mode 100644 test/staking/StakeHolderOperationalWIMX.t.sol diff --git a/contracts/staking/IWIMX.sol b/contracts/staking/IWIMX.sol new file mode 100644 index 00000000..83b4615e --- /dev/null +++ b/contracts/staking/IWIMX.sol @@ -0,0 +1,36 @@ +// Copyright Immutable Pty Ltd 2018 - 2023 +// SPDX-License-Identifier: Apache 2.0 +pragma solidity >=0.8.19 <0.8.29; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +/* +* @notice Interface for the Wrapped IMX (wIMX) contract. +* @dev Based on the interface for the standard [Wrapped ETH contract](../root/IWETH.sol) +*/ +interface IWIMX is IERC20 { + /** + * @notice Emitted when native ETH is deposited to the contract, and a corresponding amount of wETH are minted + * @param account The address of the account that deposited the tokens. + * @param value The amount of tokens that were deposited. + */ + event Deposit(address indexed account, uint256 value); + + /** + * @notice Emitted when wETH is withdrawn from the contract, and a corresponding amount of wETH are burnt. + * @param account The address of the account that withdrew the tokens. + * @param value The amount of tokens that were withdrawn. + */ + event Withdrawal(address indexed account, uint256 value); + + /** + * @notice Deposit native ETH to the contract and mint an equal amount of wrapped ETH to msg.sender. + */ + function deposit() external payable; + + /** + * @notice Withdraw given amount of native ETH to msg.sender after burning an equal amount of wrapped ETH. + * @param value The amount to withdraw. + */ + function withdraw(uint256 value) external; +} diff --git a/contracts/staking/StakeHolderBase.sol b/contracts/staking/StakeHolderBase.sol index 72116900..8eaf16cd 100644 --- a/contracts/staking/StakeHolderBase.sol +++ b/contracts/staking/StakeHolderBase.sol @@ -57,7 +57,7 @@ abstract contract StakeHolderBase is * @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 { + function __StakeHolderBase_init(address _roleAdmin, address _upgradeAdmin, address _distributeAdmin) internal onlyInitializing { __UUPSUpgradeable_init(); __AccessControl_init(); __ReentrancyGuard_init(); diff --git a/contracts/staking/StakeHolderERC20.sol b/contracts/staking/StakeHolderERC20.sol index cca01d1a..165923da 100644 --- a/contracts/staking/StakeHolderERC20.sol +++ b/contracts/staking/StakeHolderERC20.sol @@ -20,7 +20,8 @@ contract StakeHolderERC20 is StakeHolderBase { * @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 _distributeAdmin the address to grant `DISTRIBUTE_ROLE` to. + * @param _token the token to use for staking. */ function initialize( address _roleAdmin, @@ -28,10 +29,20 @@ contract StakeHolderERC20 is StakeHolderBase { 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 */ diff --git a/contracts/staking/StakeHolderWIMX.sol b/contracts/staking/StakeHolderWIMX.sol new file mode 100644 index 00000000..97a5632d --- /dev/null +++ b/contracts/staking/StakeHolderWIMX.sol @@ -0,0 +1,103 @@ +// Copyright (c) Immutable Pty Ltd 2018 - 2025 +// SPDX-License-Identifier: Apache 2 +pragma solidity >=0.8.19 <0.8.29; + +import {IERC20Upgradeable} from "openzeppelin-contracts-upgradeable-4.9.3/token/ERC20/IERC20Upgradeable.sol"; +import {SafeERC20Upgradeable} from "openzeppelin-contracts-upgradeable-4.9.3/token/ERC20/utils/SafeERC20Upgradeable.sol"; +import {IStakeHolder, StakeHolderBase} from "./StakeHolderBase.sol"; +import {IWIMX} from "./IWIMX.sol"; + +/** + * @title StakeHolderWIMX: allows anyone to stake any amount of IMX and to then remove all or part of that stake. + * @dev Stake can be added and withdrawn either as native IMX only. + * The StakeHolderWIMX contract is designed to be upgradeable. + */ +contract StakeHolderWIMX is StakeHolderBase { + using SafeERC20Upgradeable for IERC20Upgradeable; + + /// @notice Error: Unstake transfer failed. + error UnstakeTransferFailed(); + + /// @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 { + __StakeHolderWIMX_init(_roleAdmin, _upgradeAdmin, _distributeAdmin, _wIMXToken); + } + + function __StakeHolderWIMX_init( + address _roleAdmin, + address _upgradeAdmin, + address _distributeAdmin, + address _wIMXToken + ) internal onlyInitializing { + __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 returns (address) { + return address(wIMX); + } + + /** + * @inheritdoc StakeHolderBase + */ + function _sendValue(address _to, uint256 _amount) internal override { + // Convert WIMX to native IMX + wIMX.withdraw(_amount); + + // 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 override { + // Check that the amount matches the msg.value. + if (_amount != msg.value) { + revert MismatchMsgValueAmount(msg.value, _amount); + } + + // Convert native IMX to WIMX. + 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 __StakeHolderERC20AndNativeGap; + // slither-disable-end unused-state +} diff --git a/contracts/staking/WIMX.sol b/contracts/staking/WIMX.sol new file mode 100644 index 00000000..08148686 --- /dev/null +++ b/contracts/staking/WIMX.sol @@ -0,0 +1,99 @@ +// Copyright Immutable Pty Ltd 2018 - 2023 +// SPDX-License-Identifier: GPL-3.0 +pragma solidity >=0.8.19 <0.8.29; + +import {IWIMX} from "./IWIMX.sol"; +import {Address} from "@openzeppelin/contracts/utils/Address.sol"; + +/** + * @notice WIMX is a wrapped IMX contract that allows users to wrap their native IMX. + * @dev This contract is adapted from the official Wrapped ETH contract. + */ +contract WIMX is IWIMX { + string public name = "Wrapped IMX"; + string public symbol = "WIMX"; + uint8 public decimals = 18; + + mapping(address => uint256) public balanceOf; + mapping(address => mapping(address => uint256)) public allowance; + + /** + * @notice Fallback function on receiving native IMX. + */ + receive() external payable { + deposit(); + } + + /** + * @notice Deposit native IMX in the function call and mint the equal amount of wrapped IMX to msg.sender. + */ + function deposit() public payable { + balanceOf[msg.sender] += msg.value; + emit Deposit(msg.sender, msg.value); + } + + /** + * @notice Withdraw given amount of native IMX to msg.sender and burn the equal amount of wrapped IMX. + * @param wad The amount to withdraw. + */ + function withdraw(uint256 wad) public { + require(balanceOf[msg.sender] >= wad, "Wrapped IMX: Insufficient balance"); + balanceOf[msg.sender] -= wad; + + Address.sendValue(payable(msg.sender), wad); + emit Withdrawal(msg.sender, wad); + } + + /** + * @notice Obtain the current total supply of wrapped IMX. + * @return uint The amount of supplied wrapped IMX. + */ + function totalSupply() public view returns (uint256) { + return address(this).balance; + } + + /** + * @notice Approve given spender the ability to spend a given amount of msg.sender's tokens. + * @param guy Approved spender. + * @param wad Amount of allowance. + * @return bool Returns true if function call is successful. + */ + function approve(address guy, uint256 wad) public returns (bool) { + allowance[msg.sender][guy] = wad; + emit Approval(msg.sender, guy, wad); + return true; + } + + /** + * @notice Transfer given amount of tokens from msg.sender to given destination. + * @param dst Destination of this transfer. + * @param wad Amount of this transfer. + * @return bool Returns true if function call is successful. + */ + function transfer(address dst, uint256 wad) public returns (bool) { + return transferFrom(msg.sender, dst, wad); + } + + /** + * @notice Transfer given amount of tokens from given source to given destination. + * @param src Source of this transfer. + * @param dst Destination of this transfer. + * @param wad Amount of this transfer. + * @return bool Returns true if function call is successful. + */ + function transferFrom(address src, address dst, uint256 wad) public returns (bool) { + require(balanceOf[src] >= wad, "Wrapped IMX: Insufficient balance"); + + if (src != msg.sender && allowance[src][msg.sender] != type(uint256).max) { + require(allowance[src][msg.sender] >= wad, "Wrapped IMX: Insufficient allowance"); + allowance[src][msg.sender] -= wad; + } + + balanceOf[src] -= wad; + balanceOf[dst] += wad; + + emit Transfer(src, dst, wad); + + return true; + } +} diff --git a/test/staking/StakeHolderConfigWIMX.t.sol b/test/staking/StakeHolderConfigWIMX.t.sol new file mode 100644 index 00000000..fd2e457e --- /dev/null +++ b/test/staking/StakeHolderConfigWIMX.t.sol @@ -0,0 +1,42 @@ +// Copyright Immutable Pty Ltd 2018 - 2024 +// SPDX-License-Identifier: Apache 2.0 +pragma solidity >=0.8.19 <0.8.29; + +// solhint-disable-next-line no-global-import +import "forge-std/Test.sol"; +import {StakeHolderWIMX} from "../../contracts/staking/StakeHolderWIMX.sol"; +import {WIMX} from "../../contracts/staking/WIMX.sol"; +import {IStakeHolder} from "../../contracts/staking/IStakeHolder.sol"; +import {StakeHolderBase} from "../../contracts/staking/StakeHolderBase.sol"; +import {StakeHolderConfigBaseTest} from "./StakeHolderConfigBase.t.sol"; +import {ERC1967Proxy} from "openzeppelin-contracts-4.9.3/proxy/ERC1967/ERC1967Proxy.sol"; + +contract StakeHolderWIMXV2 is StakeHolderWIMX { + function upgradeStorage(bytes memory /* _data */) external override(StakeHolderBase) { + version = 1; + } +} + +contract StakeHolderConfigWIMXTest is StakeHolderConfigBaseTest { + + function setUp() public override { + super.setUp(); + + StakeHolderWIMX impl = new StakeHolderWIMX(); + + bytes memory initData = abi.encodeWithSelector( + StakeHolderWIMX.initialize.selector, roleAdmin, upgradeAdmin, distributeAdmin, address(0) + ); + + ERC1967Proxy proxy = new ERC1967Proxy(address(impl), initData); + stakeHolder = IStakeHolder(address(proxy)); + } + + function _deployV1() internal override returns(IStakeHolder) { + return IStakeHolder(address(new StakeHolderWIMX())); + } + + function _deployV2() internal override returns(IStakeHolder) { + return IStakeHolder(address(new StakeHolderWIMXV2())); + } +} \ No newline at end of file diff --git a/test/staking/StakeHolderInitWIMX.t.sol b/test/staking/StakeHolderInitWIMX.t.sol new file mode 100644 index 00000000..914418b2 --- /dev/null +++ b/test/staking/StakeHolderInitWIMX.t.sol @@ -0,0 +1,28 @@ +// Copyright Immutable Pty Ltd 2018 - 2024 +// SPDX-License-Identifier: Apache 2.0 +pragma solidity >=0.8.19 <0.8.29; + +// solhint-disable-next-line no-global-import +import "forge-std/Test.sol"; +import {StakeHolderWIMX} from "../../contracts/staking/StakeHolderWIMX.sol"; +import {WIMX} from "../../contracts/staking/WIMX.sol"; +import {IStakeHolder} from "../../contracts/staking/IStakeHolder.sol"; +import {StakeHolderBase} from "../../contracts/staking/StakeHolderBase.sol"; +import {StakeHolderInitBaseTest} from "./StakeHolderInitBase.t.sol"; +import {ERC1967Proxy} from "openzeppelin-contracts-4.9.3/proxy/ERC1967/ERC1967Proxy.sol"; + +contract StakeHolderInitWIMXTest is StakeHolderInitBaseTest { + + function setUp() public override { + super.setUp(); + + StakeHolderWIMX impl = new StakeHolderWIMX(); + + bytes memory initData = abi.encodeWithSelector( + StakeHolderWIMX.initialize.selector, roleAdmin, upgradeAdmin, distributeAdmin, address(0) + ); + + ERC1967Proxy proxy = new ERC1967Proxy(address(impl), initData); + stakeHolder = IStakeHolder(address(proxy)); + } +} diff --git a/test/staking/StakeHolderOperationalBase.t.sol b/test/staking/StakeHolderOperationalBase.t.sol index d5cbb464..931194c7 100644 --- a/test/staking/StakeHolderOperationalBase.t.sol +++ b/test/staking/StakeHolderOperationalBase.t.sol @@ -11,8 +11,8 @@ abstract contract StakeHolderOperationalBaseTest is StakeHolderBaseTest { function testStake() public { _deal(staker1, 100 ether); _addStake(staker1, 10 ether); - assertEq(_getBalance(staker1), 90 ether, "Incorrect balance1"); - assertEq(_getBalance(address(stakeHolder)), 10 ether, "Incorrect balance2"); + assertEq(_getBalanceStaker(staker1), 90 ether, "Incorrect balance1"); + assertEq(_getBalanceStakeHolderContract(), 10 ether, "Incorrect balance2"); assertEq(stakeHolder.getBalance(staker1), 10 ether, "Incorrect balance3"); assertTrue(stakeHolder.hasStaked(staker1), "Expect staker1 has staked"); @@ -68,7 +68,7 @@ abstract contract StakeHolderOperationalBaseTest is StakeHolderBaseTest { vm.prank(staker1); stakeHolder.unstake(10 ether); - assertEq(_getBalance(staker1), 100 ether, "Incorrect native balance"); + assertEq(_getBalanceStaker(staker1), 100 ether, "Incorrect native balance"); assertEq(stakeHolder.getBalance(staker1), 0 ether, "Incorrect balance"); assertTrue(stakeHolder.hasStaked(staker1), "Expect staker1 has staked"); assertEq(stakeHolder.getNumStakers(), 1, "Incorrect number of stakers"); @@ -93,7 +93,7 @@ abstract contract StakeHolderOperationalBaseTest is StakeHolderBaseTest { vm.prank(staker1); stakeHolder.unstake(3 ether); - assertEq(_getBalance(staker1), 93 ether, "Incorrect native balance"); + assertEq(_getBalanceStaker(staker1), 93 ether, "Incorrect native balance"); assertEq(stakeHolder.getBalance(staker1), 7 ether, "Incorrect balance"); } @@ -106,7 +106,7 @@ abstract contract StakeHolderOperationalBaseTest is StakeHolderBaseTest { vm.prank(staker1); stakeHolder.unstake(1 ether); - assertEq(_getBalance(staker1), 94 ether, "Incorrect native balance"); + assertEq(_getBalanceStaker(staker1), 94 ether, "Incorrect native balance"); assertEq(stakeHolder.getBalance(staker1), 6 ether, "Incorrect balance"); } @@ -264,7 +264,8 @@ abstract contract StakeHolderOperationalBaseTest is StakeHolderBaseTest { function _deal(address _to, uint256 _amount) internal virtual; - function _getBalance(address _staker) internal view virtual returns (uint256); + function _getBalanceStaker(address _staker) internal virtual view returns (uint256); + function _getBalanceStakeHolderContract() internal virtual view returns (uint256); function _addStake(address _staker, uint256 _amount) internal { _addStake(_staker, _amount, false, bytes("")); diff --git a/test/staking/StakeHolderOperationalERC20.t.sol b/test/staking/StakeHolderOperationalERC20.t.sol index ce14f16c..a91f9ebb 100644 --- a/test/staking/StakeHolderOperationalERC20.t.sol +++ b/test/staking/StakeHolderOperationalERC20.t.sol @@ -66,8 +66,10 @@ contract StakeHolderOperationalERC20Test is StakeHolderOperationalBaseTest { vm.prank(_distributor); stakeHolder.distributeRewards(_accountAmounts); } - function _getBalance(address _staker) internal view override returns (uint256) { + function _getBalanceStaker(address _staker) internal view override returns (uint256) { return erc20.balanceOf(_staker); } - + function _getBalanceStakeHolderContract() internal view override returns (uint256) { + return erc20.balanceOf(address(stakeHolder)); + } } diff --git a/test/staking/StakeHolderOperationalNative.t.sol b/test/staking/StakeHolderOperationalNative.t.sol index b45f178d..0d599f54 100644 --- a/test/staking/StakeHolderOperationalNative.t.sol +++ b/test/staking/StakeHolderOperationalNative.t.sol @@ -80,8 +80,11 @@ contract StakeHolderOperationalNativeTest is StakeHolderOperationalBaseTest { vm.prank(_distributor); stakeHolder.distributeRewards{value: _total}(_accountAmounts); } - function _getBalance(address _staker) internal view override returns (uint256) { + function _getBalanceStaker(address _staker) internal view override returns (uint256) { return _staker.balance; } + function _getBalanceStakeHolderContract() internal view override returns (uint256) { + return address(stakeHolder).balance; + } } diff --git a/test/staking/StakeHolderOperationalWIMX.t.sol b/test/staking/StakeHolderOperationalWIMX.t.sol new file mode 100644 index 00000000..df8f5e10 --- /dev/null +++ b/test/staking/StakeHolderOperationalWIMX.t.sol @@ -0,0 +1,99 @@ +// Copyright Immutable Pty Ltd 2018 - 2024 +// SPDX-License-Identifier: Apache 2.0 +pragma solidity >=0.8.19 <0.8.29; + +// solhint-disable-next-line no-global-import +import "forge-std/Test.sol"; +import {StakeHolderWIMX} from "../../contracts/staking/StakeHolderWIMX.sol"; +import {IStakeHolder} from "../../contracts/staking/IStakeHolder.sol"; +import {WIMX} from "../../contracts/staking/WIMX.sol"; +import {StakeHolderOperationalBaseTest} from "./StakeHolderOperationalBase.t.sol"; +import {ERC1967Proxy} from "openzeppelin-contracts-4.9.3/proxy/ERC1967/ERC1967Proxy.sol"; +import {ERC20PresetFixedSupply} from "openzeppelin-contracts-4.9.3/token/ERC20/presets/ERC20PresetFixedSupply.sol"; + +contract StakeHolderOperationalWIMXTest is StakeHolderOperationalBaseTest { + WIMX erc20; + + + function setUp() public override { + super.setUp(); + + erc20 = new WIMX(); + + StakeHolderWIMX impl = new StakeHolderWIMX(); + + bytes memory initData = abi.encodeWithSelector( + StakeHolderWIMX.initialize.selector, roleAdmin, upgradeAdmin, distributeAdmin, address(erc20) + ); + + ERC1967Proxy proxy = new ERC1967Proxy(address(impl), initData); + stakeHolder = IStakeHolder(address(proxy)); + } + + +//TODO + + // function testUnstakeReentrantAttack() public { + // StakeHolderAttackWallet attacker = new StakeHolderAttackWallet(address(stakeHolder)); + // _deal(address(attacker), 100 ether); + + // attacker.stake(10 ether); + // // Attacker's reentracy attack will double the amount being unstaked. + // // The attack fails due to attempting to withdraw more than balance (that is, 2 x 6 eth = 12) + // vm.expectRevert(abi.encodePacked("ReentrancyGuard: reentrant call")); + // attacker.unstake{gas: 10000000}(6 ether); + // } + + function testDistributeMismatch() public { + _deal(staker1, 100 ether); + _deal(staker2, 100 ether); + _deal(staker3, 100 ether); + _deal(distributeAdmin, 100 ether); + + _addStake(staker1, 10 ether); + _addStake(staker2, 20 ether); + _addStake(staker3, 30 ether); + + // Distribute rewards to staker2 and staker3. + IStakeHolder.AccountAmount[] memory accountsAmounts = new IStakeHolder.AccountAmount[](2); + accountsAmounts[0] = IStakeHolder.AccountAmount(staker2, 0.5 ether); + accountsAmounts[1] = IStakeHolder.AccountAmount(staker3, 1 ether); + _distributeRewards(distributeAdmin, 1 ether, accountsAmounts, + abi.encodeWithSelector(IStakeHolder.MismatchMsgValueAmount.selector, 1 ether, 1.5 ether)); + } + + + function testAddStakeMismatch() public { + uint256 amount = 100 ether; + _deal(staker1, amount); + vm.prank(staker1); + vm.expectRevert(abi.encodeWithSelector(IStakeHolder.MismatchMsgValueAmount.selector, amount, amount+1)); + stakeHolder.stake{value: amount}(amount + 1); + } + + function _deal(address _to, uint256 _amount) internal override { + vm.deal(_to, _amount); + } + + function _addStake(address _staker, uint256 _amount, bool _hasError, bytes memory _error) internal override { + if (_hasError) { + vm.expectRevert(_error); + } + vm.prank(_staker); + stakeHolder.stake{value: _amount}(_amount); + } + function _distributeRewards(address _distributor, uint256 _total, IStakeHolder.AccountAmount[] memory _accountAmounts, + bool _hasError, bytes memory _error) internal override { + if (_hasError) { + vm.expectRevert(_error); + } + vm.prank(_distributor); + stakeHolder.distributeRewards{value: _total}(_accountAmounts); + } + function _getBalanceStaker(address _staker) internal view override returns (uint256) { + return _staker.balance; + } + function _getBalanceStakeHolderContract() internal view override returns (uint256) { + return erc20.balanceOf(address(stakeHolder)); + } +} From 5dd2a3fbb40026d5deabcf9170fdf557a8b8cc51 Mon Sep 17 00:00:00 2001 From: Peter Robinson Date: Tue, 29 Apr 2025 07:36:16 +1000 Subject: [PATCH 27/39] Fix prettier issues --- contracts/staking/IWIMX.sol | 6 +++--- contracts/staking/StakeHolderBase.sol | 6 +++++- contracts/staking/StakeHolderERC20.sol | 1 - 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/contracts/staking/IWIMX.sol b/contracts/staking/IWIMX.sol index 83b4615e..37c96117 100644 --- a/contracts/staking/IWIMX.sol +++ b/contracts/staking/IWIMX.sol @@ -5,9 +5,9 @@ pragma solidity >=0.8.19 <0.8.29; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; /* -* @notice Interface for the Wrapped IMX (wIMX) contract. -* @dev Based on the interface for the standard [Wrapped ETH contract](../root/IWETH.sol) -*/ + * @notice Interface for the Wrapped IMX (wIMX) contract. + * @dev Based on the interface for the standard [Wrapped ETH contract](../root/IWETH.sol) + */ interface IWIMX is IERC20 { /** * @notice Emitted when native ETH is deposited to the contract, and a corresponding amount of wETH are minted diff --git a/contracts/staking/StakeHolderBase.sol b/contracts/staking/StakeHolderBase.sol index 8eaf16cd..219bd063 100644 --- a/contracts/staking/StakeHolderBase.sol +++ b/contracts/staking/StakeHolderBase.sol @@ -57,7 +57,11 @@ abstract contract StakeHolderBase is * @param _upgradeAdmin the address to grant `UPGRADE_ROLE` to * @param _distributeAdmin the address to grant `DISTRIBUTE_ROLE` to */ - function __StakeHolderBase_init(address _roleAdmin, address _upgradeAdmin, address _distributeAdmin) internal onlyInitializing { + function __StakeHolderBase_init( + address _roleAdmin, + address _upgradeAdmin, + address _distributeAdmin + ) internal onlyInitializing { __UUPSUpgradeable_init(); __AccessControl_init(); __ReentrancyGuard_init(); diff --git a/contracts/staking/StakeHolderERC20.sol b/contracts/staking/StakeHolderERC20.sol index 165923da..7329df0e 100644 --- a/contracts/staking/StakeHolderERC20.sol +++ b/contracts/staking/StakeHolderERC20.sol @@ -42,7 +42,6 @@ contract StakeHolderERC20 is StakeHolderBase { token = IERC20Upgradeable(_token); } - /** * @inheritdoc IStakeHolder */ From bd0d0fbd04b4baad7a63dd698a947f11d6778a3d Mon Sep 17 00:00:00 2001 From: Peter Robinson Date: Tue, 29 Apr 2025 10:18:21 +1000 Subject: [PATCH 28/39] Minor updates --- contracts/staking/StakeHolderNative.sol | 6 ++-- contracts/staking/StakeHolderWIMX.sol | 43 ++++--------------------- 2 files changed, 9 insertions(+), 40 deletions(-) diff --git a/contracts/staking/StakeHolderNative.sol b/contracts/staking/StakeHolderNative.sol index 930b6ff5..7680faeb 100644 --- a/contracts/staking/StakeHolderNative.sol +++ b/contracts/staking/StakeHolderNative.sol @@ -25,14 +25,14 @@ contract StakeHolderNative is StakeHolderBase { /** * @inheritdoc IStakeHolder */ - function getToken() external pure returns (address) { + function getToken() external virtual view returns (address) { return address(0); } /** * @inheritdoc StakeHolderBase */ - function _sendValue(address _to, uint256 _amount) internal override { + 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) { @@ -53,7 +53,7 @@ contract StakeHolderNative is StakeHolderBase { /** * @inheritdoc StakeHolderBase */ - function _checksAndTransfer(uint256 _amount) internal override { + function _checksAndTransfer(uint256 _amount) internal virtual override { // Check that the amount matches the msg.value. if (_amount != msg.value) { revert MismatchMsgValueAmount(msg.value, _amount); diff --git a/contracts/staking/StakeHolderWIMX.sol b/contracts/staking/StakeHolderWIMX.sol index 97a5632d..f390be72 100644 --- a/contracts/staking/StakeHolderWIMX.sol +++ b/contracts/staking/StakeHolderWIMX.sol @@ -2,9 +2,8 @@ // SPDX-License-Identifier: Apache 2 pragma solidity >=0.8.19 <0.8.29; -import {IERC20Upgradeable} from "openzeppelin-contracts-upgradeable-4.9.3/token/ERC20/IERC20Upgradeable.sol"; -import {SafeERC20Upgradeable} from "openzeppelin-contracts-upgradeable-4.9.3/token/ERC20/utils/SafeERC20Upgradeable.sol"; import {IStakeHolder, StakeHolderBase} from "./StakeHolderBase.sol"; +import {StakeHolderNative} from "./StakeHolderNative.sol"; import {IWIMX} from "./IWIMX.sol"; /** @@ -12,11 +11,7 @@ import {IWIMX} from "./IWIMX.sol"; * @dev Stake can be added and withdrawn either as native IMX only. * The StakeHolderWIMX contract is designed to be upgradeable. */ -contract StakeHolderWIMX is StakeHolderBase { - using SafeERC20Upgradeable for IERC20Upgradeable; - - /// @notice Error: Unstake transfer failed. - error UnstakeTransferFailed(); +contract StakeHolderWIMX is StakeHolderNative { /// @notice The token used for staking. IWIMX internal wIMX; @@ -34,15 +29,6 @@ contract StakeHolderWIMX is StakeHolderBase { address _distributeAdmin, address _wIMXToken ) public initializer { - __StakeHolderWIMX_init(_roleAdmin, _upgradeAdmin, _distributeAdmin, _wIMXToken); - } - - function __StakeHolderWIMX_init( - address _roleAdmin, - address _upgradeAdmin, - address _distributeAdmin, - address _wIMXToken - ) internal onlyInitializing { __StakeHolderBase_init(_roleAdmin, _upgradeAdmin, _distributeAdmin); wIMX = IWIMX(_wIMXToken); } @@ -54,7 +40,7 @@ contract StakeHolderWIMX is StakeHolderBase { /** * @inheritdoc IStakeHolder */ - function getToken() external view returns (address) { + function getToken() external override view returns (address) { return address(wIMX); } @@ -65,31 +51,14 @@ contract StakeHolderWIMX is StakeHolderBase { // Convert WIMX to native IMX wIMX.withdraw(_amount); - // 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(); - } - } + super._sendValue(_to, _amount); } /** * @inheritdoc StakeHolderBase */ function _checksAndTransfer(uint256 _amount) internal override { - // Check that the amount matches the msg.value. - if (_amount != msg.value) { - revert MismatchMsgValueAmount(msg.value, _amount); - } + super._checksAndTransfer(_amount); // Convert native IMX to WIMX. wIMX.deposit{value: _amount}(); @@ -98,6 +67,6 @@ contract StakeHolderWIMX is StakeHolderBase { /// @notice storage gap for additional variables for upgrades // slither-disable-start unused-state // solhint-disable-next-line var-name-mixedcase - uint256[50] private __StakeHolderERC20AndNativeGap; + uint256[50] private __StakeHolderWIMXGap; // slither-disable-end unused-state } From df6b73350b1667706df36c40a7189a9739d1b80d Mon Sep 17 00:00:00 2001 From: Peter Robinson Date: Tue, 29 Apr 2025 11:17:24 +1000 Subject: [PATCH 29/39] Updated deployment script --- contracts/staking/IWIMX.sol | 2 +- contracts/staking/WIMX.sol | 1 + ...ipt.t.sol => StakeHolderScriptERC20.t.sol} | 4 +- script/staking/StakeHolderScriptWIMX.t.sol | 387 ++++++++++++++++++ script/staking/common.sh | 23 +- 5 files changed, 410 insertions(+), 7 deletions(-) rename script/staking/{StakeHolderScript.t.sol => StakeHolderScriptERC20.t.sol} (99%) create mode 100644 script/staking/StakeHolderScriptWIMX.t.sol diff --git a/contracts/staking/IWIMX.sol b/contracts/staking/IWIMX.sol index 37c96117..bf973abb 100644 --- a/contracts/staking/IWIMX.sol +++ b/contracts/staking/IWIMX.sol @@ -6,7 +6,7 @@ import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; /* * @notice Interface for the Wrapped IMX (wIMX) contract. - * @dev Based on the interface for the standard [Wrapped ETH contract](../root/IWETH.sol) + * @dev Based on the interface for the [Wrapped ETH contract](https://etherscan.io/token/0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2#code) */ interface IWIMX is IERC20 { /** diff --git a/contracts/staking/WIMX.sol b/contracts/staking/WIMX.sol index 08148686..596d285a 100644 --- a/contracts/staking/WIMX.sol +++ b/contracts/staking/WIMX.sol @@ -8,6 +8,7 @@ import {Address} from "@openzeppelin/contracts/utils/Address.sol"; /** * @notice WIMX is a wrapped IMX contract that allows users to wrap their native IMX. * @dev This contract is adapted from the official Wrapped ETH contract. + * This contract is copied from https://github.com/immutable/zkevm-bridge-contracts/blob/main/src/child/WIMX.sol */ contract WIMX is IWIMX { string public name = "Wrapped IMX"; diff --git a/script/staking/StakeHolderScript.t.sol b/script/staking/StakeHolderScriptERC20.t.sol similarity index 99% rename from script/staking/StakeHolderScript.t.sol rename to script/staking/StakeHolderScriptERC20.t.sol index eb12639b..b25f02c5 100644 --- a/script/staking/StakeHolderScript.t.sol +++ b/script/staking/StakeHolderScriptERC20.t.sol @@ -66,7 +66,7 @@ struct SimpleStakeHolderContractArgs { * @dev deploy() is the function the script should call. * For more details on deployment see ../../contracts/staking/README.md */ -contract StakeHolderScript is Test { +contract StakeHolderScriptERC20 is Test { /** * Deploy the OwnableCreate3Deployer needed for the complex deployment. @@ -250,7 +250,7 @@ contract StakeHolderScript is Test { uint256 bal = erc20.balanceOf(_staker); console.log("Balance is: %x", bal); - console.log("Amount is: %x", bal); + console.log("Amount is: %x", _amount); if (bal < _amount) { revert("Insufficient balance"); } diff --git a/script/staking/StakeHolderScriptWIMX.t.sol b/script/staking/StakeHolderScriptWIMX.t.sol new file mode 100644 index 00000000..1ee396f6 --- /dev/null +++ b/script/staking/StakeHolderScriptWIMX.t.sol @@ -0,0 +1,387 @@ +// Copyright (c) Immutable Pty Ltd 2018 - 2023 +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.20; + +import "forge-std/Test.sol"; +import {ERC1967Proxy} from "openzeppelin-contracts-4.9.3/proxy/ERC1967/ERC1967Proxy.sol"; +import {TimelockController} from "openzeppelin-contracts-4.9.3/governance/TimelockController.sol"; +import {IERC20} from "openzeppelin-contracts-4.9.3/token/ERC20/IERC20.sol"; + +import {IStakeHolder} from "../../contracts/staking/IStakeHolder.sol"; +import {StakeHolderWIMX} from "../../contracts/staking/StakeHolderWIMX.sol"; +import {WIMX} from "../../contracts/staking/WIMX.sol"; +import {OwnableCreate3Deployer} from "../../contracts/deployer/create3/OwnableCreate3Deployer.sol"; + +/** + * @title IDeployer Interface + * @notice This interface defines the contract responsible for deploying and optionally initializing new contracts + * via a specified deployment method. + * @dev Credit to axelarnetwork https://github.com/axelarnetwork/axelar-gmp-sdk-solidity/blob/main/contracts/interfaces/IDeployer.sol + */ +interface IDeployer { + function deploy(bytes memory bytecode, bytes32 salt) external payable returns (address deployedAddress_); + function deployAndInit(bytes memory bytecode, bytes32 salt, bytes calldata init) + external + payable + returns (address deployedAddress_); + function deployedAddress(bytes calldata bytecode, address sender, bytes32 salt) + external + view + returns (address deployedAddress_); +} + +// Args needed for compex deployment using CREATE3 and a TimelockController +struct ComplexDeploymentArgs { + address signer; + address factory; + string salt; +} +struct ComplexStakeHolderContractArgs { + address distributeAdmin; + address token; +} +struct ComplexTimelockContractArgs { + uint256 timeDelayInSeconds; + address proposerAdmin; + address executorAdmin; +} + + +// Args needed for simple deployment +struct SimpleDeploymentArgs { + address deployer; +} +struct SimpleStakeHolderContractArgs { + address roleAdmin; + address upgradeAdmin; + address distributeAdmin; + address token; +} + + + +/** + * @notice Deployment script and test code for the deployment script. + * @dev testDeploy is the test. + * @dev deploy() is the function the script should call. + * For more details on deployment see ../../contracts/staking/README.md + */ +contract StakeHolderScriptWIMX is Test { + + /** + * Deploy the OwnableCreate3Deployer needed for the complex deployment. + */ + function deployDeployer() external { + address signer = vm.envAddress("DEPLOYER_ADDRESS"); + _deployDeployer(signer); + } + + /** + * Deploy StakeHolderWIMX using Create3, with the TimelockController. + */ + function deployComplex() external { + address signer = vm.envAddress("DEPLOYER_ADDRESS"); + address factory = vm.envAddress("OWNABLE_CREATE3_FACTORY_ADDRESS"); + address distributeAdmin = vm.envAddress("DISTRIBUTE_ADMIN"); + address token = vm.envAddress("WIMX_TOKEN"); + uint256 timeDelayInSeconds = vm.envUint("TIMELOCK_DELAY_SECONDS"); + address proposerAdmin = vm.envAddress("TIMELOCK_PROPOSER_ADMIN"); + address executorAdmin = vm.envAddress("TIMELOCK_EXECUTOR_ADMIN"); + string memory salt = vm.envString("SALT"); + + ComplexDeploymentArgs memory deploymentArgs = ComplexDeploymentArgs({signer: signer, factory: factory, salt: salt}); + + ComplexStakeHolderContractArgs memory stakeHolderArgs = + ComplexStakeHolderContractArgs({distributeAdmin: distributeAdmin, token: token}); + + ComplexTimelockContractArgs memory timelockArgs = + ComplexTimelockContractArgs({timeDelayInSeconds: timeDelayInSeconds, proposerAdmin: proposerAdmin, executorAdmin: executorAdmin}); + _deployComplex(deploymentArgs, stakeHolderArgs, timelockArgs); + } + + /** + * Deploy StakeHolderWIMX using an EOA. + */ + function deploySimple() external { + address deployer = vm.envAddress("DEPLOYER_ADDRESS"); + address roleAdmin = vm.envAddress("ROLE_ADMIN"); + address upgradeAdmin = vm.envAddress("UPGRADE_ADMIN"); + address distributeAdmin = vm.envAddress("DISTRIBUTE_ADMIN"); + address token = vm.envAddress("WIMX_TOKEN"); + + SimpleDeploymentArgs memory deploymentArgs = SimpleDeploymentArgs({deployer: deployer}); + + SimpleStakeHolderContractArgs memory stakeHolderArgs = + SimpleStakeHolderContractArgs({ + roleAdmin: roleAdmin, upgradeAdmin: upgradeAdmin, + distributeAdmin: distributeAdmin, token: token}); + _deploySimple(deploymentArgs, stakeHolderArgs); + } + + function stake() external { + address stakeHolder = vm.envAddress("STAKE_HOLDER_CONTRACT"); + address staker = vm.envAddress("STAKER_ADDRESS"); + uint256 amount = vm.envUint("STAKER_AMOUNT"); + _stake(IStakeHolder(stakeHolder), staker, amount); + } + + function unstake() external { + address stakeHolder = vm.envAddress("STAKE_HOLDER_CONTRACT"); + address staker = vm.envAddress("STAKER_ADDRESS"); + uint256 amount = vm.envUint("STAKER_AMOUNT"); + _unstake(IStakeHolder(stakeHolder), staker, amount); + } + + + + /** + * Deploy the OwnableCreate3Deployer contract. Set the owner to the + * contract deployer. + */ + function _deployDeployer(address _deployer) private { + vm.startBroadcast(_deployer); + new OwnableCreate3Deployer(_deployer); + vm.stopBroadcast(); + } + + /** + * Deploy StakeHolderWIMX using Create3, with the TimelockController. + */ + function _deployComplex( + ComplexDeploymentArgs memory deploymentArgs, + ComplexStakeHolderContractArgs memory stakeHolderArgs, + ComplexTimelockContractArgs memory timelockArgs) + private + returns (StakeHolderWIMX stakeHolderContract, TimelockController timelockController) + { + IDeployer ownableCreate3 = IDeployer(deploymentArgs.factory); + + bytes32 salt1 = keccak256(abi.encode(deploymentArgs.salt)); + bytes32 salt2 = keccak256(abi.encode(salt1)); + bytes32 salt3 = keccak256(abi.encode(salt2)); + + // Deploy TimelockController via the Ownable Create3 factory. + address timelockAddress; + bytes memory deploymentBytecode; + { + address[] memory proposers = new address[](1); + proposers[0] = timelockArgs.proposerAdmin; + address[] memory executors = new address[](1); + executors[0] = timelockArgs.executorAdmin; + // Create deployment bytecode and encode constructor args + deploymentBytecode = abi.encodePacked( + type(TimelockController).creationCode, + abi.encode( + timelockArgs.timeDelayInSeconds, + proposers, + executors, + address(0) + ) + ); + /// @dev Deploy the contract via the Ownable CREATE3 factory + vm.startBroadcast(deploymentArgs.signer); + timelockAddress = ownableCreate3.deploy(deploymentBytecode, salt1); + vm.stopBroadcast(); + } + + + // Deploy StakeHolderWIMX via the Ownable Create3 factory. + // Create deployment bytecode and encode constructor args + deploymentBytecode = abi.encodePacked( + type(StakeHolderWIMX).creationCode + ); + /// @dev Deploy the contract via the Ownable CREATE3 factory + vm.startBroadcast(deploymentArgs.signer); + address stakeHolderImplAddress = ownableCreate3.deploy(deploymentBytecode, salt2); + vm.stopBroadcast(); + + // Deploy ERC1967Proxy via the Ownable Create3 factory. + // Create init data for the ERC1967 Proxy + bytes memory initData = abi.encodeWithSelector( + StakeHolderWIMX.initialize.selector, + timelockAddress, // roleAdmin + timelockAddress, // upgradeAdmin + stakeHolderArgs.distributeAdmin, + stakeHolderArgs.token + ); + // Create deployment bytecode and encode constructor args + deploymentBytecode = abi.encodePacked( + type(ERC1967Proxy).creationCode, + abi.encode(stakeHolderImplAddress, initData) + ); + /// @dev Deploy the contract via the Ownable CREATE3 factory + vm.startBroadcast(deploymentArgs.signer); + address stakeHolderContractAddress = ownableCreate3.deploy(deploymentBytecode, salt3); + vm.stopBroadcast(); + + stakeHolderContract = StakeHolderWIMX(payable(stakeHolderContractAddress)); + timelockController = TimelockController(payable(timelockAddress)); + } + + /** + * Deploy StakeHolderWIMX using an EOA and no time lock. + */ + function _deploySimple( + SimpleDeploymentArgs memory deploymentArgs, + SimpleStakeHolderContractArgs memory stakeHolderArgs) + private + returns (StakeHolderWIMX stakeHolderContract) { + + bytes memory initData = abi.encodeWithSelector( + StakeHolderWIMX.initialize.selector, + stakeHolderArgs.roleAdmin, + stakeHolderArgs.upgradeAdmin, + stakeHolderArgs.distributeAdmin, + stakeHolderArgs.token); + + vm.startBroadcast(deploymentArgs.deployer); + StakeHolderWIMX impl = new StakeHolderWIMX(); + vm.stopBroadcast(); + vm.startBroadcast(deploymentArgs.deployer); + ERC1967Proxy proxy = new ERC1967Proxy(address(impl), initData); + vm.stopBroadcast(); + + stakeHolderContract = StakeHolderWIMX(payable(address(proxy))); + } + + function _stake(IStakeHolder _stakeHolder, address _staker, uint256 _amount) private { + uint256 bal = _staker.balance; + console.log("Balance is: %x", bal); + console.log("Amount is: %x", _amount); + if (bal < _amount) { + revert("Insufficient balance"); + } + + vm.startBroadcast(_staker); + _stakeHolder.stake{value: _amount} (_amount); + vm.stopBroadcast(); + } + + function _unstake(IStakeHolder _stakeHolder, address _staker, uint256 _amount) private { + vm.startBroadcast(_staker); + _stakeHolder.unstake(_amount); + vm.stopBroadcast(); + } + + + function testComplex() external { + /// @dev Fork the Immutable zkEVM testnet for this test + string memory rpcURL = "https://rpc.testnet.immutable.com"; + vm.createSelectFork(rpcURL); + + address payable wimxOnTestnet = payable(address(0x1CcCa691501174B4A623CeDA58cC8f1a76dc3439)); + WIMX erc20 = WIMX(wimxOnTestnet); + + /// @dev These are Immutable zkEVM testnet values where necessary + address immTestNetCreate3 = 0x37a59A845Bb6eD2034098af8738fbFFB9D589610; + ComplexDeploymentArgs memory deploymentArgs = ComplexDeploymentArgs({ + signer: 0xdDA0d9448Ebe3eA43aFecE5Fa6401F5795c19333, + factory: immTestNetCreate3, + salt: "salt" + }); + + address distributeAdmin = makeAddr("distribute"); + ComplexStakeHolderContractArgs memory stakeHolderArgs = + ComplexStakeHolderContractArgs({ + distributeAdmin: distributeAdmin, + token: address(erc20) + }); + + uint256 delay = 604800; // 604800 seconds = 1 week + address proposer = makeAddr("proposer"); + address executor = makeAddr("executor"); + + ComplexTimelockContractArgs memory timelockArgs = + ComplexTimelockContractArgs({ + timeDelayInSeconds: delay, + proposerAdmin: proposer, + executorAdmin: executor + }); + + // Run deployment against forked testnet + StakeHolderWIMX stakeHolder; + TimelockController timelockController; + (stakeHolder, timelockController) = + _deployComplex(deploymentArgs, stakeHolderArgs, timelockArgs); + + _commonTest(true, IStakeHolder(stakeHolder), address(timelockController), + immTestNetCreate3, address(0), address(0), distributeAdmin); + + assertTrue(timelockController.hasRole(timelockController.PROPOSER_ROLE(), proposer), "Proposer not set correcrly"); + assertTrue(timelockController.hasRole(timelockController.EXECUTOR_ROLE(), executor), "Executor not set correcrly"); + assertEq(timelockController.getMinDelay(), delay, "Delay not set correctly"); + } + + function testSimple() external { + /// @dev Fork the Immutable zkEVM testnet for this test + string memory rpcURL = "https://rpc.testnet.immutable.com"; + vm.createSelectFork(rpcURL); + + address deployer = makeAddr("deployer"); + + address payable wimxOnTestnet = payable(address(0x1CcCa691501174B4A623CeDA58cC8f1a76dc3439)); + WIMX erc20 = WIMX(wimxOnTestnet); + + /// @dev These are Immutable zkEVM testnet values where necessary + SimpleDeploymentArgs memory deploymentArgs = SimpleDeploymentArgs({ + deployer: deployer + }); + + address roleAdmin = makeAddr("role"); + address upgradeAdmin = makeAddr("upgrade"); + address distributeAdmin = makeAddr("distribute"); + + SimpleStakeHolderContractArgs memory stakeHolderContractArgs = + SimpleStakeHolderContractArgs({ + roleAdmin: roleAdmin, + upgradeAdmin: upgradeAdmin, + distributeAdmin: distributeAdmin, + token: address(erc20) + }); + + // Run deployment against forked testnet + StakeHolderWIMX stakeHolder = _deploySimple(deploymentArgs, stakeHolderContractArgs); + + _commonTest(false, IStakeHolder(stakeHolder), address(0), + deployer, roleAdmin, upgradeAdmin, distributeAdmin); + } + + function _commonTest( + bool _isComplex, + IStakeHolder _stakeHolder, + address _timelockControl, + address _deployer, + address _roleAdmin, + address _upgradeAdmin, + address _distributeAdmin + ) private { + address roleAdmin = _isComplex ? _timelockControl : _roleAdmin; + address upgradeAdmin = _isComplex ? _timelockControl : _upgradeAdmin; + + address tokenAddress = _stakeHolder.getToken(); + IERC20 erc20 = IERC20(tokenAddress); + + // Post deployment checks + { + StakeHolderWIMX temp = new StakeHolderWIMX(); + bytes32 defaultAdminRole = temp.DEFAULT_ADMIN_ROLE(); + assertTrue(_stakeHolder.hasRole(_stakeHolder.UPGRADE_ROLE(), upgradeAdmin), "Upgrade admin should have upgrade role"); + assertTrue(_stakeHolder.hasRole(defaultAdminRole, roleAdmin), "Role admin should have default admin role"); + assertTrue(_stakeHolder.hasRole(_stakeHolder.DISTRIBUTE_ROLE(), _distributeAdmin), "Distribute admin should have distribute role"); + // The DEFAULT_ADMIN_ROLE should be revoked from the deployer account + assertFalse(_stakeHolder.hasRole(defaultAdminRole, _deployer), "msg.sender should not be an admin"); + } + + address user1 = makeAddr("user1"); + vm.deal(user1, 100 ether); + + _stake(_stakeHolder, user1, 10 ether); + + assertEq(user1.balance, 90 ether, "User1 balance after stake"); + assertEq(erc20.balanceOf(address(_stakeHolder)), 10 ether, "StakeHolder balance after stake"); + + _unstake(_stakeHolder, user1, 7 ether); + assertEq(user1.balance, 97 ether, "User1 balance after unstake"); + assertEq(erc20.balanceOf(address(_stakeHolder)), 3 ether, "StakeHolder balance after unstake"); + } +} diff --git a/script/staking/common.sh b/script/staking/common.sh index 82ee5bae..c8b89846 100644 --- a/script/staking/common.sh +++ b/script/staking/common.sh @@ -44,6 +44,21 @@ if [ -z "${FUNCTION_TO_EXECUTE}" ]; then exit 1 fi +if [ -z "${STAKEHOLDER_TYPE}" ]; then + echo "Error: STAKEHOLDER_TYPE variable is not set. Should be ERC20 or WIMX" + exit 1 +fi +if [ "$STAKEHOLDER_TYPE" = "ERC20" ]; then + script=script/staking/StakeHolderScriptERC20.t.sol:StakeHolderScriptERC20 +else + if [ "$STAKEHOLDER_TYPE" = "ERC20" ]; then + script=script/staking/StakeHolderScriptWIMX.t.sol:StakeHolderScriptWIMX + else + echo "Error: Unknown STAKEHOLDER_TYPE: " $STAKEHOLDER_TYPE + exit 1 + fi +fi + echo "Configuration" echo " IMMUTABLE_RPC: $IMMUTABLE_RPC" @@ -55,8 +70,8 @@ if [ "${HARDWARE_WALLET}" = "ledger" ] || [ "${HARDWARE_WALLET}" = "trezor" ]; t else echo " PRIVATE_KEY: " # $PRIVATE_KEY fi -echo "Function to execute: $FUNCTION_TO_EXECUTE" - +echo " Function to execute: $FUNCTION_TO_EXECUTE" +echo " Script to execute: $script" # NOTE WELL --------------------------------------------- @@ -75,7 +90,7 @@ if [ "${HARDWARE_WALLET}" = "ledger" ] || [ "${HARDWARE_WALLET}" = "trezor" ]; t --sig "$FUNCTION_TO_EXECUTE" \ --$HARDWARE_WALLET \ --hd-paths "$HD_PATH" \ - script/staking/StakeHolderScript.t.sol:StakeHolderScript + $script else forge script --rpc-url $IMMUTABLE_RPC \ --priority-gas-price 10000000000 \ @@ -87,5 +102,5 @@ else --verifier-url $BLOCKSCOUT_URI$BLOCKSCOUT_APIKEY \ --sig "$FUNCTION_TO_EXECUTE" \ --private-key $PRIVATE_KEY \ - script/staking/StakeHolderScript.t.sol:StakeHolderScript + $script fi From 0aa3f525161d21518f0f61492b36c43899801e83 Mon Sep 17 00:00:00 2001 From: Peter Robinson Date: Tue, 29 Apr 2025 11:30:14 +1000 Subject: [PATCH 30/39] fixed naming --- script/staking/common.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/script/staking/common.sh b/script/staking/common.sh index c8b89846..77c430e0 100644 --- a/script/staking/common.sh +++ b/script/staking/common.sh @@ -51,7 +51,7 @@ fi if [ "$STAKEHOLDER_TYPE" = "ERC20" ]; then script=script/staking/StakeHolderScriptERC20.t.sol:StakeHolderScriptERC20 else - if [ "$STAKEHOLDER_TYPE" = "ERC20" ]; then + if [ "$STAKEHOLDER_TYPE" = "WIMX" ]; then script=script/staking/StakeHolderScriptWIMX.t.sol:StakeHolderScriptWIMX else echo "Error: Unknown STAKEHOLDER_TYPE: " $STAKEHOLDER_TYPE From 049366a374f1d025365ea563b462c0511deff31b Mon Sep 17 00:00:00 2001 From: Peter Robinson Date: Tue, 29 Apr 2025 15:03:27 +1000 Subject: [PATCH 31/39] Add StakeHolderWIMX to architecture --- contracts/staking/README.md | 10 ++++++---- contracts/staking/staking-architecture.png | Bin 60601 -> 72192 bytes 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/contracts/staking/README.md b/contracts/staking/README.md index 3e88b598..6047c2e5 100644 --- a/contracts/staking/README.md +++ b/contracts/staking/README.md @@ -12,9 +12,11 @@ The system consists of a set of contracts show in the diagram below. `StakeHolderBase.sol` is the abstract base contract that all staking implementation use. +`StakeHolderWIMX.sol` allows the native token, IMX, to be used as the staking currency. Stake is held as wrapped IMX, WIMX, an ERC20 token. + `StakeHolderERC20.sol` allows an ERC20 token to be used as the staking currency. -`StakeHolderNative.sol` uses the native token, IMX, to be used as the staking currency. +`StakeHolderNative.sol` uses the native token, IMX, to be used as the staking currency. Stake is held as native IMX. `ERC1967Proxy.sol` is a proxy contract. All calls to StakeHolder contracts go via the ERC1967Proxy contract. @@ -49,13 +51,13 @@ See [deployment scripts](../../script/staking/README.md). # Usage -For StakeHolderERC20, the ERC20 staking token must be specified when the contract is being initialised. The token can not be changed. +For StakeHolderERC20 and StakeHolderWIMX, the ERC20 staking token must be specified when the contract is being initialised. The token can not be changed. -To stake, any account should call `stake(uint256 _amount)`. For the native IMX variant, the amount to be staked must be passed in as the msg.value. +To stake, any account should call `stake(uint256 _amount)`. For the WIMX and the native IMX variants, the amount to be staked must be passed in as the msg.value. To unstake, the account that previously staked should call, `unstake(uint256 _amountToUnstake)`. -Accounts that have DISTRIBUTE_ROLE that wish to distribute rewards should call, `distributeRewards(AccountAmount[] calldata _recipientsAndAmounts)`. The `AccountAmount` structure consists of recipient address and amount to distribute pairs. Distributions can only be made to accounts that have previously or are currently staking. For the native IMX variant, the amount to be distributed must be passed in as msg.value and must equal to the sum of the amounts specified in the `_recipientsAndAmounts` array. +Accounts that have DISTRIBUTE_ROLE that wish to distribute rewards should call, `distributeRewards(AccountAmount[] calldata _recipientsAndAmounts)`. The `AccountAmount` structure consists of recipient address and amount to distribute pairs. Distributions can only be made to accounts that have previously or are currently staking. For the WIMX and the native IMX variants, the amount to be distributed must be passed in as msg.value and must equal to the sum of the amounts specified in the `_recipientsAndAmounts` array. The `stakers` array needs to be analysed to determine which accounts have staked and how much. The following functions provide access to this data structure: diff --git a/contracts/staking/staking-architecture.png b/contracts/staking/staking-architecture.png index f408bc44c94dc56ee2efe861824699ec2f3f6057..e0c4d48bb095c526ee7caf58109a39f5a79b4efd 100644 GIT binary patch literal 72192 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 literal 60601 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νqERR`@w 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 Date: Tue, 29 Apr 2025 15:04:07 +1000 Subject: [PATCH 32/39] Remove timestamp from events --- contracts/staking/IStakeHolder.sol | 4 ++-- contracts/staking/StakeHolderBase.sol | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/contracts/staking/IStakeHolder.sol b/contracts/staking/IStakeHolder.sol index c42274bb..12c06fa5 100644 --- a/contracts/staking/IStakeHolder.sol +++ b/contracts/staking/IStakeHolder.sol @@ -34,10 +34,10 @@ interface IStakeHolder is IAccessControlEnumerableUpgradeable { error MismatchMsgValueAmount(uint256 _msgValue, uint256 _amount); /// @notice Event when an amount has been staked or when an amount is distributed to an account. - event StakeAdded(address _staker, uint256 _amountAdded, uint256 _newBalance, uint256 _timestamp); + event StakeAdded(address _staker, uint256 _amountAdded, uint256 _newBalance); /// @notice Event when an amount has been unstaked. - event StakeRemoved(address _staker, uint256 _amountRemoved, uint256 _newBalance, uint256 _timestamp); + event StakeRemoved(address _staker, uint256 _amountRemoved, uint256 _newBalance); /// @notice Event summarising a distribution. There will also be one StakeAdded event for each recipient. event Distributed(address _distributor, uint256 _totalDistribution, uint256 _numRecipients); diff --git a/contracts/staking/StakeHolderBase.sol b/contracts/staking/StakeHolderBase.sol index 219bd063..024ad50c 100644 --- a/contracts/staking/StakeHolderBase.sol +++ b/contracts/staking/StakeHolderBase.sol @@ -110,7 +110,7 @@ abstract contract StakeHolderBase is uint256 newBalance = currentStake - _amountToUnstake; stakeInfo.stake = newBalance; - emit StakeRemoved(msg.sender, _amountToUnstake, newBalance, block.timestamp); + emit StakeRemoved(msg.sender, _amountToUnstake, newBalance); _sendValue(msg.sender, _amountToUnstake); } @@ -193,7 +193,7 @@ abstract contract StakeHolderBase is } uint256 newBalance = currentStake + _amount; stakeInfo.stake = newBalance; - emit StakeAdded(_account, _amount, newBalance, block.timestamp); + emit StakeAdded(_account, _amount, newBalance); } /** From 650ed60080b4f54c238bfd73bd90b474103c0b53 Mon Sep 17 00:00:00 2001 From: Peter Robinson Date: Tue, 29 Apr 2025 15:04:31 +1000 Subject: [PATCH 33/39] Added documentation for StakeHolderWIMX variant --- script/staking/README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/script/staking/README.md b/script/staking/README.md index a488fe3d..c0549d42 100644 --- a/script/staking/README.md +++ b/script/staking/README.md @@ -22,7 +22,8 @@ The following variables must be specified via the environment or a `.env` file f * `ROLE_ADMIN`: Account that will be the initial role administrator. Accounts with the role administrator access can manage which accounts have `UPGRADE_ADMIN` and `DISTRIBUTED_ADMIN` access. Specify 0x0000000000000000000000000000000000000000 to have no account with role administrator access. * `UPGRADE_ADMIN`: Initial account that will be authorised to upgrade the StakeHolderERC20 contract. Specify 0x0000000000000000000000000000000000000000 to have no account with upgrade administrator access. * `DISTRIBUTE_ADMIN`: Initial account that will be authorised to upgrade the StakeHolderERC20 contract. Specify 0x0000000000000000000000000000000000000000 to have no account with distribute administrator access. -* `ERC20_STAKING_TOKEN`: Address of ERC20 token to be used for staking. +* `ERC20_STAKING_TOKEN`: Address of ERC20 token to be used for staking. For use by ERC20 deployment script. +* `WIMX_TOKEN`: Address of WIMX token contract. For use by the WIMX deployment scirpt. ## Complex Deployment From bf5b4bd84a81d0c26fbe16874b9a21fa2d38f41c Mon Sep 17 00:00:00 2001 From: Peter Robinson Date: Wed, 30 Apr 2025 15:48:03 +1000 Subject: [PATCH 34/39] Updated threat model to add StakeHolderWIMX contract --- .../202504-threat-model-stake-holder.md | 51 +++++++++++++++---- 1 file changed, 42 insertions(+), 9 deletions(-) diff --git a/audits/staking/202504-threat-model-stake-holder.md b/audits/staking/202504-threat-model-stake-holder.md index d5309ba6..82d9cd78 100644 --- a/audits/staking/202504-threat-model-stake-holder.md +++ b/audits/staking/202504-threat-model-stake-holder.md @@ -2,23 +2,24 @@ ## Introduction -This threat model document for the [StakeHolderERC20 and StakeHolderNative](../../contracts/staking/README.md) contracts has been created in preparation for an internal audit. +This threat model document for the [StakeHolderWIMX, StakeHolderERC20 and StakeHolderNative](../../contracts/staking/README.md) contracts has been created in preparation for an internal audit. ## Rationale -Immutable operates a system whereby people can place Wrapped 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. +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 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 [`bf327c7abdadd48fd51ae632500510ac2b07b5f0`](https://github.com/immutable/contracts/tree/bf327c7abdadd48fd51ae632500510ac2b07b5f0/contracts/staking): +The threat model is limited to the stake holder Solidity files at GitHash [`bf327c7abdadd48fd51ae632500510ac2b07b5f0`](https://github.com/immutable/contracts/tree/aee3f35d76117a1a22dab96fd6dfd8e92444757b/contracts/staking): * [IStakeHolder.sol](https://github.com/immutable/contracts/blob/bf327c7abdadd48fd51ae632500510ac2b07b5f0/contracts/staking/IStakeHolder.sol) is the interface that all staking implementations comply with. -* [StakeHolderBase.sol](https://github.com/immutable/contracts/blob/bf327c7abdadd48fd51ae632500510ac2b07b5f0/contracts/staking/StakeHolderBase.sol) is the abstract base contract that all staking implementation use. -* [StakeHolderERC20.sol](https://github.com/immutable/contracts/blob/bf327c7abdadd48fd51ae632500510ac2b07b5f0/contracts/staking/StakeHolderERC20.sol) allows an ERC20 token to be used as the staking currency. -* [StakeHolderNative.sol](https://github.com/immutable/contracts/blob/bf327c7abdadd48fd51ae632500510ac2b07b5f0/contracts/staking/StakeHolderNative.sol) uses the native token, IMX, to be used as the staking currency. +* [StakeHolderBase.sol](https://github.com/immutable/contracts/tree/aee3f35d76117a1a22dab96fd6dfd8e92444757b/contracts/staking/StakeHolderBase.sol) is the abstract base contract that all staking implementation use. +* [StakeHolderWIMX.sol](https://github.com/immutable/contracts/tree/aee3f35d76117a1a22dab96fd6dfd8e92444757b/contracts/staking/StakeHolderWIMX.sol) allows the native token, IMX, to be used as the staking currency. +* [StakeHolderERC20.sol](https://github.com/immutable/contracts/tree/aee3f35d76117a1a22dab96fd6dfd8e92444757b/contracts/staking/StakeHolderERC20.sol) allows an ERC20 token to be used as the staking currency. +* [StakeHolderNative.sol](https://github.com/immutable/contracts/tree/aee3f35d76117a1a22dab96fd6dfd8e92444757b/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: @@ -53,9 +54,10 @@ The following sections list attack surfaces evaluated as part of this threat mod 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. `StakeHolderERC20` and `StakeHolderNative` have identical functions with the exception of the `initialize` function. `StakeHolderERC20` uses the `initialize` function that has four parameters and `StakeHolderNative` uses the `initialize` function with three parameters. +The list of functions and their function selectors was determined by the following commands. The additional information was obtained by reviewing the code. `StakeHolderWIMX`, `StakeHolderERC20` and `StakeHolderNative` have identical functions with the exception of the `initialize` function. `StakeHolderWIMX` and `StakeHolderERC20` use the `initialize` function that has four parameters and `StakeHolderNative` uses the `initialize` function with three parameters. ``` +forge inspect StakeHolderWIMX methods forge inspect StakeHolderERC20 methods forge inspect StakeHolderNative methods ``` @@ -67,7 +69,7 @@ Functions that *change* state: | `distributeRewards((address,uint256)[])`| 00cfb539 | Permissionless | | `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 | +| `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 | @@ -134,6 +136,37 @@ Exploiting this attack surface requires compromising an account with `DISTRIBUTE ### 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 StakeHolderERC20 The table was constructed by using the command described below, and analysing the source code. From 02c0081fe6a5b43f9532eeac10ec226abd5fe01c Mon Sep 17 00:00:00 2001 From: Peter Robinson Date: Wed, 30 Apr 2025 15:50:07 +1000 Subject: [PATCH 35/39] Fix prettier issue --- contracts/staking/StakeHolderNative.sol | 2 +- contracts/staking/StakeHolderWIMX.sol | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/contracts/staking/StakeHolderNative.sol b/contracts/staking/StakeHolderNative.sol index 7680faeb..6961c35d 100644 --- a/contracts/staking/StakeHolderNative.sol +++ b/contracts/staking/StakeHolderNative.sol @@ -25,7 +25,7 @@ contract StakeHolderNative is StakeHolderBase { /** * @inheritdoc IStakeHolder */ - function getToken() external virtual view returns (address) { + function getToken() external view virtual returns (address) { return address(0); } diff --git a/contracts/staking/StakeHolderWIMX.sol b/contracts/staking/StakeHolderWIMX.sol index f390be72..c4b69fb7 100644 --- a/contracts/staking/StakeHolderWIMX.sol +++ b/contracts/staking/StakeHolderWIMX.sol @@ -12,7 +12,6 @@ import {IWIMX} from "./IWIMX.sol"; * The StakeHolderWIMX contract is designed to be upgradeable. */ contract StakeHolderWIMX is StakeHolderNative { - /// @notice The token used for staking. IWIMX internal wIMX; @@ -40,7 +39,7 @@ contract StakeHolderWIMX is StakeHolderNative { /** * @inheritdoc IStakeHolder */ - function getToken() external override view returns (address) { + function getToken() external view override returns (address) { return address(wIMX); } From 319457ffe768cef162da0ce6f8fe50cdff077535 Mon Sep 17 00:00:00 2001 From: Peter Robinson Date: Wed, 30 Apr 2025 16:07:44 +1000 Subject: [PATCH 36/39] Fix slither reports --- contracts/staking/StakeHolderBase.sol | 3 --- contracts/staking/StakeHolderWIMX.sol | 4 ++-- contracts/staking/WIMX.sol | 9 +++++++-- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/contracts/staking/StakeHolderBase.sol b/contracts/staking/StakeHolderBase.sol index 024ad50c..77c50ef7 100644 --- a/contracts/staking/StakeHolderBase.sol +++ b/contracts/staking/StakeHolderBase.sol @@ -4,11 +4,8 @@ pragma solidity >=0.8.19 <0.8.29; import {UUPSUpgradeable} from "openzeppelin-contracts-upgradeable-4.9.3/proxy/utils/UUPSUpgradeable.sol"; import {AccessControlEnumerableUpgradeable} from "openzeppelin-contracts-upgradeable-4.9.3/access/AccessControlEnumerableUpgradeable.sol"; -import {AccessControlUpgradeable} from "openzeppelin-contracts-upgradeable-4.9.3/access/AccessControlUpgradeable.sol"; -import {IAccessControlUpgradeable} from "openzeppelin-contracts-upgradeable-4.9.3/access/IAccessControlUpgradeable.sol"; import {ReentrancyGuardUpgradeable} from "openzeppelin-contracts-upgradeable-4.9.3/security/ReentrancyGuardUpgradeable.sol"; import {IStakeHolder} from "./IStakeHolder.sol"; -import {StakeHolderBase} from "./StakeHolderBase.sol"; /** * @title StakeHolderBase: allows anyone to stake any amount of an ERC20 token and to then remove all or part of that stake. diff --git a/contracts/staking/StakeHolderWIMX.sol b/contracts/staking/StakeHolderWIMX.sol index c4b69fb7..25396ecc 100644 --- a/contracts/staking/StakeHolderWIMX.sol +++ b/contracts/staking/StakeHolderWIMX.sol @@ -2,8 +2,7 @@ // SPDX-License-Identifier: Apache 2 pragma solidity >=0.8.19 <0.8.29; -import {IStakeHolder, StakeHolderBase} from "./StakeHolderBase.sol"; -import {StakeHolderNative} from "./StakeHolderNative.sol"; +import {IStakeHolder, StakeHolderBase, StakeHolderNative} from "./StakeHolderNative.sol"; import {IWIMX} from "./IWIMX.sol"; /** @@ -60,6 +59,7 @@ contract StakeHolderWIMX is StakeHolderNative { super._checksAndTransfer(_amount); // Convert native IMX to WIMX. + // slither-disable-next-line arbitrary-send-eth wIMX.deposit{value: _amount}(); } diff --git a/contracts/staking/WIMX.sol b/contracts/staking/WIMX.sol index 596d285a..c41e22f9 100644 --- a/contracts/staking/WIMX.sol +++ b/contracts/staking/WIMX.sol @@ -10,13 +10,16 @@ import {Address} from "@openzeppelin/contracts/utils/Address.sol"; * @dev This contract is adapted from the official Wrapped ETH contract. * This contract is copied from https://github.com/immutable/zkevm-bridge-contracts/blob/main/src/child/WIMX.sol */ +// solhint-disable custom-errors, reason-string contract WIMX is IWIMX { + // slither-disable-start constable-states string public name = "Wrapped IMX"; string public symbol = "WIMX"; uint8 public decimals = 18; + // slither-disable-end constable-states - mapping(address => uint256) public balanceOf; - mapping(address => mapping(address => uint256)) public allowance; + mapping(address account => uint256 balance) public balanceOf; + mapping(address account => mapping(address spender => uint256 amount)) public allowance; /** * @notice Fallback function on receiving native IMX. @@ -37,6 +40,7 @@ contract WIMX is IWIMX { * @notice Withdraw given amount of native IMX to msg.sender and burn the equal amount of wrapped IMX. * @param wad The amount to withdraw. */ + // slither-disable-start reentrancy-events function withdraw(uint256 wad) public { require(balanceOf[msg.sender] >= wad, "Wrapped IMX: Insufficient balance"); balanceOf[msg.sender] -= wad; @@ -44,6 +48,7 @@ contract WIMX is IWIMX { Address.sendValue(payable(msg.sender), wad); emit Withdrawal(msg.sender, wad); } + // slither-disable-end reentrancy-events /** * @notice Obtain the current total supply of wrapped IMX. From 2c88997e69a5538da055921b96aba9f2c58986a9 Mon Sep 17 00:00:00 2001 From: Peter Robinson Date: Wed, 30 Apr 2025 16:14:47 +1000 Subject: [PATCH 37/39] Add more tests --- test/staking/StakeHolderTimeDelayWIMX.t.sol | 43 +++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 test/staking/StakeHolderTimeDelayWIMX.t.sol diff --git a/test/staking/StakeHolderTimeDelayWIMX.t.sol b/test/staking/StakeHolderTimeDelayWIMX.t.sol new file mode 100644 index 00000000..0a55d1b9 --- /dev/null +++ b/test/staking/StakeHolderTimeDelayWIMX.t.sol @@ -0,0 +1,43 @@ +// Copyright Immutable Pty Ltd 2018 - 2024 +// SPDX-License-Identifier: Apache 2.0 +pragma solidity >=0.8.19 <0.8.29; + +// solhint-disable-next-line no-global-import +import "forge-std/Test.sol"; +import {StakeHolderWIMX} from "../../contracts/staking/StakeHolderWIMX.sol"; +import {IStakeHolder} from "../../contracts/staking/IStakeHolder.sol"; +import {StakeHolderTimeDelayBaseTest} from "./StakeHolderTimeDelayBase.t.sol"; +import {ERC1967Proxy} from "openzeppelin-contracts-4.9.3/proxy/ERC1967/ERC1967Proxy.sol"; +import {WIMX} from "../../contracts/staking/WIMX.sol"; +import {StakeHolderBase} from "../../contracts/staking/StakeHolderBase.sol"; + +contract StakeHolderWIMXV2 is StakeHolderWIMX { + function upgradeStorage(bytes memory /* _data */) external override(StakeHolderBase) { + version = 1; + } +} + + +contract StakeHolderTimeDelayERC20Test is StakeHolderTimeDelayBaseTest { + WIMX erc20; + + function setUp() public override { + super.setUp(); + + erc20 = new WIMX(); + + StakeHolderWIMX impl = new StakeHolderWIMX(); + + bytes memory initData = abi.encodeWithSelector( + StakeHolderWIMX.initialize.selector, address(stakeHolderTimeDelay), address(stakeHolderTimeDelay), + distributeAdmin, address(erc20) + ); + + ERC1967Proxy proxy = new ERC1967Proxy(address(impl), initData); + stakeHolder = IStakeHolder(address(proxy)); + } + + function _deployV2() internal override returns(IStakeHolder) { + return IStakeHolder(address(new StakeHolderWIMXV2())); + } +} From b7317acb2626838d826f5bc6782fa1d2483c47ce Mon Sep 17 00:00:00 2001 From: Peter Robinson Date: Thu, 1 May 2025 13:47:34 +1000 Subject: [PATCH 38/39] Change name from ETH to IMX --- contracts/staking/IWIMX.sol | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/contracts/staking/IWIMX.sol b/contracts/staking/IWIMX.sol index bf973abb..f7302bcc 100644 --- a/contracts/staking/IWIMX.sol +++ b/contracts/staking/IWIMX.sol @@ -6,30 +6,30 @@ import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; /* * @notice Interface for the Wrapped IMX (wIMX) contract. - * @dev Based on the interface for the [Wrapped ETH contract](https://etherscan.io/token/0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2#code) + * @dev Based on the interface for the [Wrapped IMX contract](https://etherscan.io/token/0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2#code) */ interface IWIMX is IERC20 { /** - * @notice Emitted when native ETH is deposited to the contract, and a corresponding amount of wETH are minted + * @notice Emitted when native IMX is deposited to the contract, and a corresponding amount of wIMX are minted * @param account The address of the account that deposited the tokens. * @param value The amount of tokens that were deposited. */ event Deposit(address indexed account, uint256 value); /** - * @notice Emitted when wETH is withdrawn from the contract, and a corresponding amount of wETH are burnt. + * @notice Emitted when wIMX is withdrawn from the contract, and a corresponding amount of wIMX are burnt. * @param account The address of the account that withdrew the tokens. * @param value The amount of tokens that were withdrawn. */ event Withdrawal(address indexed account, uint256 value); /** - * @notice Deposit native ETH to the contract and mint an equal amount of wrapped ETH to msg.sender. + * @notice Deposit native IMX to the contract and mint an equal amount of wrapped IMX to msg.sender. */ function deposit() external payable; /** - * @notice Withdraw given amount of native ETH to msg.sender after burning an equal amount of wrapped ETH. + * @notice Withdraw given amount of native IMX to msg.sender after burning an equal amount of wrapped IMX. * @param value The amount to withdraw. */ function withdraw(uint256 value) external; From ee26d433a6da5857e309525d7ccc2d646dd35921 Mon Sep 17 00:00:00 2001 From: Peter Robinson Date: Wed, 7 May 2025 13:09:14 +1000 Subject: [PATCH 39/39] Update audits/staking/202504-threat-model-stake-holder.md Co-authored-by: w3njah --- audits/staking/202504-threat-model-stake-holder.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/audits/staking/202504-threat-model-stake-holder.md b/audits/staking/202504-threat-model-stake-holder.md index 82d9cd78..66b1c2ca 100644 --- a/audits/staking/202504-threat-model-stake-holder.md +++ b/audits/staking/202504-threat-model-stake-holder.md @@ -66,7 +66,7 @@ Functions that *change* state: | Name | Function Selector | Access Control | | --------------------------------------- | ----------------- | ------------------- | -| `distributeRewards((address,uint256)[])`| 00cfb539 | Permissionless | +| `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 |