// SPDX-License-Identifier: UNLICENSED pragma solidity =0.8.18; import "@openzeppelin/contracts-upgradeable/security/ReentrancyGuardUpgradeable.sol"; import "@openzeppelin/contracts-upgradeable/utils/math/SafeCastUpgradeable.sol"; import "@openzeppelin/contracts-upgradeable/utils/structs/EnumerableSetUpgradeable.sol"; import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import "../utils/math/SortedList.sol"; import "../utils/math/Percent.sol"; import "../utils/DynamicArray.sol"; import "../SomaGuard/utils/GuardableUpgradeable.sol"; import "../SecurityTokens/ERC20/utils/SafeERC20Balance.sol"; import "../Lockdrop/extensions/TokenRecoveryUpgradeable.sol"; import "./SomaStakingLibrary.sol"; import "./ISomaStaking.sol"; /** * @notice Implementation of the {ISomaStaking} interface. */ contract SomaStaking is ISomaStaking, ReentrancyGuardUpgradeable, TokenRecoveryUpgradeable, GuardableUpgradeable { using EnumerableSetUpgradeable for EnumerableSetUpgradeable.AddressSet; using SafeERC20Balance for IERC20; using SafeERC20 for IERC20; using SortedList for SortedList.AscendingList; using Percent for uint256; using SafeCastUpgradeable for uint256; /** * @inheritdoc ISomaStaking */ bytes32 public constant override GLOBAL_ADMIN_ROLE = keccak256("Staking.GLOBAL_ADMIN_ROLE"); /** * @inheritdoc ISomaStaking */ bytes32 public constant override GLOBAL_SEIZE_ROLE = keccak256("Staking.GLOBAL_SEIZE_ROLE"); /** * @inheritdoc ISomaStaking */ bytes32 public override LOCAL_ADMIN_ROLE; /** * @inheritdoc ISomaStaking */ bytes32 public override LOCAL_SEIZE_ROLE; /* Amount of staked tokens globally */ uint256 private _totalStaked; uint256 private _totalPendingUnstake; address private _stakingToken; uint256 private _currentRequestId; StakingConfig private _config; mapping(address => uint256) private _tps; mapping(address => UserInfo) private _users; mapping(address => uint256) private _adminClaimable; mapping(uint256 => Request) private _requests; SortedList.AscendingList private _pendingStrategies; EnumerableSetUpgradeable.AddressSet private _rewardTokens; Strategy[] private _strategies; /** * @notice Modifier to restrict function calls to accounts that have the GLOBAL_ADMIN_ROLE or LOCAL_ADMIN_ROLE. */ modifier onlyAdmin() { address _sender = _msgSender(); require(hasRole(GLOBAL_ADMIN_ROLE, _sender) || hasRole(LOCAL_ADMIN_ROLE, _sender), "Staking: ADMIN_ONLY"); // TODO errors should be Staking or SomaStaking _; } /** * @notice Modifier to restrict function calls to accounts that have the GLOBAL_SEIZE_ROLE or LOCAL_SEIZE_ROLE. */ modifier onlySeizeRole() { address _sender = _msgSender(); require(hasRole(GLOBAL_SEIZE_ROLE, _sender) || hasRole(LOCAL_SEIZE_ROLE, _sender), "Staking: SEIZE_ROLE_ONLY"); _; } /** * @notice Checks if SomaStaking inherits a given contract interface. * @dev See {IERC165-supportsInterface}. */ function supportsInterface(bytes4 interfaceId) public view virtual override(GuardableUpgradeable, TokenRecoveryUpgradeable) returns (bool) { return interfaceId == type(ISomaStaking).interfaceId || super.supportsInterface(interfaceId); } /** * @inheritdoc ISomaStaking */ function config() external view override returns (StakingConfig memory) { return _config; } /** * @inheritdoc ISomaStaking */ function totalStaked() external view override returns (uint256) { return _totalStaked; } /** * @inheritdoc ISomaStaking */ function totalPendingUnstake() external view override returns (uint256) { return _totalPendingUnstake; } /** * @inheritdoc ISomaStaking */ function strategy(uint256 id) external view override returns (Strategy memory) { return _strategies[id]; } /** * @inheritdoc ISomaStaking */ function totalStrategies() external view override returns (uint256) { return _strategies.length; } /** * @inheritdoc ISomaStaking */ function stakingToken() external view override returns (address) { return _stakingToken; } /** * @inheritdoc ISomaStaking */ function rewardToken(uint256 index) external view override returns (address) { return _rewardTokens.at(index); } /** * @inheritdoc ISomaStaking */ function totalRewardTokens() external view override returns (uint256) { return _rewardTokens.length(); } /** * @inheritdoc ISomaStaking */ function pendingStrategy(uint256 index) external view override returns (Strategy memory) { (bytes32 id,) = _pendingStrategies.at(index); return _strategies[uint256(id)]; } /** * @inheritdoc ISomaStaking */ function totalPendingStrategies() external view override returns (uint256) { return _pendingStrategies.length(); } /** * @inheritdoc ISomaStaking */ function tps(address _asset) external view override returns (uint256) { return _tps[_asset]; } /** * @inheritdoc ISomaStaking */ function adminClaimable(address _asset) external view override returns (uint256) { bool _isRewardAsset = _rewardTokens.contains(_asset); require(_isRewardAsset || _asset == _stakingToken, "Staking: INVALID_ASSET"); // add the extra rewards if there are currently no stakers return _adminClaimable[_asset] + (_totalStaked == 0 && _isRewardAsset ? _rewardsUnlocked(_asset) : 0); } /** * @inheritdoc ISomaStaking */ function debt(address _account, address _asset) external view override returns (uint256) { _checkRewardToken(_asset); return SomaStakingLibrary.stakeToRewards(_users[_account].stake, currentTPS(_asset)); } /** * @inheritdoc ISomaStaking */ function claimable(address _account, address _asset) external view override returns (uint256) { UserInfo storage _user = _users[_account]; _checkRewardToken(_asset); return _user.claimable[_asset] + SomaStakingLibrary.stakeToRewards(_user.stake, currentTPS(_asset)) - _user.debt[_asset]; } /** * @inheritdoc ISomaStaking */ function claimRequest(address _account, address _asset, uint256 _id) external view override returns (Request memory) { _checkRewardToken(_asset); return _getRequest(_id, _asset, _account, RequestType.CLAIM); } /** * @inheritdoc ISomaStaking */ function stakeOf(address _account) external view override returns (uint256) { return _users[_account].stake; } /** * @inheritdoc ISomaStaking */ function unstakeRequest(address _account, uint256 _id) external view override returns (Request memory) { return _getRequest(_id, _stakingToken, _account, RequestType.UNSTAKE); } /** * @inheritdoc ISomaStaking */ function initialize(address stakingToken_, address[] memory rewardTokens_) external override initializer { LOCAL_ADMIN_ROLE = keccak256(abi.encodePacked(address(this), GLOBAL_ADMIN_ROLE)); LOCAL_SEIZE_ROLE = keccak256(abi.encodePacked(address(this), GLOBAL_SEIZE_ROLE)); require(Address.isContract(stakingToken_), "Staking: INVALID_STAKING_TOKEN"); _stakingToken = stakingToken_; _disableTokenRecovery(_stakingToken); for (uint256 i = 0; i < rewardTokens_.length; ++i) { address _token = rewardTokens_[i]; require(Address.isContract(_token), "Staking: INVALID_ASSET"); _rewardTokens.add(_token); _disableTokenRecovery(_token); } __Guardable__init(); __ReentrancyGuard_init_unchained(); __TokenRecovery__init_unchained(new address[](0)); } /** * @inheritdoc ISomaStaking */ function currentTPS(address token) public view override returns (uint256 tps_) { uint256 totalStaked_ = _totalStaked; uint256 extraTPS = (totalStaked_ > 0) ? SomaStakingLibrary.rewardsToTPS(_totalStaked, _rewardsUnlocked(token)) : 0; return _tps[token] + extraTPS; } /** * @inheritdoc ISomaStaking */ function createUnstakeRequest(uint256 _amount) external override nonReentrant onlyApprovedPrivileges(_msgSender()) returns (uint256 _id) { address _sender = _msgSender(); UserInfo storage _user = _users[_sender]; uint256 _userStake = _user.stake; uint256 totalStake_ = _totalStaked; require(_userStake >= _amount, "Staking: INSUFFICIENT_STAKE"); require(_amount > 0, "Staking: INVALID_AMOUNT"); _update(totalStake_); // remove this amount from the users stake amount, so that they no longer earn rewards on _syncUser(_user, _userStake, _userStake - _amount); // adjust the total stake balances unchecked { _totalStaked = totalStake_ - _amount; _totalPendingUnstake += _amount; } return _createRequest(_sender, _stakingToken, _amount, RequestType.UNSTAKE); } /** * @inheritdoc ISomaStaking */ function cancelUnstakeRequests(uint256[] calldata _ids) external override nonReentrant onlyApprovedPrivileges(_msgSender()) { address _sender = _msgSender(); UserInfo storage _user = _users[_sender]; uint256 _userStake = _user.stake; uint256 totalStake_ = _totalStaked; require(_ids.length > 0, "Staking: INVALID_IDS_LENGTH"); address stakingToken_ = _stakingToken; uint256 _totalAmount = _userStake; for (uint256 i = 0; i < _ids.length; ++i) { unchecked { _totalAmount += _cancelRequest(_ids[i], stakingToken_, _sender, RequestType.UNSTAKE); } } _update(totalStake_); _syncUser(_user, _userStake, _totalAmount); unchecked { _totalStaked = totalStake_ + _totalAmount; _totalPendingUnstake -= _totalAmount; } } /** * @inheritdoc ISomaStaking */ function createClaimRequests(address[] calldata _assets) external override nonReentrant onlyApprovedPrivileges(_msgSender()) returns (uint256[] memory _ids) { address _sender = _msgSender(); UserInfo storage _user = _users[_sender]; uint256 _userStake = _user.stake; _ids = new uint256[](_assets.length); _update(_totalStaked); _syncUser(_user, _userStake, _userStake); for (uint256 i = 0; i < _assets.length; ++i) { address _asset = _assets[i]; uint256 _rewards = _user.claimable[_asset]; _checkRewardToken(_asset); require(_rewards > 0, "Staking: NO_REWARDS"); delete _user.claimable[_asset]; _ids[i] = _createRequest(_sender, _asset, _rewards, RequestType.CLAIM); } } /** * @inheritdoc ISomaStaking */ function cancelClaimRequests(address[] calldata _assets, uint256[][] calldata _ids) external override nonReentrant onlyApprovedPrivileges(_msgSender()) { address _sender = _msgSender(); UserInfo storage _user = _users[_sender]; require(_assets.length > 0, "Staking: INVALID_ASSETS_LENGTH"); require(_ids.length == _assets.length, "Staking: INVALID_INPUT_LENGTHS"); for (uint256 i = 0; i < _assets.length; ++i) { uint256 _idsLength = _ids[i].length; address _asset = _assets[i]; uint256 _totalAmount; require(_idsLength > 0, "Staking: INVALID_IDS_LENGTH"); for (uint256 j = 0; j < _idsLength; ++j) { unchecked { _totalAmount += _cancelRequest(_ids[i][j], _asset, _sender, RequestType.CLAIM); } } unchecked { _user.claimable[_asset] += _totalAmount; } } } /** * @inheritdoc ISomaStaking */ function claim(address[] calldata _assets, uint256[][] calldata _ids) external override nonReentrant onlyApprovedPrivileges(_msgSender()) { address _sender = _msgSender(); uint256 _claimDuration = _config.claimDuration; require(_assets.length == _ids.length, "Staking: INVALID_INPUT_LENGTHS"); for (uint256 i = 0; i < _assets.length; ++i) { uint256 _idsLength = _ids[i].length; address _asset = _assets[i]; uint256 _totalAmount; require(_idsLength > 0, "Staking: INVALID_IDS_LENGTH"); for (uint256 j = 0; j < _idsLength; ++j) { unchecked { _totalAmount += _useRequest(_ids[i][j], _asset, _sender, _claimDuration, RequestType.CLAIM); } } IERC20(_asset).safeTransfer(_sender, _totalAmount); emit Claimed(_asset, _totalAmount, _sender); } } /** * @inheritdoc ISomaStaking */ function claimImmediate(address[] calldata _assets, uint256[] calldata _amounts) external override nonReentrant onlyApprovedPrivileges(_msgSender()) { require(_assets.length == _amounts.length, "Staking: INCONSISTENT_LENGTHS"); address _sender = _msgSender(); UserInfo storage _user = _users[_sender]; StakingConfig memory config_ = _config; uint256 _userStake = _user.stake; _update(_totalStaked); _syncUser(_user, _userStake, _userStake); for (uint256 i = 0; i < _assets.length; ++i) { _claimImmediate(_user, config_.earlyClaimFee, _sender, _assets[i], _amounts[i]); } } /** * @inheritdoc ISomaStaking */ function stake(uint256 _amount) external override nonReentrant onlyApprovedPrivileges(_msgSender()) { address _sender = _msgSender(); UserInfo storage _user = _users[_sender]; uint256 _userStake = _user.stake; uint256 totalStake_ = _totalStaked; // slither-disable-next-line reentrancy-no-eth _amount = SafeERC20Balance.safeTransferFrom(IERC20(_stakingToken), _sender, address(this), _amount); require(_amount > 0, "Staking: INVALID_AMOUNT"); _update(totalStake_); _syncUser(_user, _userStake, _userStake + _amount); _totalStaked = totalStake_ + _amount; emit Staked(_amount, _sender); } // TODO should this have an input amount? /** * @inheritdoc ISomaStaking */ function adminClaim(address _asset, address _to) external override onlyAdmin nonReentrant { _update(_totalStaked); uint256 _claimable = _adminClaimable[_asset]; require(_claimable > 0, "Staking: INSUFFICIENT_CLAIMABLE"); delete _adminClaimable[_asset]; IERC20(_asset).safeTransfer(_to, _claimable); emit AdminClaimed(_asset, _claimable, _to, _msgSender()); } /** * @inheritdoc ISomaStaking */ function unstake(uint256[] calldata _ids) external override nonReentrant onlyApprovedPrivileges(_msgSender()) { address _sender = _msgSender(); address stakingToken_ = _stakingToken; uint256 _unstakeDuration = _config.unstakeDuration; uint256 _totalAmount; for (uint256 i = 0; i < _ids.length; ++i) { unchecked { _totalAmount += _useRequest(_ids[i], stakingToken_, _sender, _unstakeDuration, RequestType.UNSTAKE); } } unchecked { _totalPendingUnstake -= _totalAmount; } IERC20(_stakingToken).safeTransfer(_sender, _totalAmount); emit Unstaked(_totalAmount, _sender); } /** * @inheritdoc ISomaStaking */ function unstakeImmediate(uint256 _amount) external override nonReentrant onlyApprovedPrivileges(_msgSender()) { address _sender = _msgSender(); UserInfo storage _user = _users[_sender]; uint256 _userStake = _user.stake; uint256 totalStake_ = _totalStaked; StakingConfig memory config_ = _config; require(_userStake >= _amount, "Staking: INSUFFICIENT_STAKE"); require(_amount > 0, "Staking: INVALID_AMOUNT"); _update(totalStake_); _syncUser(_user, _userStake, _userStake - _amount); unchecked { _totalStaked = totalStake_ - _amount; } uint256 _adminFee = _amount.applyPercent(config_.earlyUnstakeFee); address _asset = _stakingToken; _adminClaimable[_asset] += _adminFee; IERC20(_asset).safeTransfer(_sender, _amount - _adminFee); emit UnstakedImmediate(_amount, _adminFee, _msgSender()); } /** * @inheritdoc ISomaStaking */ function addRewardToken(address _asset) external override onlyAdmin nonReentrant { require(_rewardTokens.add(_asset), "Staking: REWARD_TOKEN_EXISTS"); require(_rewardTokens.length() <= 64, "Staking: LIMIT"); require(Address.isContract(_asset), "Staking: INVALID_ASSET"); _disableTokenRecovery(_asset); emit RewardTokenAdded(_asset, _msgSender()); } /** * @inheritdoc ISomaStaking */ function createStrategy(uint256 _startDate, uint256 _endDate, address _rewardToken, uint256 _rewardAmount) external override onlyAdmin nonReentrant { require(_rewardAmount <= type(uint128).max, "Staking: MAX_REWARD_AMOUNT"); require(_startDate > block.timestamp, "Staking: INVALID_START_DATE"); require(_startDate < _endDate, "Staking: INVALID_DATE_ORDER"); require(_endDate <= type(uint48).max, "Staking: INVALID_END_DATE"); _checkRewardToken(_rewardToken); // slither-disable-next-line reentrancy-no-eth _rewardAmount = SafeERC20Balance.safeTransferFrom(IERC20(_rewardToken), _msgSender(), address(this), _rewardAmount); require(_rewardAmount > 0, "Staking: INVALID_AMOUNT"); uint256 strategyId = _strategies.length; require(strategyId < 64, "Staking: LIMIT"); Strategy memory _strategy = Strategy({ startDate: uint48(_startDate), endDate: uint48(_endDate), rewardsLocked: _rewardAmount.toUint128(), rewardToken: _rewardToken, rewardsUnlocked: 0 }); _strategies.push(_strategy); _pendingStrategies.add(bytes32(strategyId), _startDate); emit StrategyCreated(_rewardToken, _strategy.rewardsLocked, _startDate, _endDate, _msgSender()); } /** * @inheritdoc ISomaStaking */ function updateConfig( uint64 _unstakeDuration, uint64 _claimDuration, uint16 _earlyUnstakeFee, uint16 _earlyClaimFee ) external override onlyAdmin { StakingConfig memory config_ = StakingConfig({ unstakeDuration: _unstakeDuration, claimDuration: _claimDuration, earlyUnstakeFee: _earlyUnstakeFee, earlyClaimFee: _earlyClaimFee }); emit StakingConfigUpdated(_config, config_, _msgSender()); _config = config_; } /** * @inheritdoc ISomaStaking */ function seize(address from) external override onlySeizeRole { address _seizeTo = SOMA.seizeTo(); uint256 totalStake_ = _totalStaked; UserInfo storage _user = _users[from]; uint256 _userStake = _user.stake; require(_userStake > 0, "Staking: INSUFFICIENT_STAKE"); _update(totalStake_); _syncUser(_user, _userStake, 0); unchecked { _totalStaked = totalStake_ - _userStake; } uint256 _totalTokens = _rewardTokens.length(); uint256[] memory _seizedRewards = new uint256[](_totalTokens); for (uint256 i = 0; i < _totalTokens; ++i) { address _rewardToken = _rewardTokens.at(i); uint256 _claimable = _user.claimable[_rewardToken]; _adminClaimable[_rewardToken] += _claimable; _seizedRewards[i] = _claimable; delete _user.claimable[_rewardToken]; } address _asset = _stakingToken; IERC20(_asset).safeTransfer(_seizeTo, _userStake); emit Seized(from, _seizeTo, _userStake, _seizedRewards, _msgSender()); } function _checkRewardToken(address _asset) private view { require(_rewardTokens.contains(_asset), "Staking: INVALID_ASSET"); } function _update(uint256 totalStake_) private { uint256 curId = _pendingStrategies.head(); uint256 nextId; bytes32 key; // slither-disable-next-line weak-prng uint48 curTimestamp = uint48(block.timestamp % type(uint48).max); while (curId != 0) { (key, nextId) = _pendingStrategies.get(curId); Strategy memory _strategy = _strategies[uint256(key)]; if (curTimestamp < _strategy.startDate) { break; } uint256 rewardsUnlocked = SomaStakingLibrary.rewardsUnlocked(_strategy, block.timestamp); // increment how many rewards have been released _strategies[uint256(key)].rewardsUnlocked = rewardsUnlocked.toUint128() + _strategy.rewardsUnlocked; // if there is nobody staking, lets go ahead and return the rewards earned to the admin if (totalStake_ > 0) { uint256 tps_ = SomaStakingLibrary.rewardsToTPS(totalStake_, rewardsUnlocked); if (tps_ == 0) { _adminClaimable[_strategy.rewardToken] += rewardsUnlocked; } else { _tps[_strategy.rewardToken] += tps_; } } else { _adminClaimable[_strategy.rewardToken] += rewardsUnlocked; } // if this strategy has been completed then let us remove it from the pending list if (curTimestamp >= _strategy.endDate) { _pendingStrategies.remove(curId); } // progress to the next item in the list curId = nextId; } } function _syncUser(UserInfo storage _user, uint256 _userStake, uint256 _newUserStake) private { if (_newUserStake != _userStake) _user.stake = _newUserStake; // sync the claimable and debt values uint256 _totalTokens = _rewardTokens.length(); for (uint256 i = 0; i < _totalTokens; ++i) { address _rewardToken = _rewardTokens.at(i); uint256 tps_ = _tps[_rewardToken]; _user.claimable[_rewardToken] += SomaStakingLibrary.stakeToRewards(_userStake, tps_) - _user.debt[_rewardToken]; _user.debt[_rewardToken] = SomaStakingLibrary.stakeToRewards(_newUserStake, tps_); } } function _claimImmediate( UserInfo storage _user, uint256 _earlyClaimFee, address _sender, address _asset, uint256 _amount ) private { _checkRewardToken(_asset); uint256 _claimable = _user.claimable[_asset]; require(_amount > 0, "Staking: INVALID_AMOUNT"); require(_claimable > 0, "Staking: NO_REWARDS"); require(_amount <= _claimable, "Staking: INSUFFICIENT_CLAIMABLE"); uint256 _adminFee = _amount.applyPercent(_earlyClaimFee); _adminClaimable[_asset] += _adminFee; IERC20(_asset).safeTransfer(_sender, _amount - _adminFee); unchecked { _user.claimable[_asset] = _claimable - _amount; } emit ClaimedImmediate(_asset, _amount, _adminFee, _sender); } function _rewardsUnlocked(address token) private view returns (uint256 _totalUnlocked) { bytes32 key; uint256 curId = _pendingStrategies.head(); while (curId != 0) { (key, curId) = _pendingStrategies.get(curId); Strategy memory _strategy = _strategies[uint256(key)]; if (block.timestamp < _strategy.startDate) break; if (_strategy.rewardToken == token) { _totalUnlocked += SomaStakingLibrary.rewardsUnlocked(_strategy, block.timestamp); } } } function _createRequest(address _sender, address _asset, uint256 _amount, RequestType _type) private returns (uint256 _id) { require(_amount > 0, "Staking: INVALID_AMOUNT"); _id = ++_currentRequestId; _requests[_id] = Request({ hash: bytes8(keccak256(abi.encodePacked(_id, _sender, _asset, _type))), timestamp: block.timestamp.toUint64(), amount: _amount.toUint128() }); emit RequestCreated(_id, _asset, _amount, _sender, _type); } function _getRequest(uint256 _id, address _asset, address _sender, RequestType _type) private view returns (Request memory _request) { bytes8 _hash = bytes8(keccak256(abi.encodePacked(_id, _sender, _asset, _type))); _request = _requests[_id]; require(_request.hash == _hash, "Staking: INVALID_REQUEST"); } function _useRequest(uint256 _id, address _asset, address _sender, uint256 _requiredDuration, RequestType _type) private returns (uint256 _amount) { Request memory _request = _getRequest(_id, _asset, _sender, _type); require(block.timestamp - _request.timestamp >= _requiredDuration, "Staking: INSUFFICIENT_TIME"); delete _requests[_id]; emit RequestFulfilled(_id); return _request.amount; } function _cancelRequest(uint256 _id, address _asset, address _sender, RequestType _type) private returns (uint256 _amount) { _amount = _getRequest(_id, _asset, _sender, _type).amount; delete _requests[_id]; emit RequestCancelled(_id); } }