diff --git a/lib/forge-std b/lib/forge-std index 8e40513d..27ba11c8 160000 --- a/lib/forge-std +++ b/lib/forge-std @@ -1 +1 @@ -Subproject commit 8e40513d678f392f398620b3ef2b418648b33e89 +Subproject commit 27ba11c86ac93d8d4a50437ae26621468fe63c20 diff --git a/src/token/Staking/StakingFacet.sol b/src/token/Staking/StakingFacet.sol new file mode 100644 index 00000000..0407db5d --- /dev/null +++ b/src/token/Staking/StakingFacet.sol @@ -0,0 +1,750 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.30; + +/** + * @dev Simplified ERC20 interface. + */ +interface IERC20 { + function totalSupply() external view returns (uint256); + + function balanceOf(address account) external view returns (uint256); + + function transfer(address to, uint256 amount) external returns (bool); + + function allowance(address owner, address spender) external view returns (uint256); + + function approve(address spender, uint256 amount) external returns (bool); + + function transferFrom(address from, address to, uint256 amount) external returns (bool); +} + +/** + * @dev Simplified ERC721 interface. + */ +interface IERC721 { + function ownerOf(uint256 tokenId) external view returns (address owner); + + function safeTransferFrom(address from, address to, uint256 tokenId) external; +} + +/** + * @dev Simplified ERC1155 interface. + */ +interface IERC1155 { + function balanceOf(address account, uint256 id) external view returns (uint256); + function safeTransferFrom(address from, address to, uint256 id, uint256 amount, bytes calldata data) external; +} + +/** + * @title ERC-721 Token Receiver Interface + * @notice Interface for contracts that want to handle safe transfers of ERC-721 tokens. + * @dev Contracts implementing this must return the selector to confirm token receipt. + */ +interface IERC721Receiver { + /** + * @notice Handles the receipt of an NFT. + * @param _operator The address which called `safeTransferFrom`. + * @param _from The previous owner of the token. + * @param _tokenId The NFT identifier being transferred. + * @param _data Additional data with no specified format. + * @return The selector to confirm the token transfer. + */ + function onERC721Received(address _operator, address _from, uint256 _tokenId, bytes calldata _data) + external + returns (bytes4); +} + +/** + * @title ERC-1155 Token Receiver Interface + * @notice Interface that must be implemented by smart contracts in order to receive ERC-1155 token transfers. + */ +interface IERC1155Receiver { + /** + * @notice Handles the receipt of a single ERC-1155 token type. + * @dev This function is called at the end of a `safeTransferFrom` after the balance has been updated. + * + * IMPORTANT: To accept the transfer, this must return + * `bytes4(keccak256("onERC1155Received(address,address,uint256,uint256,bytes)"))` + * (i.e. 0xf23a6e61, or its own function selector). + * + * @param _operator The address which initiated the transfer (i.e. msg.sender). + * @param _from The address which previously owned the token. + * @param _id The ID of the token being transferred. + * @param _value The amount of tokens being transferred. + * @param _data Additional data with no specified format. + * @return `bytes4(keccak256("onERC1155Received(address,address,uint256,uint256,bytes)"))` if transfer is allowed. + */ + function onERC1155Received(address _operator, address _from, uint256 _id, uint256 _value, bytes calldata _data) + external + returns (bytes4); + + /** + * @notice Handles the receipt of multiple ERC-1155 token types. + * @dev This function is called at the end of a `safeBatchTransferFrom` after the balances have been updated. + * + * IMPORTANT: To accept the transfer(s), this must return + * `bytes4(keccak256("onERC1155BatchReceived(address,address,uint256[],uint256[],bytes)"))` + * (i.e. 0xbc197c81, or its own function selector). + * + * @param _operator The address which initiated the batch transfer (i.e. msg.sender). + * @param _from The address which previously owned the token. + * @param _ids An array containing ids of each token being transferred (order and length must match _values array). + * @param _values An array containing amounts of each token being transferred (order and length must match _ids array). + * @param _data Additional data with no specified format. + * @return `bytes4(keccak256("onERC1155BatchReceived(address,address,uint256[],uint256[],bytes)"))` if transfer is allowed. + */ + function onERC1155BatchReceived( + address _operator, + address _from, + uint256[] calldata _ids, + uint256[] calldata _values, + bytes calldata _data + ) external returns (bytes4); +} + +/** + * @title Staking Facet + * @notice A complete, dependency-free staking facet for ERC-20, ERC-721, and ERC-1155 tokens. + * @dev Implements staking, unstaking, and reward claiming functionalities using diamond storage. + */ +contract StakingFacet { + /** + * @notice Thrown when attempting to stake an unsupported token type. + * @param tokenAddress The address of the unsupported token. + */ + error StakingUnsupportedToken(address tokenAddress); + + /** + * @notice Thrown when attempting to stake a zero amount. + */ + error StakingZeroStakeAmount(); + + /** + * @notice Thrown when attempting to stake an amount below the minimum stake amount. + * @param amount The attempted stake amount. + * @param minAmount The minimum required stake amount. + */ + error StakingAmountBelowMinimum(uint256 amount, uint256 minAmount); + + /** + * @notice Thrown when a token transfer fails. + */ + error StakingTransferFailed(); + + /** + * @notice Thrown when attempting to stake an amount above the maximum stake amount. + * @param amount The attempted stake amount. + * @param maxAmount The maximum allowed stake amount. + */ + error StakingAmountAboveMaximum(uint256 amount, uint256 maxAmount); + + /** + * @notice Thrown when there's no rewards to claim for the staked token. + * @param tokenAddress The address of the staked token. + * @param tokenId The ID of the staked token. + */ + error StakingNoRewardsToClaim(address tokenAddress, uint256 tokenId); + + /** + * @notice Thrown when attempting to unstake before the cooldown period has elapsed. + * @param stakedAt The timestamp when the tokens were staked. + * @param cooldownPeriod The required cooldown period in seconds. + * @param currentTime The current block timestamp. + */ + error StakingCooldownNotElapsed(uint256 stakedAt, uint256 cooldownPeriod, uint256 currentTime); + + /** + * @notice Thrown when owner is not the owner of the staked token. + * @param owner The address of the token owner. + * @param tokenAddress The address of the staked token. + * @param tokenId The ID of the staked token. + */ + error StakingNotTokenOwner(address owner, address tokenAddress, uint256 tokenId); + + /** + * @notice Thrown when an account has insufficient balance for a transfer or burn. + * @param _sender Address attempting the transfer. + * @param _balance Current balance of the sender. + * @param _needed Amount required to complete the operation. + */ + error StakingInsufficientBalance(address _sender, uint256 _balance, uint256 _needed); + + /** + * @notice Thrown when a spender tries to use more than the approved allowance. + * @param _spender Address attempting to spend. + * @param _allowance Current allowance for the spender. + * @param _needed Amount required to complete the operation. + */ + error StakingInsufficientAllowance(address _spender, uint256 _allowance, uint256 _needed); + + /** + * @notice Thrown when an overflow occurs during arithmetic operations. + */ + error StakingOverflow(); + + /** + * @notice Emitted when staking parameters are updated. + * @param baseAPR The base annual percentage rate for rewards. + * @param rewardDecayRate The decay rate for rewards over time. + * @param compoundFrequency The frequency at which rewards are compounded. + * @param rewardToken The address of the token used for rewards. + * @param cooldownPeriod The cooldown period before unstaking is allowed. + * @param minStakeAmount The minimum amount required to stake. + * @param maxStakeAmount The maximum amount allowed to stake. + */ + event StakingParametersUpdated( + uint256 baseAPR, + uint256 rewardDecayRate, + uint256 compoundFrequency, + address rewardToken, + uint256 cooldownPeriod, + uint256 minStakeAmount, + uint256 maxStakeAmount + ); + + /** + * @notice Emitted when supported token types are added. + */ + event SupportedTokenAdded(address indexed tokenAddress, bool isERC20, bool isERC721, bool isERC1155); + + /** + * @notice Emitted when tokens are successfully staked. + * @param staker The address of the user who staked the tokens. + * @param tokenAddress The address of the staked token. + * @param tokenId The ID of the staked token. + * @param amount The amount of tokens staked. + */ + event TokensStaked(address indexed staker, address indexed tokenAddress, uint256 indexed tokenId, uint256 amount); + + /** + * @notice Emitted when tokens are successfully unstaked. + * @param staker The address of the user who unstaked the tokens. + * @param tokenAddress The address of the unstaked token. + * @param tokenId The ID of the unstaked token. + * @param amount The amount of tokens unstaked. + */ + event TokensUnstaked(address indexed staker, address indexed tokenAddress, uint256 indexed tokenId, uint256 amount); + + /** + * @notice Emitted when rewards are claimed for staked tokens. + * @param staker The address of the user who claimed the rewards. + * @param tokenAddress The address of the staked token. + * @param tokenId The ID of the staked token. + * @param rewardAmount The amount of rewards claimed. + */ + event RewardsClaimed( + address indexed staker, address indexed tokenAddress, uint256 indexed tokenId, uint256 rewardAmount + ); + + bytes32 constant STAKING_STORAGE_POSITION = keccak256("compose.staking"); + + /** + * @notice Structure containing staking information for a specific token. + * @param amount The amount of tokens staked. + * @param stakedAt The timestamp when the tokens were staked. + * @param lastClaimedAt The timestamp when rewards were last claimed. + * @param accumulatedRewards The total accumulated rewards for the staked tokens. + */ + struct StakedTokenInfo { + uint256 amount; + uint256 stakedAt; + uint256 lastClaimedAt; + uint256 accumulatedRewards; + } + + /** + * @notice Structure containing type of tokens being staked. + * @param isERC20 Boolean indicating if the token is ERC-20. + * @param isERC721 Boolean indicating if the token is ERC-721. + * @param isERC1155 Boolean indicating if the token is ERC-1155. + */ + struct TokenType { + bool isERC20; + bool isERC721; + bool isERC1155; + } + + /** + * @custom:storage-location erc8042:compose.staking + */ + struct StakingStorage { + mapping(address tokenType => TokenType) supportedTokens; + mapping(address tokenOwner => mapping(address tokenAddress => mapping(uint256 tokenId => StakedTokenInfo))) + stakedTokens; + uint256 baseAPR; + uint256 rewardDecayRate; + uint256 compoundFrequency; + address rewardToken; + mapping(address tokenAddress => uint256 totalStaked) totalStakedPerToken; + uint256 cooldownPeriod; + uint256 maxStakeAmount; + uint256 minStakeAmount; + } + + /** + * @notice Returns the staking storage structure from its predefined slot. + * @dev Uses inline assembly to access diamond storage location. + * @return s The storage reference to StakingStorage. + */ + function getStorage() internal pure returns (StakingStorage storage s) { + bytes32 position = STAKING_STORAGE_POSITION; + assembly { + s.slot := position + } + } + + /** + * @notice An admin function to support a new token type for staking. + * @param _tokenAddress The address of the token to support. + * @param _isERC20 Boolean indicating if the token is ERC-20. + * @param _isERC721 Boolean indicating if the token is ERC-721. + * @param _isERC1155 Boolean indicating if the token is ERC-1155 + * @dev This function should be restricted to admin use only. + */ + function addSupportedToken(address _tokenAddress, bool _isERC20, bool _isERC721, bool _isERC1155) + external + returns (bool) + { + StakingStorage storage s = getStorage(); + s.supportedTokens[_tokenAddress] = TokenType({isERC20: _isERC20, isERC721: _isERC721, isERC1155: _isERC1155}); + emit SupportedTokenAdded(_tokenAddress, _isERC20, _isERC721, _isERC1155); + return true; + } + + /** + * @notice An admin function to set staking parameters. + * @param _baseAPR The base annual percentage rate for rewards. + * @param _rewardDecayRate The decay rate for rewards over time. + * @param _compoundFrequency The frequency at which rewards are compounded. + * @param _rewardToken The address of the token used for rewards. + * @param _cooldownPeriod The cooldown period before unstaking is allowed. + * @param _minStakeAmount The minimum amount required to stake. + * @param _maxStakeAmount The maximum amount allowed to stake. + * @dev This function should be restricted to admin use only. + */ + function setStakingParameters( + uint256 _baseAPR, + uint256 _rewardDecayRate, + uint256 _compoundFrequency, + address _rewardToken, + uint256 _cooldownPeriod, + uint256 _minStakeAmount, + uint256 _maxStakeAmount + ) external { + StakingStorage storage s = getStorage(); + + bool isSupported = isTokenSupported(_rewardToken); + if (!isSupported) { + revert StakingUnsupportedToken(_rewardToken); + } + + if (_minStakeAmount == 0 || _maxStakeAmount == 0) { + revert StakingZeroStakeAmount(); + } + + s.baseAPR = _baseAPR; + s.rewardDecayRate = _rewardDecayRate; + s.compoundFrequency = _compoundFrequency; + s.rewardToken = _rewardToken; + s.cooldownPeriod = _cooldownPeriod; + s.minStakeAmount = _minStakeAmount; + s.maxStakeAmount = _maxStakeAmount; + + emit StakingParametersUpdated( + _baseAPR, + _rewardDecayRate, + _compoundFrequency, + _rewardToken, + _cooldownPeriod, + _minStakeAmount, + _maxStakeAmount + ); + } + + /** + * @notice Stakes tokens of a supported type. + * @param _tokenAddress The address of the token to stake. + * @param _tokenId The ID of the token to stake (for ERC-721 and ERC-1155). + * @param _amount The amount of tokens to stake. + */ + function stakeToken(address _tokenAddress, uint256 _tokenId, uint256 _amount) external { + StakingStorage storage s = getStorage(); + TokenType storage tokenType = s.supportedTokens[_tokenAddress]; + + bool isSupported = isTokenSupported(_tokenAddress); + bool isTokenERC20 = s.supportedTokens[_tokenAddress].isERC20; + + if (!isSupported) { + revert StakingUnsupportedToken(_tokenAddress); + } + + if (s.minStakeAmount > 0) { + if (isTokenERC20 && _amount < s.minStakeAmount) { + revert StakingAmountBelowMinimum(_amount, s.minStakeAmount); + } + } + if (s.maxStakeAmount > 0) { + if (_amount + s.totalStakedPerToken[_tokenAddress] >= s.maxStakeAmount) { + revert StakingAmountAboveMaximum(_amount, s.maxStakeAmount); + } + } + + if (s.supportedTokens[_tokenAddress].isERC20) { + bool success = IERC20(_tokenAddress).transferFrom(msg.sender, address(this), _amount); + if (!success) { + revert StakingTransferFailed(); + } + stakeERC20(_tokenAddress, _amount); + } else if (s.supportedTokens[_tokenAddress].isERC721) { + if (IERC721(_tokenAddress).ownerOf(_tokenId) != msg.sender) { + revert StakingNotTokenOwner(msg.sender, _tokenAddress, _tokenId); + } + IERC721(_tokenAddress).safeTransferFrom(msg.sender, address(this), _tokenId); + stakeERC721(_tokenAddress, _tokenId); + } else if (s.supportedTokens[_tokenAddress].isERC1155) { + if (IERC1155(_tokenAddress).balanceOf(msg.sender, _tokenId) < _amount) { + revert StakingInsufficientBalance( + msg.sender, IERC1155(_tokenAddress).balanceOf(msg.sender, _tokenId), _amount + ); + } + IERC1155(_tokenAddress).safeTransferFrom(msg.sender, address(this), _tokenId, _amount, ""); + stakeERC1155(_tokenAddress, _tokenId, _amount); + } + + emit TokensStaked(msg.sender, _tokenAddress, _tokenId, _amount); + } + + /** + * @notice Unstakes tokens of a supported type. + * @param _tokenAddress The address of the token to unstake. + * @param _tokenId The ID of the token to unstake (for ERC-721 and ERC-1155). + */ + function unstakeToken(address _tokenAddress, uint256 _tokenId) external { + StakingStorage storage s = getStorage(); + StakedTokenInfo storage stake = s.stakedTokens[msg.sender][_tokenAddress][_tokenId]; + + uint256 amount = stake.amount; + if (amount == 0) { + revert StakingZeroStakeAmount(); + } + + if (s.cooldownPeriod > 0 && block.timestamp <= stake.stakedAt + s.cooldownPeriod) { + revert StakingCooldownNotElapsed(stake.stakedAt, s.cooldownPeriod, block.timestamp); + } + + uint256 rewards = calculateRewards(_tokenAddress, _tokenId); + if (rewards > 0) { + _claimRewards(_tokenAddress, _tokenId); + } + + if (s.supportedTokens[_tokenAddress].isERC20) { + IERC20(_tokenAddress).transfer(msg.sender, amount); + } else if (s.supportedTokens[_tokenAddress].isERC721) { + IERC721(_tokenAddress).safeTransferFrom(address(this), msg.sender, _tokenId); + } else if (s.supportedTokens[_tokenAddress].isERC1155) { + IERC1155(_tokenAddress).safeTransferFrom(address(this), msg.sender, _tokenId, amount, ""); + } + + s.totalStakedPerToken[_tokenAddress] -= amount; + + emit TokensUnstaked(msg.sender, _tokenAddress, _tokenId, amount); + + delete s.stakedTokens[msg.sender][_tokenAddress][_tokenId]; + } + + /** + * @notice Retrieve staking parameters + * @return baseAPR The base annual percentage rate for rewards. + * @return rewardDecayRate The decay rate for rewards over time. + * @return compoundFrequency The frequency at which rewards are compounded. + * @return rewardToken The address of the token used for rewards. + * @return cooldownPeriod The cooldown period before unstaking is allowed. + * @return minStakeAmount The minimum amount required to stake. + * @return maxStakeAmount The maximum amount allowed to stake. + */ + function getStakingParameters() + external + view + returns ( + uint256 baseAPR, + uint256 rewardDecayRate, + uint256 compoundFrequency, + address rewardToken, + uint256 cooldownPeriod, + uint256 minStakeAmount, + uint256 maxStakeAmount + ) + { + StakingStorage storage s = getStorage(); + return ( + s.baseAPR, + s.rewardDecayRate, + s.compoundFrequency, + s.rewardToken, + s.cooldownPeriod, + s.minStakeAmount, + s.maxStakeAmount + ); + } + + /** + * @notice Retrieve staked token info for ERC-20, ERC-721, or ERC-1155 tokens + * @param _tokenAddress The address of the token. + * @param _tokenId The ID of the token (0 for ERC-20). + * @return amount The amount of tokens staked. + * @return stakedAt The timestamp when the tokens were staked. + * @return lastClaimedAt The timestamp when rewards were last claimed. + * @return accumulatedRewards The total accumulated rewards for the staked tokens. + */ + function getStakedTokenInfo(address _tokenAddress, uint256 _tokenId) + external + view + returns (uint256 amount, uint256 stakedAt, uint256 lastClaimedAt, uint256 accumulatedRewards) + { + StakingStorage storage s = getStorage(); + StakedTokenInfo storage stake = s.stakedTokens[msg.sender][_tokenAddress][_tokenId]; + return (stake.amount, stake.stakedAt, stake.lastClaimedAt, stake.accumulatedRewards); + } + + /** + * @notice Claims rewards for a staked token. + * @dev Updates the last claimed timestamp and accumulated rewards. + * @param _tokenAddress The address of the staked token. + * @param _tokenId The ID of the staked token. + */ + function claimRewards(address _tokenAddress, uint256 _tokenId) external { + _claimRewards(_tokenAddress, _tokenId); + } + + /** + * @notice Stake ERC-20 tokens + * @dev Transfers token from the user and updates the amount staked and staking info. + * @param _tokenAddress The address of the ERC-20 token to stake. + * @param _value The amount of tokens to stake. + */ + function stakeERC20(address _tokenAddress, uint256 _value) internal { + StakingStorage storage s = getStorage(); + StakedTokenInfo storage stake = s.stakedTokens[msg.sender][_tokenAddress][0]; + + bool isSupported = isTokenSupported(_tokenAddress); + if (!isSupported) { + revert StakingUnsupportedToken(_tokenAddress); + } + + unchecked { + stake.amount += _value; + stake.stakedAt = block.timestamp; + stake.lastClaimedAt = block.timestamp; + + s.totalStakedPerToken[_tokenAddress] += _value; + } + } + + /** + * @notice Stake ERC-721 tokens + * @dev Transfers token from the user and updates the amount staked and staking info. + * @param _tokenAddress The address of the ERC-721 token to stake. + * @param _tokenId The ID of the token to stake. + */ + function stakeERC721(address _tokenAddress, uint256 _tokenId) internal { + StakingStorage storage s = getStorage(); + StakedTokenInfo storage stake = s.stakedTokens[msg.sender][_tokenAddress][_tokenId]; + + bool isSupported = isTokenSupported(_tokenAddress); + if (!isSupported) { + revert StakingUnsupportedToken(_tokenAddress); + } + + unchecked { + stake.amount = 1; + stake.stakedAt = block.timestamp; + stake.lastClaimedAt = block.timestamp; + + s.totalStakedPerToken[_tokenAddress] += 1; + } + } + + /** + * @notice Stake ERC-1155 tokens + * @dev Transfers token from the user and updates the amount staked and staking info. + * @param _tokenAddress The address of the ERC-1155 token to stake. + * @param _tokenId The ID of the token to stake. + * @param _value The amount of tokens to stake. + */ + function stakeERC1155(address _tokenAddress, uint256 _tokenId, uint256 _value) internal { + StakingStorage storage s = getStorage(); + StakedTokenInfo storage stake = s.stakedTokens[msg.sender][_tokenAddress][_tokenId]; + + bool isSupported = isTokenSupported(_tokenAddress); + if (!isSupported) { + revert StakingUnsupportedToken(_tokenAddress); + } + + unchecked { + stake.amount += _value; + stake.stakedAt = block.timestamp; + stake.lastClaimedAt = block.timestamp; + + s.totalStakedPerToken[_tokenAddress] += _value; + } + } + + /** + * @notice Calculates the rewards for a staked token. + * @dev Uses base APR, decay rate, and compounding frequency to compute rewards. + * @dev Rewards are calculated based on the time since last claim. + * @dev Uses fixed-point arithmetic with 1e18 precision. + * @param _tokenAddress The address of the staked token. + * @param _tokenId The ID of the staked token. + * @return finalReward The calculated reward amount. + */ + function calculateRewards(address _tokenAddress, uint256 _tokenId) internal view returns (uint256) { + StakingStorage storage s = getStorage(); + StakedTokenInfo storage stake = s.stakedTokens[msg.sender][_tokenAddress][_tokenId]; + + uint256 stakedDuration = block.timestamp - stake.lastClaimedAt; + if (stakedDuration == 0 || stake.amount == 0) { + return 0; + } + + uint256 baseReward = (stake.amount * s.baseAPR * stakedDuration) / (365 days * 100); + + uint256 decayFactor; + if (s.rewardDecayRate > 0 && s.compoundFrequency > 0) { + uint256 exponent = stakedDuration / s.compoundFrequency; + + /** + * Cap exponent to prevent overflow + * With max exponent of 125, even 2e18^125 is manageable with rpow + */ + if (exponent > 125) { + exponent = 125; + } + + decayFactor = rpow(s.rewardDecayRate, exponent); + } else { + decayFactor = 1e18; + } + + uint256 finalReward = (baseReward * decayFactor) / (10 ** 18); + + return finalReward; + } + + /** + * @notice Claimes rewards for a staked token. + * @dev Internal function to update staking info after rewards are claimed. + * @param _tokenAddress The address of the staked token. + * @param _tokenId The ID of the staked token. + */ + function _claimRewards(address _tokenAddress, uint256 _tokenId) internal { + StakingStorage storage s = getStorage(); + StakedTokenInfo storage stake = s.stakedTokens[msg.sender][_tokenAddress][_tokenId]; + + uint256 rewards = calculateRewards(_tokenAddress, _tokenId); + if (rewards == 0) { + revert StakingNoRewardsToClaim(_tokenAddress, _tokenId); + } + + bool success = IERC20(s.rewardToken).transfer(msg.sender, rewards); + if (!success) { + revert StakingTransferFailed(); + } + + stake.lastClaimedAt = block.timestamp; + stake.accumulatedRewards += rewards; + + emit RewardsClaimed(msg.sender, _tokenAddress, _tokenId, rewards); + } + + /** + * @notice Retrieve supported token types + * @param _tokenAddress The address of the token. + * @return true if the token is supported, false otherwise. + */ + function isTokenSupported(address _tokenAddress) internal view returns (bool) { + StakingStorage storage s = getStorage(); + TokenType storage tokenType = s.supportedTokens[_tokenAddress]; + return tokenType.isERC20 || tokenType.isERC721 || tokenType.isERC1155; + } + + /** + * @notice Raises a 1e18 fixed-point number to an integer power, with 1e18 precision. + * @dev Implements binary exponentiation. Handles underflow and overflow safely. + * @param _base 1e18-scaled fixed-point base (e.g. 0.99e18, 1e18, 1.01e18). + * @param _exp Integer exponent (e.g staked duration / compound frequency). + * @return result Fixed-point result of base^exp, scaled by 1e18. + */ + function rpow(uint256 _base, uint256 _exp) internal pure returns (uint256 result) { + result = 1e18; + uint256 base = _base; + + while (_exp > 0) { + if (_exp % 2 == 1) { + result = rmul(result, base); + } + base = rmul(base, base); + _exp /= 2; + } + + return result; + } + + /** + * @notice Multiplies two 1e18 fixed-point numbers, returning a 1e18 fixed-point result. + * @dev Equivalent to (x * y) / 1e18, rounded down. + */ + function rmul(uint256 x, uint256 y) internal pure returns (uint256 z) { + if (x == 0 || y == 0) { + return 0; + } + + /** + * Check for overflow in multiplication + */ + if (x > type(uint256).max / y) { + revert StakingOverflow(); + } + + unchecked { + z = (x * y) / 1e18; + } + + return z; + } + + /** + * @notice Support Interface to satisfy ERC-165 standard. + * @param interfaceId The interface identifier, as specified in ERC-165. + * @return True if the contract implements the requested interface. + */ + function supportsInterface(bytes4 interfaceId) external pure returns (bool) { + return interfaceId == type(IERC721Receiver).interfaceId || interfaceId == type(IERC1155Receiver).interfaceId; + } + + /** + * @notice Handle the receipt of an NFT + * @dev The ERC721 smart contract calls this on the recipient after a `safeTransfer`. + * @return The selector to confirm token transfer. If any other value is returned or the interface is not implemented by the recipient, the transfer will be reverted. + */ + function onERC721Received(address operator, address from, uint256 tokenId, bytes calldata data) + external + pure + returns (bytes4) + { + return IERC721Receiver.onERC721Received.selector; + } + + /** + * @notice Handle the receipt of a single ERC1155 token type + * @dev The ERC1155 smart contract calls this on the recipient after a `safeTransferFrom`. + * @return The selector to confirm token transfer. If any other value is returned or the interface is not implemented by the recipient, the transfer will be reverted. + */ + function onERC1155Received(address operator, address from, uint256 id, uint256 value, bytes calldata data) + external + pure + returns (bytes4) + { + return IERC1155Receiver.onERC1155Received.selector; + } +} diff --git a/src/token/Staking/StakingMod.sol b/src/token/Staking/StakingMod.sol new file mode 100644 index 00000000..66ee1726 --- /dev/null +++ b/src/token/Staking/StakingMod.sol @@ -0,0 +1,297 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.30; + +/** + * @title Staking Library for Compose + * @notice Provides internal logic for staking functionality using diamond storage. + * This library is intended to be used by custom facets to integrate with staking features. + * @dev Uses ERC-8042 for storage location standardization. + */ + +/** + * @notice Thrown when attempting to stake an unsupported token type. + * @param tokenAddress The address of the unsupported token. + */ +error StakingUnsupportedToken(address tokenAddress); + +/** + * @notice Thrown when attempting to stake a zero amount. + */ +error StakingZeroStakeAmount(); + +/** + * @notice Thrown when an overflow occurs during arithmetic operations. + */ +error StakingOverflow(); + +/** + * @notice Emitted when supported token types are added. + */ +event SupportedTokenAdded(address indexed tokenAddress, bool isERC20, bool isERC721, bool isERC1155); + +/** + * @dev Storage position constant defined via keccak256 hash of diamond storage identifier. + */ +bytes32 constant STAKING_STORAGE_POSITION = keccak256("compose.staking"); + +/** + * @notice Structure containing staking information for a specific token. + * @param amount The amount of tokens staked. + * @param stakedAt The timestamp when the tokens were staked. + * @param lastClaimedAt The timestamp when rewards were last claimed. + * @param accumulatedRewards The total accumulated rewards for the staked tokens. + */ +struct StakedTokenInfo { + uint256 amount; + uint256 stakedAt; + uint256 lastClaimedAt; + uint256 accumulatedRewards; +} + +/** + * @notice Structure containing type of tokens being staked. + * @param isERC20 Boolean indicating if the token is ERC-20. + * @param isERC721 Boolean indicating if the token is ERC-721. + * @param isERC1155 Boolean indicating if the token is ERC-1155. + */ +struct TokenType { + bool isERC20; + bool isERC721; + bool isERC1155; +} + +/** + * @custom:storage-location erc8042:compose.staking + */ +struct StakingStorage { + mapping(address tokenType => TokenType) supportedTokens; + mapping(address tokenOwner => mapping(address tokenAddress => mapping(uint256 tokenId => StakedTokenInfo))) + stakedTokens; + uint256 baseAPR; + uint256 rewardDecayRate; + uint256 compoundFrequency; + address rewardToken; + mapping(address tokenAddress => uint256 totalStaked) totalStakedPerToken; + uint256 cooldownPeriod; + uint256 maxStakeAmount; + uint256 minStakeAmount; +} + +/** + * @notice Returns the staking storage structure from its predefined slot. + * @dev Uses inline assembly to access diamond storage location. + * @return s The storage reference to StakingStorage. + */ +function getStorage() pure returns (StakingStorage storage s) { + bytes32 position = STAKING_STORAGE_POSITION; + assembly { + s.slot := position + } +} + +/** + * @notice An admin function to support a new token type for staking. + * @param _tokenAddress The address of the token to support. + * @param _isERC20 Boolean indicating if the token is ERC-20. + * @param _isERC721 Boolean indicating if the token is ERC-721. + * @param _isERC1155 Boolean indicating if the token is ERC-1155 + * @dev This function should be restricted to admin use only. + * @dev Emits a SupportedTokenAdded event upon successful addition. + */ +function addSupportedToken(address _tokenAddress, bool _isERC20, bool _isERC721, bool _isERC1155) returns (bool) { + StakingStorage storage s = getStorage(); + s.supportedTokens[_tokenAddress] = TokenType({isERC20: _isERC20, isERC721: _isERC721, isERC1155: _isERC1155}); + + emit SupportedTokenAdded(_tokenAddress, _isERC20, _isERC721, _isERC1155); + + return true; +} + +/** + * @notice Stake ERC-20 tokens + * @dev Transfers token from the user and updates the amount staked and staking info. + * @param _tokenAddress The address of the ERC-20 token to stake. + * @param _value The amount of tokens to stake. + */ +function stakeERC20(address _tokenAddress, uint256 _value) { + StakingStorage storage s = getStorage(); + StakedTokenInfo storage stake = s.stakedTokens[msg.sender][_tokenAddress][0]; + + bool isSupported = isTokenSupported(_tokenAddress); + if (!isSupported) { + revert StakingUnsupportedToken(_tokenAddress); + } + + unchecked { + stake.amount += _value; + stake.stakedAt = block.timestamp; + stake.lastClaimedAt = block.timestamp; + + s.totalStakedPerToken[_tokenAddress] += _value; + } +} + +/** + * @notice Stake ERC-721 tokens + * @dev Transfers token from the user and updates the amount staked and staking info. + * @param _tokenAddress The address of the ERC-721 token to stake. + * @param _tokenId The ID of the token to stake. + */ +function stakeERC721(address _tokenAddress, uint256 _tokenId) { + StakingStorage storage s = getStorage(); + StakedTokenInfo storage stake = s.stakedTokens[msg.sender][_tokenAddress][_tokenId]; + + bool isSupported = isTokenSupported(_tokenAddress); + if (!isSupported) { + revert StakingUnsupportedToken(_tokenAddress); + } + + unchecked { + stake.amount = 1; + stake.stakedAt = block.timestamp; + stake.lastClaimedAt = block.timestamp; + + s.totalStakedPerToken[_tokenAddress] += 1; + } +} + +/** + * @notice Stake ERC-1155 tokens + * @dev Transfers token from the user and updates the amount staked and staking info. + * @param _tokenAddress The address of the ERC-1155 token to stake. + * @param _tokenId The ID of the token to stake. + * @param _value The amount of tokens to stake. + */ +function stakeERC1155(address _tokenAddress, uint256 _tokenId, uint256 _value) { + StakingStorage storage s = getStorage(); + StakedTokenInfo storage stake = s.stakedTokens[msg.sender][_tokenAddress][_tokenId]; + + bool isSupported = isTokenSupported(_tokenAddress); + if (!isSupported) { + revert StakingUnsupportedToken(_tokenAddress); + } + + unchecked { + stake.amount += _value; + stake.stakedAt = block.timestamp; + stake.lastClaimedAt = block.timestamp; + + s.totalStakedPerToken[_tokenAddress] += _value; + } +} + +/** + * @notice Calculates the rewards for a staked token. + * @dev Uses base APR, decay rate, and compounding frequency to compute rewards. + * @dev Rewards are calculated based on the time since last claim. + * @dev Uses fixed-point arithmetic with 1e18 precision. + * @param _tokenAddress The address of the staked token. + * @param _tokenId The ID of the staked token. + * @return finalReward The calculated reward amount. + */ +function calculateRewards(address _tokenAddress, uint256 _tokenId) view returns (uint256) { + StakingStorage storage s = getStorage(); + StakedTokenInfo storage stake = s.stakedTokens[msg.sender][_tokenAddress][_tokenId]; + + uint256 stakedDuration = block.timestamp - stake.lastClaimedAt; + if (stakedDuration == 0 || stake.amount == 0) { + return 0; + } + + uint256 baseReward = (stake.amount * s.baseAPR * stakedDuration) / (365 days * 100); + + uint256 decayFactor; + if (s.rewardDecayRate > 0 && s.compoundFrequency > 0) { + uint256 exponent = stakedDuration / s.compoundFrequency; + + /** + * Cap exponent to prevent overflow + * With max exponent of 125, even 2e18^125 is manageable with rpow + */ + if (exponent > 125) { + exponent = 125; + } + + decayFactor = rpow(s.rewardDecayRate, exponent); + } else { + decayFactor = 1e18; + } + + uint256 finalReward = (baseReward * decayFactor) / (10 ** 18); + + return finalReward; +} + +/** + * @notice Retrieve staked token info for ERC-20, ERC-721, or ERC-1155 tokens + * @param _tokenAddress The address of the token. + * @param _tokenId The ID of the token (0 for ERC-20). + * @return amount The amount of tokens staked. + * @return stakedAt The timestamp when the tokens were staked. + * @return lastClaimedAt The timestamp when rewards were last claimed. + * @return accumulatedRewards The total accumulated rewards for the staked tokens. + */ +function getStakedTokenInfo(address _tokenAddress, uint256 _tokenId) + view + returns (uint256 amount, uint256 stakedAt, uint256 lastClaimedAt, uint256 accumulatedRewards) +{ + StakingStorage storage s = getStorage(); + StakedTokenInfo storage stake = s.stakedTokens[msg.sender][_tokenAddress][_tokenId]; + return (stake.amount, stake.stakedAt, stake.lastClaimedAt, stake.accumulatedRewards); +} + +/** + * @notice Retrieve supported token types + * @param _tokenAddress The address of the token. + * @return true if the token is supported, false otherwise. + */ +function isTokenSupported(address _tokenAddress) view returns (bool) { + StakingStorage storage s = getStorage(); + TokenType storage tokenType = s.supportedTokens[_tokenAddress]; + return tokenType.isERC20 || tokenType.isERC721 || tokenType.isERC1155; +} + +/** + * @notice Raises a 1e18 fixed-point number to an integer power, with 1e18 precision. + * @dev Implements binary exponentiation. Handles underflow and overflow safely. + * @param _base 1e18-scaled fixed-point base (e.g. 0.99e18, 1e18, 1.01e18). + * @param _exp Integer exponent (e.g staked duration / compound frequency). + * @return result Fixed-point result of base^exp, scaled by 1e18. + */ +function rpow(uint256 _base, uint256 _exp) pure returns (uint256 result) { + result = 1e18; // Initialize result as 1 in 1e18 fixed-point + uint256 base = _base; + + while (_exp > 0) { + if (_exp % 2 == 1) { + result = rmul(result, base); + } + base = rmul(base, base); + _exp /= 2; + } + + return result; +} + +/** + * @notice Multiplies two 1e18 fixed-point numbers, returning a 1e18 fixed-point result. + * @dev Equivalent to (x * y) / 1e18, rounded down. + */ +function rmul(uint256 x, uint256 y) pure returns (uint256 z) { + if (x == 0 || y == 0) { + return 0; + } + + /** + * Check for overflow in multiplication + */ + if (x > type(uint256).max / y) { + revert StakingOverflow(); + } + + unchecked { + z = (x * y) / 1e18; + } + + return z; +} diff --git a/test/token/Staking/Staking.t.sol b/test/token/Staking/Staking.t.sol new file mode 100644 index 00000000..73f6517e --- /dev/null +++ b/test/token/Staking/Staking.t.sol @@ -0,0 +1,195 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.30; + +import {Test} from "forge-std/Test.sol"; +import {LibStakingHarness} from "./harnesses/LibStakingHarness.sol"; +import {ERC20FacetHarness} from "../ERC20/ERC20/harnesses/ERC20FacetHarness.sol"; +import {ERC721FacetHarness} from "../ERC721/ERC721/harnesses/ERC721FacetHarness.sol"; +import {ERC1155FacetHarness} from "../ERC1155/ERC1155/harnesses/ERC1155FacetHarness.sol"; +import "../../../src/token/Staking/StakingMod.sol" as StakingMod; + +contract StakingTest is Test { + ERC20FacetHarness erc20Token; + ERC20FacetHarness rewardToken; + ERC721FacetHarness erc721Token; + ERC1155FacetHarness erc1155Token; + LibStakingHarness staking; + + address public alice; + address public bob; + + string constant ASSET_NAME = "Test Token"; + string constant ASSET_SYMBOL = "TEST"; + uint8 constant ASSET_DECIMALS = 18; + + string constant TOKEN_NAME = "Test Token"; + string constant TOKEN_SYMBOL = "TEST"; + string constant BASE_URI = "https://example.com/api/nft/"; + + string constant DEFAULT_URI = "https://token.uri/{id}.json"; + + uint256 constant TOKEN_ID_1 = 1; + uint256 constant TOKEN_ID_2 = 2; + + uint256 constant BASE_APR = 500; + uint256 constant REWARD_DECAY_RATE = 50; + uint256 constant COMPOUND_FREQUENCY = 1 days; + uint256 constant COOLDOWN_PERIOD = 7 days; + uint256 constant MIN_STAKE_AMOUNT = 1 ether; + uint256 constant MAX_STAKE_AMOUNT = 1000 ether; + + event StakingParametersUpdated( + uint256 baseAPR, + uint256 rewardDecayRate, + uint256 compoundFrequency, + address rewardToken, + uint256 cooldownPeriod, + uint256 minStakeAmount, + uint256 maxStakeAmount + ); + + event SupportedTokenAdded(address indexed tokenAddress, bool isERC20, bool isERC721, bool isERC1155); + + function setUp() public { + alice = makeAddr("alice"); + bob = makeAddr("bob"); + + /** + * Deploy and initialize the reward ERC-20 token + */ + rewardToken = new ERC20FacetHarness(); + rewardToken.initialize("Reward Token", "RWD", 18); + + /** + * Deploy and initialize the ERC-20 token for staking tests + */ + erc20Token = new ERC20FacetHarness(); + erc20Token.initialize(ASSET_NAME, ASSET_SYMBOL, ASSET_DECIMALS); + + /** + * Deploy and initialize the ERC-721 token for staking tests + */ + erc721Token = new ERC721FacetHarness(); + erc721Token.initialize(TOKEN_NAME, TOKEN_SYMBOL, BASE_URI); + + /** + * Deploy and initialize the ERC-1155 token for staking tests + */ + erc1155Token = new ERC1155FacetHarness(); + erc1155Token.initialize(DEFAULT_URI); + + /** + * Deploy and initialize the staking harness + */ + staking = new LibStakingHarness(); + + /** + * Register supported tokens + */ + staking.addSupportedToken(address(erc20Token), true, false, false); + staking.addSupportedToken(address(erc721Token), false, true, false); + staking.addSupportedToken(address(erc1155Token), false, false, true); + staking.addSupportedToken(address(rewardToken), true, false, false); + + /** + * Mint tokens to Alice and Bob for testing + */ + erc20Token.mint(alice, 1_000 ether); + erc20Token.mint(bob, 1_000 ether); + erc1155Token.mint(bob, TOKEN_ID_2, 100); + erc1155Token.mint(alice, TOKEN_ID_1, 100); + erc721Token.mint(bob, TOKEN_ID_2); + erc721Token.mint(alice, TOKEN_ID_1); + rewardToken.mint(address(staking), 10_000 ether); + } + + function test_VerifySupportedTokens() public { + bool isERC20Supported = staking.isTokenSupported(address(erc20Token)); + bool isERC721Supported = staking.isTokenSupported(address(erc721Token)); + bool isERC1155Supported = staking.isTokenSupported(address(erc1155Token)); + bool isRewardTokenSupported = staking.isTokenSupported(address(rewardToken)); + + assertTrue(isERC20Supported); + assertTrue(isERC721Supported); + assertTrue(isERC1155Supported); + assertTrue(isRewardTokenSupported); + + address unsupportedToken = makeAddr("unsupportedToken"); + bool isUnsupportedTokenSupported = staking.isTokenSupported(unsupportedToken); + assertFalse(isUnsupportedTokenSupported); + } + + function test_StakeERC20UpdatesState() public { + uint256 stakeAmount = 100 ether; + vm.startPrank(alice); + + erc20Token.approve(address(staking), stakeAmount); + + staking.stakeERC20(address(erc20Token), stakeAmount); + + (uint256 amount,,,) = staking.getStakedTokenInfo(address(erc20Token), 0); + + assertEq(amount, stakeAmount); + vm.stopPrank(); + } + + function test_StakeERC721UpdatesState() public { + vm.startPrank(alice); + + staking.stakeERC721(address(erc721Token), TOKEN_ID_1); + + (uint256 amount,,,) = staking.getStakedTokenInfo(address(erc721Token), TOKEN_ID_1); + + assertEq(amount, 1); + vm.stopPrank(); + } + + function test_StakeERC1155UpdatesState() public { + uint256 stakeAmount = 10; + vm.startPrank(bob); + + erc1155Token.setApprovalForAll(address(staking), true); + + staking.stakeERC1155(address(erc1155Token), TOKEN_ID_2, stakeAmount); + + (uint256 amount,,,) = staking.getStakedTokenInfo(address(erc1155Token), TOKEN_ID_2); + + assertEq(amount, stakeAmount); + vm.stopPrank(); + } + + function test_StakeTokens_RevertWithUnsupportedToken() public { + address unsupportedToken = makeAddr("unsupportedToken"); + + vm.expectRevert(abi.encodeWithSelector(StakingMod.StakingUnsupportedToken.selector, unsupportedToken)); + staking.stakeERC20(unsupportedToken, 100 ether); + } + + function test_FixedPoint_IntegerPower() public { + uint256 result = staking.rPow(2e18, 3); + assertEq(result, 8e18); + + result = staking.rPow(5e18, 0); + assertEq(result, 1e18); + + result = staking.rPow(1e18, 10); + assertEq(result, 1e18); + + result = staking.rPow(3e18, 4); + assertEq(result, 81e18); + } + + function test_FixedPoint_Multiply() public { + uint256 result = staking.rMul(2e18, 3e18); + assertEq(result, 6e18); + + result = staking.rMul(5e18, 0e18); + assertEq(result, 0); + + result = staking.rMul(1e18, 10e18); + assertEq(result, 10e18); + + result = staking.rMul(3e18, 4e18); + assertEq(result, 12e18); + } +} diff --git a/test/token/Staking/StakingFacet.t.sol b/test/token/Staking/StakingFacet.t.sol new file mode 100644 index 00000000..dd7d39e2 --- /dev/null +++ b/test/token/Staking/StakingFacet.t.sol @@ -0,0 +1,572 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.30; + +import {Test} from "forge-std/Test.sol"; +import {StakingFacetHarness} from "./harnesses/StakingFacetHarness.sol"; +import {StakingFacet} from "../../../src/token/Staking/StakingFacet.sol"; +import {ERC20FacetHarness} from "../ERC20/ERC20/harnesses/ERC20FacetHarness.sol"; +import {ERC721FacetHarness} from "../ERC721/ERC721/harnesses/ERC721FacetHarness.sol"; +import {ERC1155FacetHarness} from "../ERC1155/ERC1155/harnesses/ERC1155FacetHarness.sol"; +import {IERC721Receiver} from "../../../src/interfaces/IERC721Receiver.sol"; +import {IERC1155Receiver} from "../../../src/interfaces/IERC1155Receiver.sol"; + +contract StakingFacetTest is Test { + StakingFacetHarness public facet; + ERC20FacetHarness erc20Token; + ERC20FacetHarness rewardToken; + ERC721FacetHarness erc721Token; + ERC1155FacetHarness erc1155Token; + + address public alice; + address public bob; + address public owner; + + string constant ASSET_NAME = "Test Token"; + string constant ASSET_SYMBOL = "TEST"; + uint8 constant ASSET_DECIMALS = 18; + + string constant TOKEN_NAME = "Test Token"; + string constant TOKEN_SYMBOL = "TEST"; + string constant BASE_URI = "https://example.com/api/nft/"; + + string constant DEFAULT_URI = "https://token.uri/{id}.json"; + + uint256 constant TOKEN_ID_1 = 1; + uint256 constant TOKEN_ID_2 = 2; + + uint256 constant BASE_APR = 10; + uint256 constant REWARD_DECAY_RATE = 0; + uint256 constant COMPOUND_FREQUENCY = 365 days; + uint256 constant COOLDOWN_PERIOD = 1 days; + uint256 constant MIN_STAKE_AMOUNT = 1 ether; + uint256 constant MAX_STAKE_AMOUNT = 1_000_000 ether; + + event TokensStaked(address indexed staker, address indexed tokenAddress, uint256 indexed tokenId, uint256 amount); + event TokensUnstaked(address indexed staker, address indexed tokenAddress, uint256 indexed tokenId, uint256 amount); + event StakingParametersUpdated( + uint256 baseAPR, + uint256 rewardDecayRate, + uint256 compoundFrequency, + address rewardToken, + uint256 cooldownPeriod, + uint256 minStakeAmount, + uint256 maxStakeAmount + ); + + function setUp() public { + alice = makeAddr("alice"); + bob = makeAddr("bob"); + owner = makeAddr("owner"); + + vm.startPrank(owner); + + /** + * Deploy and initialize the reward ERC-20 token + */ + rewardToken = new ERC20FacetHarness(); + rewardToken.initialize("Reward Token", "RWD", 18); + + /** + * Deploy and initialize the ERC-20 token for staking tests + */ + erc20Token = new ERC20FacetHarness(); + erc20Token.initialize(ASSET_NAME, ASSET_SYMBOL, ASSET_DECIMALS); + + /** + * Deploy and initialize the ERC-721 token for staking tests + */ + erc721Token = new ERC721FacetHarness(); + erc721Token.initialize(TOKEN_NAME, TOKEN_SYMBOL, BASE_URI); + + /** + * Deploy and initialize the ERC-1155 token for staking tests + */ + erc1155Token = new ERC1155FacetHarness(); + erc1155Token.initialize(DEFAULT_URI); + + /** + * Deploy and initialize the staking harness + */ + facet = new StakingFacetHarness(); + + /** + * Initialize staking parameters + */ + facet.initialize( + BASE_APR, + REWARD_DECAY_RATE, + COMPOUND_FREQUENCY, + address(rewardToken), + COOLDOWN_PERIOD, + MIN_STAKE_AMOUNT, + MAX_STAKE_AMOUNT + ); + + /** + * Register supported tokens + */ + facet.addSupportedToken(address(erc20Token), true, false, false); + facet.addSupportedToken(address(erc721Token), false, true, false); + facet.addSupportedToken(address(erc1155Token), false, false, true); + + /** + * Mint tokens to Alice and Bob for testing + */ + erc20Token.mint(alice, 1000 ether); + erc20Token.mint(bob, 1000 ether); + erc721Token.mint(alice, TOKEN_ID_1); + erc721Token.mint(bob, TOKEN_ID_2); + erc1155Token.mint(alice, TOKEN_ID_1, 10); + erc1155Token.mint(bob, TOKEN_ID_2, 10); + rewardToken.mint(address(facet), 1_000_000 ether); + + vm.stopPrank(); + } + + function test_ParametersAreSetCorrectly() public { + ( + uint256 baseAPR, + uint256 rewardDecayRate, + uint256 compoundFrequency, + address rewardTokenAddress, + uint256 cooldownPeriod, + uint256 minStakeAmount, + uint256 maxStakeAmount + ) = facet.getStakingParameters(); + + assertEq(baseAPR, BASE_APR); + assertEq(rewardDecayRate, REWARD_DECAY_RATE); + assertEq(compoundFrequency, COMPOUND_FREQUENCY); + assertEq(rewardTokenAddress, address(rewardToken)); + assertEq(cooldownPeriod, COOLDOWN_PERIOD); + assertEq(minStakeAmount, MIN_STAKE_AMOUNT); + assertEq(maxStakeAmount, MAX_STAKE_AMOUNT); + } + + function test_ParametersAreSetCorrectly_EmitEventStakingParametersUpdated() public { + vm.startPrank(owner); + + facet.addSupportedToken(address(rewardToken), true, false, false); + + vm.expectEmit(true, true, true, true); + emit StakingParametersUpdated( + BASE_APR, + REWARD_DECAY_RATE, + COMPOUND_FREQUENCY, + address(rewardToken), + COOLDOWN_PERIOD, + MIN_STAKE_AMOUNT, + MAX_STAKE_AMOUNT + ); + + facet.setStakingParameters( + BASE_APR, + REWARD_DECAY_RATE, + COMPOUND_FREQUENCY, + address(rewardToken), + COOLDOWN_PERIOD, + MIN_STAKE_AMOUNT, + MAX_STAKE_AMOUNT + ); + + vm.stopPrank(); + } + + function test_StakeERC20Token() public { + vm.startPrank(alice); + + erc20Token.approve(address(facet), 500 ether); + + facet.stakeToken(address(erc20Token), 0, 500 ether); + + (uint256 amount, uint256 stakedAt, uint256 lastClaimedAt, uint256 accumulatedRewards) = + facet.getStakedTokenInfo(address(erc20Token), 0); + + assertEq(amount, 500 ether); + assertGt(stakedAt, 0); + assertGt(lastClaimedAt, 0); + assertEq(accumulatedRewards, 0); + + vm.stopPrank(); + } + + function test_StakeERC721Token() public { + vm.startPrank(bob); + + erc721Token.approve(address(facet), TOKEN_ID_2); + + facet.stakeToken(address(erc721Token), TOKEN_ID_2, 1); + + (uint256 amount, uint256 stakedAt, uint256 lastClaimedAt, uint256 accumulatedRewards) = + facet.getStakedTokenInfo(address(erc721Token), TOKEN_ID_2); + + assertEq(amount, 1); + assertGt(stakedAt, 0); + assertGt(lastClaimedAt, 0); + assertEq(accumulatedRewards, 0); + + vm.stopPrank(); + } + + function test_StakeERC1155Token() public { + vm.startPrank(alice); + + erc1155Token.setApprovalForAll(address(facet), true); + + facet.stakeToken(address(erc1155Token), TOKEN_ID_1, 5); + + (uint256 amount, uint256 stakedAt, uint256 lastClaimedAt, uint256 accumulatedRewards) = + facet.getStakedTokenInfo(address(erc1155Token), TOKEN_ID_1); + + assertEq(amount, 5); + assertGt(stakedAt, 0); + assertGt(lastClaimedAt, 0); + assertEq(accumulatedRewards, 0); + + vm.stopPrank(); + } + + function test_StakeToken_EmitTokensStakedEvent() public { + vm.startPrank(alice); + + erc20Token.approve(address(facet), 500 ether); + + vm.expectEmit(true, true, true, true); + emit TokensStaked(alice, address(erc20Token), 0, 500 ether); + + facet.stakeToken(address(erc20Token), 0, 500 ether); + + vm.stopPrank(); + } + + function test_StakeToken_RevertsForUnsupportedToken() public { + address unsupportedToken = makeAddr("unsupportedToken"); + + vm.startPrank(alice); + + vm.expectRevert(abi.encodeWithSelector(StakingFacet.StakingUnsupportedToken.selector, unsupportedToken)); + facet.stakeToken(unsupportedToken, 0, 100 ether); + + vm.stopPrank(); + } + + function test_StakeToken_RevertsForMinimumAmount() public { + vm.startPrank(alice); + + vm.expectRevert( + abi.encodeWithSelector(StakingFacet.StakingAmountBelowMinimum.selector, 0 ether, MIN_STAKE_AMOUNT) + ); + facet.stakeToken(address(erc20Token), 0, 0 ether); + + vm.stopPrank(); + } + + function test_StakeToken_RevertsForMaximumAmount() public { + vm.startPrank(alice); + + vm.expectRevert( + abi.encodeWithSelector(StakingFacet.StakingAmountAboveMaximum.selector, 2_000_000 ether, MAX_STAKE_AMOUNT) + ); + facet.stakeToken(address(erc20Token), 0, 2_000_000 ether); + + vm.stopPrank(); + } + + function test_StakeERC721Token_RevertsIfNotOwner() public { + vm.startPrank(bob); + + vm.expectRevert( + abi.encodeWithSelector(StakingFacet.StakingNotTokenOwner.selector, bob, address(erc721Token), TOKEN_ID_1) + ); + facet.stakeToken(address(erc721Token), TOKEN_ID_1, 1); + + vm.stopPrank(); + } + + function test_StakeERC1155Token_RevertsIfNotEnoughBalance() public { + vm.startPrank(bob); + + vm.expectRevert(abi.encodeWithSelector(StakingFacet.StakingInsufficientBalance.selector, bob, 10, 20)); + facet.stakeToken(address(erc1155Token), TOKEN_ID_2, 20); + + vm.stopPrank(); + } + + function test_UnstakeERC20Token() public { + vm.startPrank(alice); + + erc20Token.approve(address(facet), 500 ether); + facet.stakeToken(address(erc20Token), 0, 500 ether); + + vm.warp(block.timestamp + COOLDOWN_PERIOD + 1); + + facet.unstakeToken(address(erc20Token), 0); + + (uint256 amount,,,) = facet.getStakedTokenInfo(address(erc20Token), 0); + assertEq(amount, 0); + + vm.stopPrank(); + } + + function test_UnstakeERC721Token() public { + vm.startPrank(bob); + + erc721Token.approve(address(facet), TOKEN_ID_2); + facet.stakeToken(address(erc721Token), TOKEN_ID_2, 1); + + vm.warp(block.timestamp + COOLDOWN_PERIOD + 1); + + facet.unstakeToken(address(erc721Token), TOKEN_ID_2); + + (uint256 amount,,,) = facet.getStakedTokenInfo(address(erc721Token), TOKEN_ID_2); + assertEq(amount, 0); + + vm.stopPrank(); + } + + function test_UnstakeERC1155Token() public { + vm.startPrank(alice); + + erc1155Token.setApprovalForAll(address(facet), true); + facet.stakeToken(address(erc1155Token), TOKEN_ID_1, 5); + + vm.warp(block.timestamp + COOLDOWN_PERIOD + 1); + + facet.unstakeToken(address(erc1155Token), TOKEN_ID_1); + + (uint256 amount,,,) = facet.getStakedTokenInfo(address(erc1155Token), TOKEN_ID_1); + assertEq(amount, 0); + + vm.stopPrank(); + } + + function test_UnstakeToken_EmitTokensUnstakedEvent() public { + vm.startPrank(alice); + + erc20Token.approve(address(facet), 500 ether); + facet.stakeToken(address(erc20Token), 0, 500 ether); + + vm.warp(block.timestamp + COOLDOWN_PERIOD + 1); + + vm.expectEmit(true, true, true, true); + emit TokensUnstaked(alice, address(erc20Token), 0, 500 ether); + + facet.unstakeToken(address(erc20Token), 0); + + vm.stopPrank(); + } + + function test_Unstake_RevertsBeforeCooldown() public { + vm.startPrank(alice); + + erc20Token.approve(address(facet), 1000 ether); + facet.stakeToken(address(erc20Token), 0, 1000 ether); + + vm.warp(block.timestamp + COOLDOWN_PERIOD - 1); + + (uint256 amount, uint256 stakedAt, uint256 lastClaimedAt, uint256 accumulatedRewards) = + facet.getStakedTokenInfo(address(erc20Token), 0); + + vm.expectRevert( + abi.encodeWithSelector( + StakingFacet.StakingCooldownNotElapsed.selector, stakedAt, COOLDOWN_PERIOD, block.timestamp + ) + ); + facet.unstakeToken(address(erc20Token), 0); + + vm.stopPrank(); + } + + function test_Unstake_RevertsZeroStakeAmount() public { + vm.startPrank(alice); + + vm.expectRevert(abi.encodeWithSelector(StakingFacet.StakingZeroStakeAmount.selector)); + facet.unstakeToken(address(erc20Token), 0); + + vm.stopPrank(); + } + + function test_RewardCalculation() public { + vm.startPrank(alice); + + erc20Token.approve(address(facet), 1000 ether); + facet.stakeToken(address(erc20Token), 0, 1000 ether); + + vm.warp(block.timestamp + 365 days); + + uint256 calculatedRewards = facet.calculateRewardsForToken(address(erc20Token), 0); + + assertGt(calculatedRewards, 0); + vm.stopPrank(); + } + + function test_ClaimedRewards() public { + vm.startPrank(alice); + + erc20Token.approve(address(facet), 1000 ether); + facet.stakeToken(address(erc20Token), 0, 1000 ether); + + vm.warp(block.timestamp + 365 days); + + uint256 rewardsBalanceBefore = rewardToken.balanceOf(alice); + facet.claimRewards(address(erc20Token), 0); + uint256 rewardsBalanceAfter = rewardToken.balanceOf(alice); + + (uint256 amount, uint256 stakedAt, uint256 lastClaimedAt, uint256 accumulatedRewards) = + facet.getStakedTokenInfo(address(erc20Token), 0); + + assertGt(rewardsBalanceAfter, rewardsBalanceBefore); + assertEq(accumulatedRewards, rewardsBalanceAfter - rewardsBalanceBefore); + assertEq(lastClaimedAt, block.timestamp); + assertEq(amount, 1000 ether); + vm.stopPrank(); + } + + function testFuzz_StakeAmount(uint256 stakeAmount) public { + stakeAmount = bound(stakeAmount, 1e18, 1_000_000e18); + + vm.startPrank(owner); + facet.addSupportedToken(address(erc20Token), true, false, false); + facet.addSupportedToken(address(rewardToken), true, false, false); + facet.setStakingParameters(1000, 1e18, 1 days, address(rewardToken), 0, 1e18, type(uint256).max); + vm.stopPrank(); + + erc20Token.mint(alice, stakeAmount); + rewardToken.mint(address(facet), 1_000_000 ether); + + vm.startPrank(alice); + erc20Token.approve(address(facet), stakeAmount); + facet.stakeToken(address(erc20Token), 0, stakeAmount); + + (uint256 amount,,,) = facet.getStakedTokenInfo(address(erc20Token), 0); + assertEq(amount, stakeAmount); + vm.stopPrank(); + } + + function testFuzz_RewardAfterTime(uint256 daysStaked) public { + daysStaked = bound(daysStaked, 1, 3650); + + vm.startPrank(owner); + facet.addSupportedToken(address(erc20Token), true, false, false); + facet.addSupportedToken(address(rewardToken), true, false, false); + facet.setStakingParameters(1000, 1e18, 1 days, address(rewardToken), 0, 1e18, type(uint256).max); + vm.stopPrank(); + + erc20Token.mint(alice, 100 ether); + rewardToken.mint(address(facet), 1_000_000 ether); + + vm.startPrank(alice); + erc20Token.approve(address(facet), 100 ether); + facet.stakeToken(address(erc20Token), 0, 100 ether); + + vm.warp(block.timestamp + (daysStaked * 1 days)); + uint256 rewards = facet.calculateRewardsForToken(address(erc20Token), 0); + + assertGt(rewards, 0); + assertLe(rewards, 1_000_000 ether); + vm.stopPrank(); + } + + function testFuzz_RewardDecay(uint256 stakedSeconds, uint256 decayRate, uint256 compoundFreq) public { + vm.startPrank(owner); + + stakedSeconds = bound(stakedSeconds, 1 days, 3650 days); + decayRate = bound(decayRate, 0.5e18, 1.5e18); + compoundFreq = bound(compoundFreq, 1 days, 365 days); + + facet.addSupportedToken(address(erc20Token), true, false, false); + facet.addSupportedToken(address(rewardToken), true, false, false); + + facet.setStakingParameters( + 1000, decayRate, compoundFreq, address(rewardToken), COOLDOWN_PERIOD, 1, type(uint256).max + ); + + vm.stopPrank(); + + /** + * Warp time forward to ensure block.timestamp > stakedSeconds + * Add buffer to ensure we're well past the stake time + */ + vm.warp(stakedSeconds + 1 days); + + uint256 stakeTime = block.timestamp - stakedSeconds; + facet.testSetStakeInfo(alice, address(erc20Token), 0, 100 ether, stakeTime, stakeTime); + + vm.prank(alice); + uint256 rewards = facet.calculateRewardsForToken(address(erc20Token), 0); + + assertLe(rewards, type(uint256).max); + if (decayRate >= 1e18 && stakedSeconds < 10 * 365 days) { + assertGt(rewards, 0); + } + + if (rewards == 0 || rewards > 1_000_000 ether) { + emit log_named_uint("Decay Rate", decayRate); + emit log_named_uint("Compound Frequency", compoundFreq); + emit log_named_uint("Staked Seconds", stakedSeconds); + emit log_named_uint("Calculated Rewards", rewards); + } + } + + function testFuzz_StakeUnstake(uint256 stakeAmount, uint256 waitDays) public { + stakeAmount = bound(stakeAmount, 1e18, 1_000e18); + waitDays = bound(waitDays, 1, 365); + + vm.startPrank(owner); + facet.addSupportedToken(address(erc20Token), true, false, false); + facet.addSupportedToken(address(rewardToken), true, false, false); + facet.setStakingParameters(1000, 1e18, 1 days, address(rewardToken), 1 days, 1e18, type(uint256).max); + vm.stopPrank(); + + erc20Token.mint(alice, stakeAmount); + rewardToken.mint(address(facet), 1_000_000 ether); + + vm.startPrank(alice); + erc20Token.approve(address(facet), stakeAmount); + facet.stakeToken(address(erc20Token), 0, stakeAmount); + + vm.warp(block.timestamp + (waitDays * 1 days) + COOLDOWN_PERIOD + 1); + + facet.unstakeToken(address(erc20Token), 0); + (uint256 amount,,,) = facet.getStakedTokenInfo(address(erc20Token), 0); + assertEq(amount, 0); + vm.stopPrank(); + } + + function testFuzz_UnsupportedToken(address randomToken) public { + vm.assume(randomToken != address(erc20Token) && randomToken != address(0)); + + vm.startPrank(alice); + vm.expectRevert(abi.encodeWithSelector(StakingFacet.StakingUnsupportedToken.selector, randomToken)); + facet.stakeToken(randomToken, 0, 100 ether); + vm.stopPrank(); + } + + function test_FixedPoint_IntegerPower() public { + uint256 result = facet.rPow(2e18, 3); + assertEq(result, 8e18); + + result = facet.rPow(5e18, 0); + assertEq(result, 1e18); + + result = facet.rPow(1e18, 10); + assertEq(result, 1e18); + + result = facet.rPow(3e18, 4); + assertEq(result, 81e18); + } + + function test_FixedPoint_Multiply() public { + uint256 result = facet.rMul(2e18, 3e18); + assertEq(result, 6e18); + + result = facet.rMul(5e18, 0e18); + assertEq(result, 0); + + result = facet.rMul(1e18, 10e18); + assertEq(result, 10e18); + + result = facet.rMul(3e18, 4e18); + assertEq(result, 12e18); + } +} diff --git a/test/token/Staking/harnesses/LibStakingHarness.sol b/test/token/Staking/harnesses/LibStakingHarness.sol new file mode 100644 index 00000000..934b2ac4 --- /dev/null +++ b/test/token/Staking/harnesses/LibStakingHarness.sol @@ -0,0 +1,71 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.30; + +import "../../../../src/token/Staking/StakingMod.sol" as StakingMod; + +/** + * @title LibStakingHarness + * @notice Test harness that exposes internal functions of LibStaking as external + * @dev Required for testing since StakingMod functions are internal + */ +contract LibStakingHarness { + /** + * @notice Exposes StakingMod.addSupportedToken as an external function + */ + function addSupportedToken(address _tokenAddress, bool _isERC20, bool _isERC721, bool _isERC1155) external { + StakingMod.addSupportedToken(_tokenAddress, _isERC20, _isERC721, _isERC1155); + } + + /** + * @notice Exposes StakingMod._stakeERC20 as an external function + */ + function stakeERC20(address _tokenAddress, uint256 _value) external { + StakingMod.stakeERC20(_tokenAddress, _value); + } + + /** + * @notice Exposes StakingMod._stakeERC721 as an external function + */ + function stakeERC721(address _tokenAddress, uint256 _tokenId) external { + StakingMod.stakeERC721(_tokenAddress, _tokenId); + } + + /** + * @notice Exposes StakingMod._stakeERC1155 as an external function + */ + function stakeERC1155(address _tokenAddress, uint256 _tokenId, uint256 _value) external { + StakingMod.stakeERC1155(_tokenAddress, _tokenId, _value); + } + + /** + * @notice Exposes StakingMod.getStakedTokenInfo as an external function + */ + function getStakedTokenInfo(address _tokenAddress, uint256 _tokenId) + external + view + returns (uint256, uint256, uint256, uint256) + { + return StakingMod.getStakedTokenInfo(_tokenAddress, _tokenId); + } + + /** + * @notice Exposes StakingMod.isSupportedToken as an external function + */ + function isTokenSupported(address _tokenAddress) external view returns (bool) { + return StakingMod.isTokenSupported(_tokenAddress); + } + + /** + * @notice Exposes StakingMod.rpow as an external function + */ + function rPow(uint256 _base, uint256 _exp) external pure returns (uint256) { + return StakingMod.rpow(_base, _exp); + } + + /** + * @notice Exposes StakingMod.rmul as an external function + */ + function rMul(uint256 _a, uint256 _b) external pure returns (uint256) { + return StakingMod.rmul(_a, _b); + } +} diff --git a/test/token/Staking/harnesses/StakingFacetHarness.sol b/test/token/Staking/harnesses/StakingFacetHarness.sol new file mode 100644 index 00000000..4d0b08aa --- /dev/null +++ b/test/token/Staking/harnesses/StakingFacetHarness.sol @@ -0,0 +1,90 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.30; + +import {StakingFacet} from "../../../../src/token/Staking/StakingFacet.sol"; + +/** + * @title StakingFacetHarness + * @notice Test harness for StakingFacet + * @dev Adds helper functions to set up staking state for testing + */ +contract StakingFacetHarness is StakingFacet { + /** + * @notice Initialize the staking storage for testing + * @dev Only used for testing purposes - production diamonds should initialize in constructor + * @dev Set staking parameters + * @param _baseAPR The base annual percentage rate for staking rewards + * @param _rewardDecayRate The decay rate for staking rewards + * @param _compoundFrequency The frequency at which rewards are compounded + * @param _rewardToken The address of the reward ERC-20 token + * @param _cooldownPeriod The cooldown period for unstaking + * @param _minStakeAmount The minimum amount that can be staked + * @param _maxStakeAmount The maximum amount that can be staked + */ + function initialize( + uint256 _baseAPR, + uint256 _rewardDecayRate, + uint256 _compoundFrequency, + address _rewardToken, + uint256 _cooldownPeriod, + uint256 _minStakeAmount, + uint256 _maxStakeAmount + ) external { + StakingStorage storage s = getStorage(); + s.baseAPR = _baseAPR; + s.rewardDecayRate = _rewardDecayRate; + s.compoundFrequency = _compoundFrequency; + s.rewardToken = _rewardToken; + s.cooldownPeriod = _cooldownPeriod; + s.minStakeAmount = _minStakeAmount; + s.maxStakeAmount = _maxStakeAmount; + } + + function stakeERC20Token(address _tokenAddress, uint256 _value) external { + stakeERC20(_tokenAddress, _value); + } + + function stakeERC721Token(address _tokenAddress, uint256 _tokenId) external { + stakeERC721(_tokenAddress, _tokenId); + } + + function stakeERC1155Token(address _tokenAddress, uint256 _tokenId, uint256 _value) external { + stakeERC1155(_tokenAddress, _tokenId, _value); + } + + function calculateRewardsForToken(address _tokenAddress, uint256 _tokenId) external view returns (uint256) { + return calculateRewards(_tokenAddress, _tokenId); + } + + function claimRewardsForToken(address _tokenAddress, uint256 _tokenId) external { + _claimRewards(_tokenAddress, _tokenId); + } + + function tokenSupported(address _tokenAddress) external view { + isTokenSupported(_tokenAddress); + } + + function testSetStakeInfo( + address user, + address token, + uint256 tokenId, + uint256 amount, + uint256 stakedAt, + uint256 lastClaimedAt + ) external { + StakingStorage storage s = getStorage(); + StakedTokenInfo storage stake = s.stakedTokens[user][token][tokenId]; + + stake.amount = amount; + stake.stakedAt = stakedAt; + stake.lastClaimedAt = lastClaimedAt; + } + + function rPow(uint256 _base, uint256 _exp) external pure returns (uint256) { + return rpow(_base, _exp); + } + + function rMul(uint256 _a, uint256 _b) external pure returns (uint256) { + return rmul(_a, _b); + } +}