diff --git a/contracts/MuonNodeManager.sol b/contracts/MuonNodeManager.sol new file mode 100644 index 0000000..d1b208a --- /dev/null +++ b/contracts/MuonNodeManager.sol @@ -0,0 +1,419 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.19; + +import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; +import "@openzeppelin/contracts-upgradeable/access/AccessControlUpgradeable.sol"; +import "./interfaces/IMuonNodeManager.sol"; + +contract MuonNodeManager is + Initializable, + AccessControlUpgradeable, + IMuonNodeManager +{ + bytes32 public constant ADMIN_ROLE = keccak256("ADMIN_ROLE"); + bytes32 public constant DAO_ROLE = keccak256("DAO_ROLE"); + + // nodeId => Node + mapping(uint64 => Node) public nodes; + + // nodeAddress => nodeId + mapping(address => uint64) public nodeAddressIds; + + // stakerAddress => nodeId + mapping(address => uint64) public stakerAddressIds; + + uint64 public lastNodeId; + + // muon nodes check lastUpdateTime to sync their memory + uint256 public lastUpdateTime; + + struct EditLog { + uint64 nodeId; + uint256 editTime; + } + EditLog[] public editLogs; + + // commit id => git commit id + mapping(string => string) public configs; + + uint64 public lastRoleId; + + // hash(role) => role id + mapping(bytes32 => uint64) public roleIds; + + // role id => node id => index + 1 + mapping(uint64 => mapping(uint64 => uint16)) public nodesRoles; + + /** + * @dev Modifier to update the lastUpdateTime state variable. + */ + modifier updateState() { + lastUpdateTime = block.timestamp; + _; + } + + /** + * @dev Modifier to update the lastEditTime of a specific node. + * @param nodeId The id of the node. + */ + modifier updateNodeState(uint64 nodeId) { + nodes[nodeId].lastEditTime = block.timestamp; + editLogs.push(EditLog(nodeId, block.timestamp)); + _; + } + + function __MuonNodeManagerUpgradeable_init() internal initializer { + __AccessControl_init(); + + _setupRole(DEFAULT_ADMIN_ROLE, msg.sender); + _setupRole(ADMIN_ROLE, msg.sender); + _setupRole(DAO_ROLE, msg.sender); + + lastNodeId = 0; + lastUpdateTime = block.timestamp; + lastRoleId = 0; + } + + /** + * @dev Initializes the contract. + */ + function initialize() external initializer { + __MuonNodeManagerUpgradeable_init(); + } + + function __MuonNodeManagerUpgradeable_init_unchained() + internal + initializer + {} + + /** + * @dev Adds a new node. + * Only callable by the ADMIN_ROLE. + * @param nodeAddress The address of the node. + * @param stakerAddress The address of the staker associated with the node. + * @param peerId The peer ID of the node. + * @param active Indicates whether the node is active or not. + */ + function addNode( + address nodeAddress, + address stakerAddress, + string calldata peerId, + bool active + ) public override onlyRole(ADMIN_ROLE) updateState { + require(nodeAddressIds[nodeAddress] == 0, "Duplicate node address."); + + require( + stakerAddressIds[stakerAddress] == 0, + "Duplicate staker address." + ); + + lastNodeId++; + nodes[lastNodeId] = Node({ + id: lastNodeId, + nodeAddress: nodeAddress, + stakerAddress: stakerAddress, + peerId: peerId, + tier: 0, + active: active, + roles: new uint64[](0), + startTime: block.timestamp, + lastEditTime: block.timestamp, + endTime: 0 + }); + + nodeAddressIds[nodeAddress] = lastNodeId; + stakerAddressIds[stakerAddress] = lastNodeId; + + editLogs.push(EditLog(lastNodeId, block.timestamp)); + + emit NodeAdded(lastNodeId, nodes[lastNodeId]); + } + + /** + * @dev Allows the admins to deactivate the nodes. + * Only callable by the ADMIN_ROLE. + * @param nodeId The ID of the node to be deactivated. + */ + function deactiveNode(uint64 nodeId) + public + override + onlyRole(ADMIN_ROLE) + updateState + updateNodeState(nodeId) + { + require(nodes[nodeId].id == nodeId, "Node not found."); + + require(nodes[nodeId].active, "Already deactivated."); + + nodes[nodeId].endTime = block.timestamp; + nodes[nodeId].active = false; + + emit NodeDeactivated(nodeId); + } + + /** + * @dev Adds a role to a given node. + * Only callable by the DAO_ROLE. + * @param nodeId The ID of the node. + * @param roleId The ID of the role. + */ + function setNodeRole(uint64 nodeId, uint64 roleId) + public + onlyRole(DAO_ROLE) + updateState + updateNodeState(nodeId) + { + require(roleId > 0 && roleId <= lastRoleId, "Invalid role ID."); + + require(nodesRoles[roleId][nodeId] == 0, "Already set."); + + nodes[nodeId].roles.push(roleId); + nodesRoles[roleId][nodeId] = uint16(nodes[nodeId].roles.length); + emit NodeRoleSet(nodeId, roleId); + } + + /** + * @dev Removes a role from a given node. + * Only callable by the DAO_ROLE. + * @param nodeId The ID of the node. + * @param roleId The ID of the role. + */ + function unsetNodeRole(uint64 nodeId, uint64 roleId) + public + onlyRole(DAO_ROLE) + updateState + updateNodeState(nodeId) + { + require(roleId > 0 && roleId <= lastRoleId, "Invalid role ID."); + + require(nodesRoles[roleId][nodeId] > 0, "Already unset."); + + uint16 index = nodesRoles[roleId][nodeId] - 1; + uint64 lRoleId = nodes[nodeId].roles[nodes[nodeId].roles.length - 1]; + nodes[nodeId].roles[index] = lRoleId; + nodesRoles[lRoleId][nodeId] = index + 1; + nodes[nodeId].roles.pop(); + nodesRoles[roleId][nodeId] = 0; + emit NodeRoleUnset(nodeId, roleId); + } + + /** + * @dev Returns whether a given node has a given role. + * @param nodeId The ID of the node. + * @param role The role to check. + * @return A boolean indicating whether the node has the role. + */ + function nodeHasRole(uint64 nodeId, bytes32 role) + public + view + returns (bool) + { + return nodesRoles[roleIds[role]][nodeId] > 0; + } + + /** + * @dev Returns a list of roles associated with a node. + * @param nodeId The ID of the node. + * @return An array of role IDs. + */ + function getNodeRoles(uint64 nodeId) public view returns (uint64[] memory) { + return nodes[nodeId].roles; + } + + /** + * @dev Returns the information of a node. + * @param nodeId The ID of the node. + * @return The node information. + */ + function getNode(uint64 nodeId) public view returns (Node memory) { + Node memory node = nodes[nodeId]; + node.roles = getNodeRoles(nodeId); + return node; + } + + /** + * @dev Returns a list of nodes that have been edited. + * @param lastEditTime The time of the last edit. + * @param startId The starting node ID. + * @param endId The ending node ID. + * @return nodesList An array of edited nodes. + */ + function getAllNodes( + uint256 lastEditTime, + uint64 startId, + uint64 endId + ) public view returns (Node[] memory nodesList) { + startId = startId > 0 ? startId : 1; + endId = endId <= lastNodeId ? endId : lastNodeId; + require(startId <= endId, "Invalid range."); + + nodesList = new Node[](endId - startId + 1); + uint8 n = 0; + for (uint64 i = startId; i <= endId; i++) { + Node memory node = nodes[i]; + + if (node.lastEditTime > lastEditTime) { + nodesList[n] = node; + nodesList[n].roles = getNodeRoles(i); + n++; + } + } + + // Resize the array to remove any unused elements + assembly { + mstore(nodesList, n) + } + } + + /** + * @dev Returns a list of nodes that have been edited. + * @param lastEditTime The time of the last edit. + * @param index The index to start retrieving the edited nodes or zero. + * @return nodesList An array of edited nodes. + * @return lastIndex The index of the last retrieved edit log in the `editLogs` array. + */ + function getEditedNodes( + uint256 lastEditTime, + uint256 index, + uint16 maxNodesToRetrieve + ) public view returns (Node[] memory nodesList, uint256 lastIndex) { + uint256 startIndex = index == 0 ? editLogs.length - 1 : index - 1; + nodesList = new Node[](maxNodesToRetrieve); + uint8 nodesIndex = 0; + lastIndex = 0; + + for (uint256 i = startIndex + 1; i > 0; i--) { + EditLog memory log = editLogs[i - 1]; + + if (log.editTime <= lastEditTime) { + break; + } + + if (log.editTime == nodes[log.nodeId].lastEditTime) { + nodesList[nodesIndex] = nodes[log.nodeId]; + nodesList[nodesIndex].roles = getNodeRoles(log.nodeId); + nodesIndex++; + } + + if (nodesIndex == maxNodesToRetrieve) { + lastIndex = i - 1; + break; + } + } + // Resize the array to remove any unused elements + assembly { + mstore(nodesList, nodesIndex) + } + + return (nodesList, lastIndex); + } + + /** + * @dev Returns the information of a node associated with the provided node address. + * @param nodeAddress The node address. + * @return node The node information. + */ + function nodeAddressInfo(address nodeAddress) + public + view + returns (Node memory node) + { + node = nodes[nodeAddressIds[nodeAddress]]; + } + + /** + * @dev Returns the information of a node associated with the provided staker address. + * @param stakerAddress The staker address. + * @return node The node information. + */ + function stakerAddressInfo(address stakerAddress) + public + view + override + returns (Node memory node) + { + node = nodes[stakerAddressIds[stakerAddress]]; + } + + /** + * @dev Sets the tier of a node. + * Only callable by the DAO_ROLE. + * @param nodeId The ID of the node. + * @param tier The tier to set. + */ + function setTier(uint64 nodeId, uint8 tier) + public + onlyRole(ADMIN_ROLE) + updateState + updateNodeState(nodeId) + { + require(nodes[nodeId].id == nodeId, "Node not found."); + + require(nodes[nodeId].tier != tier, "Already set."); + + nodes[nodeId].tier = tier; + emit TierSet(nodeId, tier); + } + + /** + * @dev Sets a configuration value. + * Only callable by the DAO_ROLE. + * @param key The key of the configuration value. + * @param val The value to be set. + */ + function setConfig(string memory key, string memory val) + public + onlyRole(DAO_ROLE) + { + configs[key] = val; + emit ConfigSet(key, val); + } + + /** + * @dev Adds a new role. + * Only callable by the DAO_ROLE. + * @param role The role to be added. + */ + function addNodeRole(bytes32 role) public onlyRole(DAO_ROLE) { + require(roleIds[role] == 0, "Already added."); + + lastRoleId++; + roleIds[role] = lastRoleId; + emit NodeRoleAdded(role, lastRoleId); + } + + /** + * @dev Retrieves various contract information. + * @param configKeys An array of configuration keys to retrieve. + * @return lastUpdateTime The value of lastUpdateTime state variable. + * @return lastNodeId The value of lastNodeId state variable. + * @return lastRoleId The value of lastRoleId state variable. + * @return configValues An array of configuration values corresponding to the keys. + */ + function getInfo(string[] memory configKeys) + public + view + returns ( + uint256, + uint64, + uint64, + string[] memory + ) + { + string[] memory configValues = new string[](configKeys.length); + + for (uint256 i = 0; i < configKeys.length; i++) { + configValues[i] = configs[configKeys[i]]; + } + return (lastUpdateTime, lastNodeId, lastRoleId, configValues); + } + + // ======== Events ======== + event NodeAdded(uint64 indexed nodeId, Node node); + event NodeDeactivated(uint64 indexed nodeId); + event ConfigSet(string indexed key, string value); + event NodeRoleAdded(bytes32 indexed role, uint64 roleId); + event NodeRoleSet(uint64 indexed nodeId, uint64 indexed roleId); + event NodeRoleUnset(uint64 indexed nodeId, uint64 indexed roleId); + event TierSet(uint64 indexed nodeId, uint8 indexed tier); +} diff --git a/contracts/MuonNodeStaking.sol b/contracts/MuonNodeStaking.sol new file mode 100644 index 0000000..f6cdb0d --- /dev/null +++ b/contracts/MuonNodeStaking.sol @@ -0,0 +1,680 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.19; + +import "@openzeppelin/contracts-upgradeable/access/AccessControlUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; +import "@openzeppelin/contracts-upgradeable/interfaces/IERC20Upgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/token/ERC20/utils/SafeERC20Upgradeable.sol"; +import "./utils/MuonClientBase.sol"; +import "./interfaces/IMuonNodeManager.sol"; +import "./interfaces/IBondedToken.sol"; + +contract MuonNodeStaking is + Initializable, + AccessControlUpgradeable, + MuonClientBase +{ + using SafeERC20Upgradeable for IERC20Upgradeable; + + bytes32 public constant ADMIN_ROLE = keccak256("ADMIN_ROLE"); + bytes32 public constant DAO_ROLE = keccak256("DAO_ROLE"); + bytes32 public constant REWARD_ROLE = keccak256("REWARD_ROLE"); + + uint256 public constant REWARD_PERIOD = 30 days; + + uint256 public totalStaked; + + uint256 public notPaidRewards; + + uint256 public exitPendingPeriod; + + uint256 public minStakeAmount; + + uint256 public periodFinish; + + uint256 public rewardRate; + + uint256 public lastUpdateTime; + + uint256 public rewardPerTokenStored; + + struct User { + uint256 balance; + uint256 paidReward; + uint256 paidRewardPerToken; + uint256 pendingRewards; + uint256 tokenId; + } + mapping(address => User) public users; + + IMuonNodeManager public nodeManager; + + IERC20Upgradeable public muonToken; + + // stakerAddress => bool + mapping(address => bool) public lockedStakes; + + // address public vePion; + IBondedToken public bondedToken; + + // token address => index + 1 + mapping(address => uint16) public isStakingToken; + + address[] public stakingTokens; + + // token => multiplier * 1e18 + mapping(address => uint256) public stakingTokensMultiplier; + + // tier => maxStakeAmount + mapping(uint8 => uint256) public tiersMaxStakeAmount; + + // function name => paused + mapping(string => bool) public functionPauseStatus; + + /** + * @dev Modifier to make a function callable only when the contract is not paused. + */ + modifier whenFunctionNotPaused(string memory functionName) { + require(!functionPauseStatus[functionName], "Function is paused."); + _; + } + + /** + * @dev Modifier that updates the reward parameters + * before all of the functions that can change the rewards. + * + * `stakerAddress` should be address(0) when new rewards are distributing. + */ + modifier updateReward(address stakerAddress) { + rewardPerTokenStored = rewardPerToken(); + lastUpdateTime = lastTimeRewardApplicable(); + if (stakerAddress != address(0)) { + users[stakerAddress].pendingRewards = earned(stakerAddress); + users[stakerAddress].paidRewardPerToken = rewardPerTokenStored; + } + _; + } + + function __MuonNodeStakingUpgradeable_init( + address muonTokenAddress, + address nodeManagerAddress, + uint256 _muonAppId, + PublicKey memory _muonPublicKey, + address bondedTokenAddress + ) internal initializer { + __AccessControl_init(); + + _setupRole(DEFAULT_ADMIN_ROLE, msg.sender); + _setupRole(ADMIN_ROLE, msg.sender); + _setupRole(DAO_ROLE, msg.sender); + + muonToken = IERC20Upgradeable(muonTokenAddress); + nodeManager = IMuonNodeManager(nodeManagerAddress); + bondedToken = IBondedToken(bondedTokenAddress); + + exitPendingPeriod = 7 days; + minStakeAmount = 1000 ether; + + validatePubKey(_muonPublicKey.x); + muonPublicKey = _muonPublicKey; + muonAppId = _muonAppId; + } + + /** + * @dev Initializes the contract. + * @param muonTokenAddress The address of the Muon token. + * @param nodeManagerAddress The address of the Muon Node Manager contract. + * @param _muonAppId The Muon app ID. + * @param _muonPublicKey The Muon public key. + * @param bondedTokenAddress The address of the BondedToken contract. + */ + function initialize( + address muonTokenAddress, + address nodeManagerAddress, + uint256 _muonAppId, + PublicKey memory _muonPublicKey, + address bondedTokenAddress + ) external initializer { + __MuonNodeStakingUpgradeable_init( + muonTokenAddress, + nodeManagerAddress, + _muonAppId, + _muonPublicKey, + bondedTokenAddress + ); + } + + function __MuonNodeStakingUpgradeable_init_unchained() + internal + initializer + {} + + /** + * @dev Updates the list of staking tokens and their multipliers. + * Only callable by the DAO_ROLE. + * @param tokens The array of staking token addresses. + * @param multipliers The array of corresponding multipliers for each token. + */ + function updateStakingTokens( + address[] calldata tokens, + uint256[] calldata multipliers + ) external onlyRole(DAO_ROLE) { + require(tokens.length == multipliers.length, "Arrays length mismatch."); + + for (uint256 i = 0; i < tokens.length; i++) { + address token = tokens[i]; + uint256 multiplier = multipliers[i]; + + if (isStakingToken[token] > 0) { + if (multiplier == 0) { + uint16 tokenIndex = isStakingToken[token] - 1; + address lastToken = stakingTokens[stakingTokens.length - 1]; + + stakingTokens[tokenIndex] = lastToken; + isStakingToken[lastToken] = isStakingToken[token]; + stakingTokens.pop(); + isStakingToken[token] = 0; + } + + stakingTokensMultiplier[token] = multiplier; + } else { + require(multiplier > 0, "Invalid multiplier."); + stakingTokens.push(token); + stakingTokensMultiplier[token] = multiplier; + isStakingToken[token] = uint16(stakingTokens.length); + } + emit StakingTokenUpdated(token, multiplier); + } + } + + /** + * @dev Locks the specified tokens in the BondedToken contract for a given tokenId. + * The staker must first approve the contract to transfer the tokens on their behalf. + * Only the staker can call this function. + * @param tokens The array of token addresses to be locked. + * @param amounts The corresponding array of token amounts to be locked. + */ + function lockToBondedToken( + address[] memory tokens, + uint256[] memory amounts + ) external whenFunctionNotPaused("lockToBondedToken") { + require(tokens.length == amounts.length, "Arrays length mismatch."); + + uint256 tokenId = users[msg.sender].tokenId; + require(tokenId != 0, "No staking found."); + require( + bondedToken.ownerOf(tokenId) == address(this), + "Staking contract is not the owner of the NFT." + ); + + for (uint256 i = 0; i < tokens.length; i++) { + uint256 balance = IERC20Upgradeable(tokens[i]).balanceOf( + address(this) + ); + + IERC20Upgradeable(tokens[i]).safeTransferFrom( + msg.sender, + address(this), + amounts[i] + ); + + uint256 receivedAmount = IERC20Upgradeable(tokens[i]).balanceOf( + address(this) + ) - balance; + require( + amounts[i] == receivedAmount, + "The discrepancy between the received amount and the claimed amount." + ); + + IERC20Upgradeable(tokens[i]).safeApprove( + address(bondedToken), + amounts[i] + ); + } + + bondedToken.lock(tokenId, tokens, amounts); + + _updateStaking(msg.sender); + } + + /** + * @dev Merges two bonded tokens in the BondedToken contract. + * The staker must first approve the contract to transfer the tokenIdA on their behalf. + * @param tokenIdA The id of the first token to be merged. + */ + function mergeBondedTokens(uint256 tokenIdA) + external + whenFunctionNotPaused("mergeBondedTokens") + { + require( + bondedToken.ownerOf(tokenIdA) == msg.sender, + "Caller is not token owner." + ); + + uint256 tokenIdB = users[msg.sender].tokenId; + require(tokenIdB != 0, "No staking found."); + require( + bondedToken.ownerOf(tokenIdB) == address(this), + "Staking contract is not token owner." + ); + + bondedToken.safeTransferFrom(msg.sender, address(this), tokenIdA); + bondedToken.approve(address(bondedToken), tokenIdA); + + bondedToken.merge(tokenIdA, tokenIdB); + + _updateStaking(msg.sender); + } + + /** + * @dev Calculates the total value of a bonded token in terms of the staking tokens. + * @param tokenId The id of the bonded token. + * @return amount The total value of the bonded token. + */ + function valueOfBondedToken(uint256 tokenId) + public + view + returns (uint256 amount) + { + uint256[] memory lockedAmounts = bondedToken.getLockedOf( + tokenId, + stakingTokens + ); + + amount = 0; + for (uint256 i = 0; i < lockedAmounts.length; i++) { + address token = stakingTokens[i]; + uint256 multiplier = stakingTokensMultiplier[token]; + amount += (multiplier * lockedAmounts[i]) / 1e18; + } + return amount; + } + + /** + * @dev Updates the staking status for the staker. + * This function calculates the staked amount based on the locked tokens and their multipliers, + * and updates the balance and total staked amount accordingly. + * Only callable by staker. + */ + function updateStaking() external { + _updateStaking(msg.sender); + } + + function _updateStaking(address stakerAddress) + private + updateReward(stakerAddress) + { + IMuonNodeManager.Node memory node = nodeManager.stakerAddressInfo( + stakerAddress + ); + require(node.id != 0 && node.active, "No active node found."); + + uint256 tokenId = users[stakerAddress].tokenId; + require(tokenId != 0, "No staking found."); + + uint256 amount = valueOfBondedToken(tokenId); + require(amount >= minStakeAmount, "Insufficient staking."); + + uint256 maxStakeAmount = tiersMaxStakeAmount[node.tier]; + if (amount > maxStakeAmount) { + amount = maxStakeAmount; + } + + if (users[stakerAddress].balance != amount) { + totalStaked -= users[stakerAddress].balance; + users[stakerAddress].balance = amount; + totalStaked += amount; + emit Staked(stakerAddress, amount); + } + } + + /** + * @dev Allows the stakers to withdraw their rewards. + * @param amount The amount of tokens to withdraw. + * @param reqId The id of the withdrawal request. + * @param signature A tss signature that proves the authenticity of the withdrawal request. + */ + function getReward( + uint256 amount, + uint256 paidRewardPerToken, + bytes calldata reqId, + SchnorrSign calldata signature + ) public whenFunctionNotPaused("getReward") { + require(amount > 0, "Invalid amount."); + + IMuonNodeManager.Node memory node = nodeManager.stakerAddressInfo( + msg.sender + ); + require(node.id != 0, "Node not found."); + + User memory user = users[msg.sender]; + require( + user.paidRewardPerToken <= paidRewardPerToken && + paidRewardPerToken <= rewardPerToken(), + "Invalid paidRewardPerToken." + ); + + // Verify the authenticity of the withdrawal request. + bytes32 hash = keccak256( + abi.encodePacked( + muonAppId, + reqId, + msg.sender, + user.paidReward, + paidRewardPerToken, + amount + ) + ); + + bool verified = muonVerify( + reqId, + uint256(hash), + signature, + muonPublicKey + ); + require(verified, "Invalid signature."); + + uint256 maxReward = (user.balance * + (paidRewardPerToken - user.paidRewardPerToken)) / + 1e18 + + user.pendingRewards; + require(amount <= maxReward, "Invalid withdrawal amount."); + notPaidRewards += (maxReward - amount); + + require(amount <= earned(msg.sender), "Invalid withdrawal amount."); + + users[msg.sender].pendingRewards = 0; + users[msg.sender].paidReward += amount; + users[msg.sender].paidRewardPerToken = paidRewardPerToken; + muonToken.safeTransfer(msg.sender, amount); + emit RewardGot(reqId, msg.sender, amount); + } + + /** + * @dev Allows stakers to request to exit from the network. + * Stakers can withdraw the staked amount after the exit pending period has passed. + */ + function requestExit() external { + _deactiveMuonNode(msg.sender); + + emit ExitRequested(msg.sender); + } + + /** + * @dev Allows DAO_ROLE to deactive a node. + * @param stakerAddress The address of the staker. + */ + function deactiveMuonNode(address stakerAddress) public onlyRole(DAO_ROLE) { + _deactiveMuonNode(stakerAddress); + } + + function _deactiveMuonNode(address stakerAddress) + private + updateReward(stakerAddress) + { + IMuonNodeManager.Node memory node = nodeManager.stakerAddressInfo( + stakerAddress + ); + + totalStaked -= users[stakerAddress].balance; + users[stakerAddress].balance = 0; + nodeManager.deactiveNode(node.id); + } + + /** + * @dev Allows stakers to withdraw their staked amount after exiting the network and exit pending period has passed. + */ + function withdraw() public whenFunctionNotPaused("withdraw") { + IMuonNodeManager.Node memory node = nodeManager.stakerAddressInfo( + msg.sender + ); + require(node.id != 0, "Node not found."); + + require( + !node.active && + (node.endTime + exitPendingPeriod) < block.timestamp, + "The exit time has not been reached yet." + ); + + require(!lockedStakes[msg.sender], "Stake is locked."); + + uint256 tokenId = users[msg.sender].tokenId; + require(tokenId != 0, "No staking found."); + + if (users[msg.sender].balance > 0) { + totalStaked -= users[msg.sender].balance; + users[msg.sender].balance = 0; + } + + users[msg.sender].tokenId = 0; + bondedToken.safeTransferFrom(address(this), msg.sender, tokenId); + emit Withdrawn(msg.sender, tokenId); + } + + /** + * @dev Allows users to add a Muon node. + * The user must have a sufficient staking amount in the BondedToken contract to run a node. + * @param nodeAddress The address of the Muon node. + * @param peerId The peer ID of the node. + * @param tokenId The id of the staking token. + */ + function addMuonNode( + address nodeAddress, + string calldata peerId, + uint256 tokenId + ) public whenFunctionNotPaused("addMuonNode") { + require(users[msg.sender].tokenId == 0, "Already staked an NFT."); + + uint256 amount = valueOfBondedToken(tokenId); + require(amount >= minStakeAmount, "Insufficient staking."); + + users[msg.sender].tokenId = tokenId; + + bondedToken.safeTransferFrom(msg.sender, address(this), tokenId); + require( + bondedToken.ownerOf(tokenId) == address(this), + "Not received the NFT." + ); + + nodeManager.addNode( + nodeAddress, + msg.sender, // stakerAddress + peerId, + true // active + ); + emit MuonNodeAdded(nodeAddress, msg.sender, peerId); + } + + /** + * @dev Distributes the specified reward amount to the stakers. + * Only callable by the REWARD_ROLE. + * @param reward The reward amount to be distributed. + */ + function distributeRewards(uint256 reward) + public + updateReward(address(0)) + onlyRole(REWARD_ROLE) + { + if (block.timestamp >= periodFinish) { + rewardRate = (reward + notPaidRewards) / REWARD_PERIOD; + } else { + uint256 remaining = periodFinish - block.timestamp; + uint256 leftover = remaining * rewardRate; + rewardRate = (reward + leftover + notPaidRewards) / REWARD_PERIOD; + } + + notPaidRewards = 0; + lastUpdateTime = block.timestamp; + periodFinish = block.timestamp + REWARD_PERIOD; + emit RewardsDistributed(reward, block.timestamp, REWARD_PERIOD); + } + + /** + * @dev Calculates the current reward per token. + * The reward per token is the amount of reward earned per staking token until now. + * @return The current reward per token. + */ + function rewardPerToken() public view returns (uint256) { + if (totalStaked == 0) { + return rewardPerTokenStored; + } else { + return + rewardPerTokenStored + + ((lastTimeRewardApplicable() - lastUpdateTime) * + rewardRate * + 1e18) / + totalStaked; + } + } + + /** + * @dev Calculates the total rewards earned by a node. + * @param stakerAddress The staker address of a node. + * @return The total rewards earned by a node. + */ + function earned(address stakerAddress) public view returns (uint256) { + User memory user = users[stakerAddress]; + return + (user.balance * (rewardPerToken() - user.paidRewardPerToken)) / + 1e18 + + user.pendingRewards; + } + + /** + * @dev Returns the last time when rewards were applicable. + * @return The last time when rewards were applicable. + */ + function lastTimeRewardApplicable() public view returns (uint256) { + return block.timestamp < periodFinish ? block.timestamp : periodFinish; + } + + /** + * @dev Locks or unlocks the specified staker's stake. + * Only callable by the REWARD_ROLE. + * @param stakerAddress The address of the staker. + * @param lockStatus Boolean indicating whether to lock (true) or unlock (false) the stake. + */ + function setStakeLockStatus(address stakerAddress, bool lockStatus) + external + onlyRole(REWARD_ROLE) + { + IMuonNodeManager.Node memory node = nodeManager.stakerAddressInfo( + stakerAddress + ); + require(node.id != 0, "Node not found."); + + bool currentLockStatus = lockedStakes[stakerAddress]; + require( + currentLockStatus != lockStatus, + lockStatus ? "Already locked." : "Already unlocked." + ); + + lockedStakes[stakerAddress] = lockStatus; + emit StakeLockStatusChanged(stakerAddress, lockStatus); + } + + /** + * @dev ERC721 token receiver function. + * + * @return bytes4 `bytes4(keccak256("onERC721Received(address,address,uint256,bytes)"))`. + */ + function onERC721Received( + address, + address, + uint256, + bytes calldata + ) public pure returns (bytes4) { + return this.onERC721Received.selector; + } + + // ======== DAO functions ======== + + function setExitPendingPeriod(uint256 _exitPendingPeriod) + public + onlyRole(DAO_ROLE) + { + exitPendingPeriod = _exitPendingPeriod; + emit ExitPendingPeriodUpdated(_exitPendingPeriod); + } + + function setMinStakeAmount(uint256 _minStakeAmount) + public + onlyRole(DAO_ROLE) + { + minStakeAmount = _minStakeAmount; + emit MinStakeAmountUpdated(_minStakeAmount); + } + + function setMuonAppId(uint256 _muonAppId) public onlyRole(DAO_ROLE) { + muonAppId = _muonAppId; + emit MuonAppIdUpdated(_muonAppId); + } + + function setMuonPublicKey(PublicKey memory _muonPublicKey) + public + onlyRole(DAO_ROLE) + { + validatePubKey(_muonPublicKey.x); + + muonPublicKey = _muonPublicKey; + emit MuonPublicKeyUpdated(_muonPublicKey); + } + + function setTierMaxStakeAmount(uint8 tier, uint256 maxStakeAmount) + public + onlyRole(DAO_ROLE) + { + tiersMaxStakeAmount[tier] = maxStakeAmount; + emit TierMaxStakeUpdated(tier, maxStakeAmount); + } + + function setMuonNodeTier(address stakerAddress, uint8 tier) + public + onlyRole(DAO_ROLE) + updateReward(stakerAddress) + { + IMuonNodeManager.Node memory node = nodeManager.stakerAddressInfo( + stakerAddress + ); + nodeManager.setTier(node.id, tier); + _updateStaking(stakerAddress); + } + + function setFunctionPauseStatus( + string memory functionName, + bool pauseStatus + ) external onlyRole(DAO_ROLE) { + bool currentStatus = functionPauseStatus[functionName]; + require( + currentStatus != pauseStatus, + pauseStatus ? "Already paused." : "Already unpaused." + ); + + functionPauseStatus[functionName] = pauseStatus; + emit FunctionPauseStatusChanged(functionName, pauseStatus); + } + + // ======== Events ======== + event Staked(address indexed stakerAddress, uint256 amount); + event Withdrawn(address indexed stakerAddress, uint256 tokenId); + event RewardGot(bytes reqId, address indexed stakerAddress, uint256 amount); + event ExitRequested(address indexed stakerAddress); + event MuonNodeAdded( + address indexed nodeAddress, + address indexed stakerAddress, + string peerId + ); + event RewardsDistributed( + uint256 reward, + uint256 periodStart, + uint256 rewardPeriod + ); + event ExitPendingPeriodUpdated(uint256 exitPendingPeriod); + event MinStakeAmountUpdated(uint256 minStakeAmount); + event MuonAppIdUpdated(uint256 muonAppId); + event MuonPublicKeyUpdated(PublicKey muonPublicKey); + event StakeLockStatusChanged(address indexed stakerAddress, bool locked); + event StakingTokenUpdated(address indexed token, uint256 multiplier); + event TierMaxStakeUpdated(uint8 tier, uint256 maxStakeAmount); + event FunctionPauseStatusChanged(string functionName, bool isPaused); +} diff --git a/contracts/interfaces/IBondedToken.sol b/contracts/interfaces/IBondedToken.sol new file mode 100644 index 0000000..ee6f681 --- /dev/null +++ b/contracts/interfaces/IBondedToken.sol @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +interface IBondedToken { + function lock( + uint256 tokenId, + address[] memory tokens, + uint256[] memory amounts + ) external; + + function merge(uint256 tokenIdA, uint256 tokenIdB) external; + + function getLockedOf(uint256 tokenId, address[] memory tokens) + external + view + returns (uint256[] memory amounts); + + function safeTransferFrom( + address from, + address to, + uint256 tokenId + ) external; + + function approve(address to, uint256 tokenId) external; + + function ownerOf(uint256 tokenId) external view returns (address owner); +} diff --git a/contracts/interfaces/IMuonNodeManager.sol b/contracts/interfaces/IMuonNodeManager.sol new file mode 100644 index 0000000..c0500c8 --- /dev/null +++ b/contracts/interfaces/IMuonNodeManager.sol @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.19; + +interface IMuonNodeManager { + struct Node { + uint64 id; // incremental ID + address nodeAddress; // will be used on the node + address stakerAddress; + string peerId; // p2p peer ID + bool active; + uint8 tier; + uint64[] roles; + uint256 startTime; + uint256 endTime; + uint256 lastEditTime; + } + + function addNode( + address _nodeAddress, + address _stakerAddress, + string calldata _peerId, + bool _active + ) external; + + function deactiveNode(uint64 nodeId) external; + + function stakerAddressInfo(address _addr) + external + view + returns (Node memory node); + + function setTier(uint64 nodeId, uint8 tier) external; + +} diff --git a/contracts/mock/PIONlpTest.sol b/contracts/mock/PIONlpTest.sol new file mode 100644 index 0000000..ed0a26a --- /dev/null +++ b/contracts/mock/PIONlpTest.sol @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.19; + +import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; + +contract PIONlpTest is ERC20 { + constructor() ERC20("PioneerLp", "PIONlp") {} + + function mint(address to, uint256 amount) public { + _mint(to, amount); + } +} diff --git a/contracts/mock/PIONtest.sol b/contracts/mock/PIONtest.sol new file mode 100644 index 0000000..ced59a3 --- /dev/null +++ b/contracts/mock/PIONtest.sol @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.19; + +import "../Token.sol"; + +contract PIONtest is Token { + function initialize() public initializer { + Token._initialize("PioneerNetwork", "PION"); + } +} diff --git a/contracts/utils/MuonClientBase.sol b/contracts/utils/MuonClientBase.sol new file mode 100644 index 0000000..7d04b16 --- /dev/null +++ b/contracts/utils/MuonClientBase.sol @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.19; + +import "./SchnorrSECP256K1Verifier.sol"; + +contract MuonClientBase is SchnorrSECP256K1Verifier{ + + struct SchnorrSign { + uint256 signature; + address owner; + address nonce; + } + + struct PublicKey { + uint256 x; + uint8 parity; + } + + event MuonTX(bytes reqId, PublicKey pubKey); + + uint256 public muonAppId; + PublicKey public muonPublicKey; + + function muonVerify( + bytes calldata reqId, + uint256 hash, + SchnorrSign memory signature, + PublicKey memory pubKey + ) public returns (bool) { + if(!verifySignature(pubKey.x, pubKey.parity, + signature.signature, + hash, signature.nonce)){ + return false; + } + emit MuonTX(reqId, pubKey); + return true; + } +} diff --git a/contracts/utils/SchnorrSECP256K1Verifier.sol b/contracts/utils/SchnorrSECP256K1Verifier.sol new file mode 100644 index 0000000..41d78e2 --- /dev/null +++ b/contracts/utils/SchnorrSECP256K1Verifier.sol @@ -0,0 +1,128 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.19; + +contract SchnorrSECP256K1Verifier { + // See https://en.bitcoin.it/wiki/Secp256k1 for this constant. + uint256 constant public Q = // Group order of secp256k1 + 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141; + uint256 constant public HALF_Q = (Q >> 1) + 1; + + /** ************************************************************************** + @notice verifySignature returns true iff passed a valid Schnorr signature. + @dev See https://en.wikipedia.org/wiki/Schnorr_signature for reference. + @dev In what follows, let d be your secret key, PK be your public key, + PKx be the x ordinate of your public key, and PKyp be the parity bit for + the y ordinate (i.e., 0 if PKy is even, 1 if odd.) + ************************************************************************** + @dev TO CREATE A VALID SIGNATURE FOR THIS METHOD + @dev First PKx must be less than HALF_Q. Then follow these instructions + (see evm/test/schnorr_test.js, for an example of carrying them out): + @dev 1. Hash the target message to a uint256, called msgHash here, using + keccak256 + @dev 2. Pick k uniformly and cryptographically securely randomly from + {0,...,Q-1}. It is critical that k remains confidential, as your + private key can be reconstructed from k and the signature. + @dev 3. Compute k*g in the secp256k1 group, where g is the group + generator. (This is the same as computing the public key from the + secret key k. But it's OK if k*g's x ordinate is greater than + HALF_Q.) + @dev 4. Compute the ethereum address for k*g. This is the lower 160 bits + of the keccak hash of the concatenated affine coordinates of k*g, + as 32-byte big-endians. (For instance, you could pass k to + ethereumjs-utils's privateToAddress to compute this, though that + should be strictly a development convenience, not for handling + live secrets, unless you've locked your javascript environment + down very carefully.) Call this address + nonceTimesGeneratorAddress. + @dev 5. Compute e=uint256(keccak256(PKx as a 32-byte big-endian + ‖ PKyp as a single byte + ‖ msgHash + ‖ nonceTimesGeneratorAddress)) + This value e is called "msgChallenge" in verifySignature's source + code below. Here "‖" means concatenation of the listed byte + arrays. + @dev 6. Let x be your secret key. Compute s = (k - d * e) % Q. Add Q to + it, if it's negative. This is your signature. (d is your secret + key.) + ************************************************************************** + @dev TO VERIFY A SIGNATURE + @dev Given a signature (s, e) of msgHash, constructed as above, compute + S=e*PK+s*generator in the secp256k1 group law, and then the ethereum + address of S, as described in step 4. Call that + nonceTimesGeneratorAddress. Then call the verifySignature method as: + @dev verifySignature(PKx, PKyp, s, msgHash, + nonceTimesGeneratorAddress) + ************************************************************************** + @dev This signging scheme deviates slightly from the classical Schnorr + signature, in that the address of k*g is used in place of k*g itself, + both when calculating e and when verifying sum S as described in the + verification paragraph above. This reduces the difficulty of + brute-forcing a signature by trying random secp256k1 points in place of + k*g in the signature verification process from 256 bits to 160 bits. + However, the difficulty of cracking the public key using "baby-step, + giant-step" is only 128 bits, so this weakening constitutes no compromise + in the security of the signatures or the key. + @dev The constraint signingPubKeyX < HALF_Q comes from Eq. (281), p. 24 + of Yellow Paper version 78d7b9a. ecrecover only accepts "s" inputs less + than HALF_Q, to protect against a signature- malleability vulnerability in + ECDSA. Schnorr does not have this vulnerability, but we must account for + ecrecover's defense anyway. And since we are abusing ecrecover by putting + signingPubKeyX in ecrecover's "s" argument the constraint applies to + signingPubKeyX, even though it represents a value in the base field, and + has no natural relationship to the order of the curve's cyclic group. + ************************************************************************** + @param signingPubKeyX is the x ordinate of the public key. This must be + less than HALF_Q. + @param pubKeyYParity is 0 if the y ordinate of the public key is even, 1 + if it's odd. + @param signature is the actual signature, described as s in the above + instructions. + @param msgHash is a 256-bit hash of the message being signed. + @param nonceTimesGeneratorAddress is the ethereum address of k*g in the + above instructions + ************************************************************************** + @return True if passed a valid signature, false otherwise. */ + function verifySignature( + uint256 signingPubKeyX, + uint8 pubKeyYParity, + uint256 signature, + uint256 msgHash, + address nonceTimesGeneratorAddress) public pure returns (bool) { + require(signingPubKeyX < HALF_Q, "Public-key x >= HALF_Q"); + // Avoid signature malleability from multiple representations for ℤ/Qℤ elts + require(signature < Q, "signature must be reduced modulo Q"); + + // Forbid trivial inputs, to avoid ecrecover edge cases. The main thing to + // avoid is something which causes ecrecover to return 0x0: then trivial + // signatures could be constructed with the nonceTimesGeneratorAddress input + // set to 0x0. + // + require(nonceTimesGeneratorAddress != address(0) && signingPubKeyX > 0 && + signature > 0 && msgHash > 0, "no zero inputs allowed"); + + uint256 msgChallenge = // "e" + uint256(keccak256(abi.encodePacked(nonceTimesGeneratorAddress, msgHash))); + + // Verify msgChallenge * signingPubKey + signature * generator == + // nonce * generator + // + // https://ethresear.ch/t/you-can-kinda-abuse-ecrecover-to-do-ecmul-in-secp256k1-today/2384/9 + // The point corresponding to the address returned by + // ecrecover(-s*r,v,r,e*r) is (r⁻¹ mod Q)*(e*r*R-(-s)*r*g)=e*R+s*g, where R + // is the (v,r) point. See https://crypto.stackexchange.com/a/18106 + // + address recoveredAddress = ecrecover( + bytes32(Q - mulmod(signingPubKeyX, signature, Q)), + // https://ethereum.github.io/yellowpaper/paper.pdf p. 24, "The + // value 27 represents an even y value and 28 represents an odd + // y value." + (pubKeyYParity == 0) ? 27 : 28, + bytes32(signingPubKeyX), + bytes32(mulmod(msgChallenge, signingPubKeyX, Q))); + return nonceTimesGeneratorAddress == recoveredAddress; + } + + function validatePubKey (uint256 signingPubKeyX) public pure { + require(signingPubKeyX < HALF_Q, "Public-key x >= HALF_Q"); + } +} diff --git a/test/muonNodeManager.ts b/test/muonNodeManager.ts new file mode 100644 index 0000000..799d997 --- /dev/null +++ b/test/muonNodeManager.ts @@ -0,0 +1,475 @@ +import { ethers, upgrades } from "hardhat"; +import { Signer } from "ethers"; +import { expect } from "chai"; + +import { MuonNodeManager } from "../typechain/MuonNodeManager"; + +describe("MuonNodeManager", function () { + let deployer: Signer; + let adminRole: Signer; + let daoRole: Signer; + let node1: Signer; + let node2: Signer; + let node3: Signer; + let staker1: Signer; + let staker2: Signer; + let staker3: Signer; + let user1: Signer; + + const peerId1 = "QmQ28Fae738pmSuhQPYtsDtwU8pKYPPgf76pSN61T3APh1"; + const peerId2 = "QmQ28Fae738pmSuhQPYtsDtwU8pKYPPgf76pSN61T3APh2"; + const peerId3 = "QmQ28Fae738pmSuhQPYtsDtwU8pKYPPgf76pSN61T3APh3"; + + let nodeManager: MuonNodeManager; + + before(async function () { + [ + deployer, + adminRole, + daoRole, + node1, + node2, + node3, + staker1, + staker2, + staker3, + user1, + ] = await ethers.getSigners(); + }); + + beforeEach(async function () { + const MuonNodeManager = await ethers.getContractFactory("MuonNodeManager"); + nodeManager = await upgrades.deployProxy(MuonNodeManager, []); + await nodeManager.deployed(); + + await nodeManager + .connect(deployer) + .grantRole(await nodeManager.ADMIN_ROLE(), adminRole.address); + + await nodeManager + .connect(deployer) + .grantRole(await nodeManager.DAO_ROLE(), daoRole.address); + }); + + describe("add nodes", function () { + it("should successfully add Muon nodes", async function () { + await nodeManager + .connect(adminRole) + .addNode(node1.address, staker1.address, peerId1, true); + const node = await nodeManager.nodes(1); + expect(node.id).eq(1); + expect(node.nodeAddress).eq(node1.address); + expect(node.stakerAddress).eq(staker1.address); + expect(node.peerId).eq(peerId1); + expect(node.active).to.be.true; + expect(node.tier).eq(0); + expect(node.startTime).to.be.greaterThan(0); + expect(node.endTime).eq(0); + expect(node.lastEditTime).eq(node.startTime); + }); + + it("should not allow adding a node with a duplicate nodeAddress.", async function () { + await nodeManager + .connect(adminRole) + .addNode(node1.address, staker1.address, peerId1, true); + + await expect( + nodeManager + .connect(adminRole) + .addNode(node1.address, staker2.address, peerId2, true) + ).to.be.revertedWith("Duplicate node address."); + }); + + it("should not allow adding a node with a duplicate stakerAddress", async function () { + await nodeManager + .connect(adminRole) + .addNode(node1.address, staker1.address, peerId1, true); + + await expect( + nodeManager + .connect(adminRole) + .addNode(node2.address, staker1.address, peerId2, true) + ).to.be.revertedWith("Duplicate staker address."); + }); + }); + + describe("deactive nodes", function () { + it("should successfully deactivate an active node", async function () { + await nodeManager + .connect(adminRole) + .addNode(node1.address, staker1.address, peerId1, true); + + await nodeManager.connect(adminRole).deactiveNode(1); + const node = await nodeManager.nodes(1); + expect(node.active).eq(false); + expect(node.endTime).to.not.equal(0); + }); + + it("should not allow deactivating an already deactivated node", async function () { + await nodeManager + .connect(adminRole) + .addNode(node1.address, staker1.address, peerId1, true); + + await nodeManager.connect(adminRole).deactiveNode(1); + await expect( + nodeManager.connect(adminRole).deactiveNode(1) + ).to.be.revertedWith("Already deactivated."); + }); + + it("should not allow deactivating a non-existent node", async function () { + await expect( + nodeManager.connect(adminRole).deactiveNode(2) + ).to.be.revertedWith("Node not found."); + }); + }); + + describe("get nods", function () { + it("should retrieve all edited nodes or all nodes", async () => { + const startTime = (await ethers.provider.getBlock("latest")).timestamp; + + for (let i = 1; i <= 10; i++) { + await nodeManager + .connect(adminRole) + .addNode( + ethers.Wallet.createRandom().address, + ethers.Wallet.createRandom().address, + `peerId${i}`, + true + ); + } + + const targetTimestamp = startTime + 2 * 3600; + await ethers.provider.send("evm_setNextBlockTimestamp", [ + targetTimestamp, + ]); + + for (let i = 1; i <= 5; i++) { + await nodeManager + .connect(adminRole) + .addNode( + ethers.Wallet.createRandom().address, + ethers.Wallet.createRandom().address, + `peerId${i}`, + true + ); + } + + const nodeId = 1; + const roleDeployers = ethers.utils.solidityKeccak256( + ["string"], + ["deployers"] + ); + await nodeManager.connect(daoRole).addNodeRole(roleDeployers); + const roleIdDeployers = await nodeManager.roleIds(roleDeployers); + await nodeManager.connect(daoRole).setNodeRole(nodeId, roleIdDeployers); + expect(await nodeManager.nodeHasRole(nodeId, roleDeployers)).to.be.true; + + // get the list of the nodes that were edited in the past hour + const endTime = (await ethers.provider.getBlock("latest")).timestamp; + const lastEditTime = endTime - 3600; + const editedNodesList = await nodeManager.getAllNodes( + lastEditTime, + 1, + 1000 + ); + + expect(editedNodesList).to.have.lengthOf(6); + const node = editedNodesList[0]; + const nodeRoles = node.roles.map((role) => role.toNumber()); + expect(nodeRoles).to.deep.equal([1]); + + for (let i = 1; i <= 100; i++) { + await nodeManager + .connect(adminRole) + .addNode( + ethers.Wallet.createRandom().address, + ethers.Wallet.createRandom().address, + `peerId${i}`, + true + ); + } + + const allNodesList = await nodeManager.getAllNodes(0, 1, 1000); + expect(allNodesList).to.have.lengthOf(115); + + expect(await nodeManager.lastNodeId()).to.be.equal(115); + }); + + it("should retrieve edited nodes", async () => { + const startTime = (await ethers.provider.getBlock("latest")).timestamp; + + for (let i = 1; i <= 108; i++) { + await nodeManager + .connect(adminRole) + .addNode( + ethers.Wallet.createRandom().address, + ethers.Wallet.createRandom().address, + `peerId${i}`, + true + ); + } + + const targetTimestamp = startTime + 2 * 3600; + await ethers.provider.send("evm_setNextBlockTimestamp", [ + targetTimestamp, + ]); + + const roleDeployers = ethers.utils.solidityKeccak256( + ["string"], + ["deployers"] + ); + + for (let i = 1; i <= 102; i++) { + await nodeManager.setTier(i, 1); + + // to test that it should not return duplicate nodes + await nodeManager.setTier(i, 2); + } + + // get the list of the nodes that were edited in the past hour + const endTime = (await ethers.provider.getBlock("latest")).timestamp; + const lastEditTime = endTime - 3600; + + const editedNodesList = []; + let lastIndex = 0; + while (true) { + const resp = await nodeManager.getEditedNodes( + lastEditTime, + lastIndex, + 50 + ); + editedNodesList.push(...resp.nodesList); + lastIndex = resp.lastIndex; + if (lastIndex == 0) { + break; + } + } + + expect(editedNodesList).to.have.lengthOf(102); + }); + }); + + describe("nodeAddressInfo", function () { + it("should successfully retrieve node information for a valid nodeAddress", async function () { + await nodeManager + .connect(adminRole) + .addNode(node1.address, staker1.address, peerId1, true); + const node = await nodeManager.nodeAddressInfo(node1.address); + expect(node.id).eq(1); + expect(node.peerId).eq(peerId1); + }); + + it("should return empty node information for an invalid nodeAddress", async function () { + const node = await nodeManager.nodeAddressInfo(staker3.address); + expect(node.id).eq(0); + }); + }); + + describe("stakerAddressInfo", function () { + it("should successfully retrieve node information for a valid stakerAddress", async function () { + await nodeManager + .connect(adminRole) + .addNode(node1.address, staker1.address, peerId1, true); + const node = await nodeManager.stakerAddressInfo(staker1.address); + expect(node.id).eq(1); + expect(node.peerId).eq(peerId1); + }); + + it("should return empty node information for an invalid stakerAddress", async function () { + const node = await nodeManager.stakerAddressInfo(node3.address); + expect(node.id).eq(0); + }); + }); + + describe("node roles", () => { + it("should not allow unauthorized accounts to add/set/unset node roles' roles", async () => { + await nodeManager + .connect(adminRole) + .addNode(node1.address, staker1.address, peerId1, true); + const nodeId = 1; + const role = ethers.utils.solidityKeccak256(["string"], ["poa"]); + + const DAO_ROLE = await nodeManager.DAO_ROLE(); + const revertMSG = `AccessControl: account ${adminRole.address.toLowerCase()} is missing role ${DAO_ROLE}`; + await expect( + nodeManager.connect(adminRole).addNodeRole(role) + ).to.be.revertedWith(revertMSG); + + await nodeManager.connect(daoRole).addNodeRole(role); + const roleId = await nodeManager.roleIds(role); + await expect( + nodeManager.connect(adminRole).setNodeRole(nodeId, roleId) + ).to.be.revertedWith(revertMSG); + + await expect( + nodeManager.connect(adminRole).unsetNodeRole(nodeId, roleId) + ).to.be.revertedWith(revertMSG); + }); + + it("should not allow setting/unsetting roles that have not been added yet", async () => { + await nodeManager + .connect(adminRole) + .addNode(node1.address, staker1.address, peerId1, true); + + const nodeId = 1; + const role = ethers.utils.solidityKeccak256(["string"], ["poa"]); + const roleId = await nodeManager.roleIds(role); + + await expect( + nodeManager.connect(daoRole).setNodeRole(nodeId, roleId) + ).to.be.revertedWith("Invalid role ID."); + + await expect( + nodeManager.connect(daoRole).unsetNodeRole(nodeId, roleId) + ).be.revertedWith("Invalid role ID."); + }); + + it("the DAO should be able to add/set node roles", async () => { + const startTime = (await ethers.provider.getBlock("latest")).timestamp; + + await nodeManager + .connect(adminRole) + .addNode(node1.address, staker1.address, peerId1, true); + + const nodeId = 1; + const roleDeployers = ethers.utils.solidityKeccak256( + ["string"], + ["deployers"] + ); + await nodeManager.connect(daoRole).addNodeRole(roleDeployers); + const roleIdDeployers = await nodeManager.roleIds(roleDeployers); + await nodeManager.connect(daoRole).setNodeRole(nodeId, roleIdDeployers); + expect(await nodeManager.nodeHasRole(nodeId, roleDeployers)).to.be.true; + let node = await nodeManager.getNode(nodeId); + let nodeRoles = node.roles.map((role) => role.toNumber()); + expect(nodeRoles.includes(1)).to.be.true; + + const rolePoa = ethers.utils.solidityKeccak256(["string"], ["poa"]); + await nodeManager.connect(daoRole).addNodeRole(rolePoa); + const roleIdPoa = await nodeManager.roleIds(rolePoa); + await nodeManager.connect(daoRole).setNodeRole(nodeId, roleIdPoa); + expect(await nodeManager.nodeHasRole(nodeId, rolePoa)).to.be.true; + + const nodeRoleSetEvents = await nodeManager.queryFilter( + nodeManager.filters.NodeRoleSet(nodeId, null) + ); + + expect(nodeRoleSetEvents[0].args.nodeId).eq(nodeId); + expect(nodeRoleSetEvents[0].args.roleId).eq(roleIdDeployers); + + expect(nodeRoleSetEvents[1].args.nodeId).eq(nodeId); + expect(nodeRoleSetEvents[1].args.roleId).eq(roleIdPoa); + + const nodes = await nodeManager.getAllNodes(0, 1, 1000); + node = nodes[0]; + nodeRoles = node.roles.map((role) => role.toNumber()); + expect(nodeRoles).to.deep.equal([1, 2]); + + const editedNodes = await nodeManager.getAllNodes(startTime, 1, 1000); + node = editedNodes[0]; + nodeRoles = node.roles.map((role) => role.toNumber()); + expect(nodeRoles).to.deep.equal([1, 2]); + }); + + it("the DAO should be able to unset node roles", async () => { + const startTime = (await ethers.provider.getBlock("latest")).timestamp; + + await nodeManager + .connect(adminRole) + .addNode(node1.address, staker1.address, peerId1, true); + + const nodeId = 1; + const roleDeployers = ethers.utils.solidityKeccak256( + ["string"], + ["deployers"] + ); + await nodeManager.connect(daoRole).addNodeRole(roleDeployers); + const roleIdDeployers = await nodeManager.roleIds(roleDeployers); + await nodeManager.connect(daoRole).setNodeRole(nodeId, roleIdDeployers); + expect(await nodeManager.nodeHasRole(nodeId, roleDeployers)).to.be.true; + + const rolePoa = ethers.utils.solidityKeccak256(["string"], ["poa"]); + await nodeManager.connect(daoRole).addNodeRole(rolePoa); + const roleIdPoa = await nodeManager.roleIds(rolePoa); + await nodeManager.connect(daoRole).setNodeRole(nodeId, roleIdPoa); + expect(await nodeManager.nodeHasRole(nodeId, rolePoa)).to.be.true; + + let node = await nodeManager.getNode(nodeId); + let nodeRoles = node.roles.map((role) => role.toNumber()); + expect(nodeRoles).to.deep.equal([1, 2]); + + let nodes = await nodeManager.getAllNodes(0, 1, 1000); + node = nodes[0]; + nodeRoles = node.roles.map((role) => role.toNumber()); + expect(nodeRoles).to.deep.equal([1, 2]); + + let editedNodes = await nodeManager.getAllNodes(startTime, 1, 1000); + node = editedNodes[0]; + nodeRoles = node.roles.map((role) => role.toNumber()); + expect(nodeRoles).to.deep.equal([1, 2]); + + await nodeManager.connect(daoRole).unsetNodeRole(nodeId, roleIdDeployers); + expect(await nodeManager.nodeHasRole(nodeId, roleDeployers)).to.be.false; + expect(await nodeManager.nodeHasRole(nodeId, rolePoa)).to.be.true; + + node = await nodeManager.getNode(nodeId); + nodeRoles = node.roles.map((role) => role.toNumber()); + expect(nodeRoles.includes(1)).to.be.false; + expect(nodeRoles.includes(2)).to.be.true; + + nodes = await nodeManager.getAllNodes(0, 1, 1000); + node = nodes[0]; + nodeRoles = node.roles.map((role) => role.toNumber()); + expect(nodeRoles).to.deep.equal([2]); + + editedNodes = await nodeManager.getAllNodes(startTime, 1, 1000); + node = editedNodes[0]; + nodeRoles = node.roles.map((role) => role.toNumber()); + expect(nodeRoles).to.deep.equal([2]); + }); + + it("should retrieve contract information and configuration values", async () => { + // Add a node + await nodeManager + .connect(adminRole) + .addNode(node1.address, staker1.address, peerId1, true); + + // Add a node role + const roleDeployers = ethers.utils.solidityKeccak256( + ["string"], + ["deployers"] + ); + await nodeManager.connect(daoRole).addNodeRole(roleDeployers); + + // Set the config values + const configKeys = ["key1", "key2", "key3"]; + const configValues = ["value1", "value2", "value3"]; + for (let i = 0; i < configKeys.length; i++) { + await nodeManager.setConfig(configKeys[i], configValues[i]); + } + + const info = await nodeManager.getInfo(configKeys); + expect(info[0]).to.equal(await nodeManager.lastUpdateTime()); + expect(info[1]).to.equal(await nodeManager.lastNodeId()); + expect(info[2]).to.equal(await nodeManager.lastRoleId()); + expect(info[3]).to.deep.equal(configValues); + }); + }); + + describe("node tier", function () { + it("the DAO should be able to set node tier", async function () { + const nodeId = 1; + await nodeManager + .connect(adminRole) + .addNode(node1.address, staker1.address, peerId1, true); + + let node = await nodeManager.nodes(nodeId); + expect(node.tier).eq(0); + + const newTier = 2; + await nodeManager.setTier(nodeId, newTier); + node = await nodeManager.nodes(nodeId); + expect(node.tier).eq(newTier); + }); + }); +}); diff --git a/test/muonNodeStaking.ts b/test/muonNodeStaking.ts new file mode 100644 index 0000000..28bb192 --- /dev/null +++ b/test/muonNodeStaking.ts @@ -0,0 +1,1328 @@ +import { ethers, upgrades } from "hardhat"; +import { Signer } from "ethers"; +import { expect } from "chai"; +import axios from "axios"; + +import { + MuonNodeManager, + MuonNodeStaking, + PIONtest, + PIONlpTest, + BondedPION, +} from "../typechain-types"; + +describe("MuonNodeStaking", function () { + const ONE = ethers.utils.parseEther("1"); + + let deployer: Signer; + let daoRole: Signer; + let rewardRole: Signer; + let node1: Signer; + let node2: Signer; + let node3: Signer; + let staker1: Signer; + let staker2: Signer; + let staker3: Signer; + let user1: Signer; + let treasury: Signer; + + const peerId1 = "QmQ28Fae738pmSuhQPYtsDtwU8pKYPPgf76pSN61T3APh1"; + const peerId2 = "QmQ28Fae738pmSuhQPYtsDtwU8pKYPPgf76pSN61T3APh2"; + const peerId3 = "QmQ28Fae738pmSuhQPYtsDtwU8pKYPPgf76pSN61T3APh3"; + + let nodeManager: MuonNodeManager; + let pion: PIONtest; + let pionLp: PIONlpTest; + let nodeStaking: MuonNodeStaking; + let bondedPion: BondedPION; + const thirtyDays = 2592000; + const muonTokenMultiplier = ONE; + const muonLpTokenMultiplier = ONE.mul(2); + const muonAppId = + "1566432988060666016333351531685287278204879617528298155619493815104572633831"; + const muonPublicKey = { + x: "0x708f698d97949cd4385f02b1cc5283d394e9a7da68e3b6d2871c830b0751a5bb", + parity: 1, + }; + + const tier1 = 1; + const tier2 = 2; + const tier3 = 3; + + const tier1MaxStake = ONE.mul(1000); + const tier2MaxStake = ONE.mul(4000); + const tier3MaxStake = ONE.mul(10000); + + before(async () => { + [ + deployer, + daoRole, + rewardRole, + node1, + node2, + node3, + staker1, + staker2, + staker3, + user1, + treasury, + ] = await ethers.getSigners(); + }); + + beforeEach(async function () { + const PIONtest = await ethers.getContractFactory("PIONtest"); + pion = await upgrades.deployProxy(PIONtest, []); + await pion.deployed(); + + const PIONlpTest = await ethers.getContractFactory("PIONlpTest"); + pionLp = await PIONlpTest.connect(deployer).deploy(); + await pionLp.deployed(); + + const BondedPION = await ethers.getContractFactory("BondedPION"); + bondedPion = await upgrades.deployProxy(BondedPION, [ + pion.address, + treasury.address, + ]); + await bondedPion.deployed(); + + const MuonNodeManager = await ethers.getContractFactory("MuonNodeManager"); + nodeManager = await upgrades.deployProxy(MuonNodeManager, []); + await nodeManager.deployed(); + + const MuonNodeStaking = await ethers.getContractFactory("MuonNodeStaking"); + nodeStaking = await upgrades.deployProxy(MuonNodeStaking, [ + pion.address, + nodeManager.address, + muonAppId, + muonPublicKey, + bondedPion.address, + ]); + await nodeStaking.deployed(); + + await nodeStaking + .connect(deployer) + .grantRole(await nodeStaking.DAO_ROLE(), daoRole.address); + + await nodeStaking + .connect(deployer) + .grantRole(await nodeStaking.REWARD_ROLE(), rewardRole.address); + + await nodeStaking + .connect(daoRole) + .updateStakingTokens( + [pion.address, pionLp.address], + [muonTokenMultiplier, muonLpTokenMultiplier] + ); + + await bondedPion + .connect(deployer) + .grantRole( + await bondedPion.TRANSFERABLE_ADDRESS_ROLE(), + nodeStaking.address + ); + + await bondedPion.connect(deployer).whitelistTokens([pionLp.address]); + + await nodeStaking.connect(daoRole).setTierMaxStakeAmount(1, tier1MaxStake); + await nodeStaking.connect(daoRole).setTierMaxStakeAmount(2, tier2MaxStake); + await nodeStaking.connect(daoRole).setTierMaxStakeAmount(3, tier3MaxStake); + + await nodeManager + .connect(deployer) + .grantRole(await nodeManager.ADMIN_ROLE(), nodeStaking.address); + + await nodeManager + .connect(deployer) + .grantRole(await nodeManager.DAO_ROLE(), daoRole.address); + + await pion.connect(deployer).mint(rewardRole.address, ONE.mul(2000000)); + + await mintBondedPion(ONE.mul(1000), ONE.mul(1000), staker1); + await bondedPion.connect(staker1).approve(nodeStaking.address, 1); + await nodeStaking.connect(staker1).addMuonNode(node1.address, peerId1, 1); + await nodeStaking.connect(daoRole).setMuonNodeTier(staker1.address, tier1); + + await mintBondedPion(ONE.mul(1000), ONE.mul(500), staker2); + await bondedPion.connect(staker2).approve(nodeStaking.address, 2); + await nodeStaking.connect(staker2).addMuonNode(node2.address, peerId2, 2); + await nodeStaking.connect(daoRole).setMuonNodeTier(staker2.address, tier2); + }); + + const getDummySig = async ( + stakerAddress, + paidReward, + rewardPerToken, + amount + ) => { + const response = await axios.get( + `http://localhost:8000/v1/?app=tss_reward_oracle_test&method=reward¶ms[stakerAddress]=${stakerAddress}¶ms[paidReward]=${paidReward}¶ms[rewardPerToken]=${rewardPerToken}¶ms[amount]=${amount}` + ); + return response.data; + }; + + const mintBondedPion = async (pionAmount, pionLpAmount, _to) => { + await pion.connect(deployer).mint(_to.address, pionAmount); + await pion.connect(_to).approve(bondedPion.address, pionAmount); + + await pionLp.connect(deployer).mint(_to.address, pionLpAmount); + await pionLp.connect(_to).approve(bondedPion.address, pionLpAmount); + + const tx = await bondedPion + .connect(_to) + .mintAndLock( + [pion.address, pionLp.address], + [pionAmount, pionLpAmount], + _to.address + ); + const receipt = await tx.wait(); + const tokenId = receipt.events[0].args.tokenId.toNumber(); + return tokenId; + }; + + const distributeRewards = async (rewardAmount) => { + await pion.connect(rewardRole).transfer(nodeStaking.address, rewardAmount); + await nodeStaking.connect(rewardRole).distributeRewards(rewardAmount); + }; + + const evmIncreaseTime = async (amount) => { + await ethers.provider.send("evm_increaseTime", [amount]); + await ethers.provider.send("evm_mine", []); + }; + + const getReward = async (staker, tssSig) => { + const reqId = tssSig["result"]["reqId"]; + const rewardPerToken = tssSig["result"]["data"]["signParams"][4]["value"]; + const amount = tssSig["result"]["data"]["signParams"][5]["value"]; + const sig = { + signature: tssSig["result"]["signatures"][0]["signature"], + owner: tssSig["result"]["signatures"][0]["owner"], + nonce: tssSig["result"]["data"]["init"]["nonceAddress"], + }; + await nodeStaking + .connect(staker) + .getReward(amount, rewardPerToken, reqId, sig); + }; + + describe("add node", function () { + it("should successfully add Muon nodes", async function () { + const info1 = await nodeManager.nodeAddressInfo(node1.address); + expect(info1.id).eq(1); + expect(info1.nodeAddress).eq(node1.address); + expect(info1.stakerAddress).eq(staker1.address); + expect(info1.peerId).eq(peerId1); + expect(info1.active).to.be.true; + expect(info1.tier).eq(tier1); + expect(info1.roles).to.be.an("array").that.is.empty; + expect(info1.startTime).to.be.greaterThan(0); + expect(info1.endTime).eq(0); + expect(info1.lastEditTime).to.be.greaterThan(info1.startTime); + + const u1 = await nodeStaking.users(staker1.address); + const u1NFTvalue = await nodeStaking.valueOfBondedToken(u1.tokenId); + expect(u1.balance) + .eq(BigInt(Math.min(tier1MaxStake, u1NFTvalue))) + .eq(tier1MaxStake); + expect(u1.paidReward).eq(0); + expect(u1.paidRewardPerToken).eq(0); + expect(u1.pendingRewards).eq(0); + expect(u1.tokenId).eq(1); + + const info2 = await nodeManager.nodeAddressInfo(node2.address); + expect(info2.id).eq(2); + expect(info2.nodeAddress).eq(node2.address); + expect(info2.stakerAddress).eq(staker2.address); + expect(info2.peerId).eq(peerId2); + expect(info2.active).to.be.true; + expect(info2.tier).eq(tier2); + expect(info2.roles).to.be.an("array").that.is.empty; + expect(info2.startTime).to.be.greaterThan(0); + expect(info2.endTime).eq(0); + expect(info2.lastEditTime).to.be.greaterThan(info2.startTime); + + const u2 = await nodeStaking.users(staker2.address); + const u2NFTvalue = await nodeStaking.valueOfBondedToken(u2.tokenId); + expect(u2.balance) + .eq(BigInt(Math.min(tier2MaxStake, u2NFTvalue))) + .eq(u2NFTvalue); + expect(u2.paidReward).eq(0); + expect(u2.paidRewardPerToken).eq(0); + expect(u2.pendingRewards).eq(0); + expect(u2.tokenId).eq(2); + }); + + it("should reject Muon nodes with insufficient stake", async function () { + const tokenId = await mintBondedPion(ONE.mul(1), ONE.mul(1), staker3); + await expect( + nodeStaking + .connect(staker3) + .addMuonNode(node3.address, peerId3, tokenId) + ).to.be.revertedWith("Insufficient staking."); + }); + + it("nodes are restricted from staking more than the MaxStakeAmount of their tier", async function () { + const tokenId = await mintBondedPion( + ONE.mul(10000), + ONE.mul(10000), + staker3 + ); + await bondedPion.connect(staker3).approve(nodeStaking.address, tokenId); + await nodeStaking + .connect(staker3) + .addMuonNode(node3.address, peerId3, tokenId); + const node = await nodeManager.nodeAddressInfo(node3.address); + expect((await nodeStaking.users(staker3.address)).tokenId).eq(tokenId); + expect(await nodeStaking.valueOfBondedToken(tokenId)).eq(ONE.mul(30000)); + // newly added nodes' tiers are 0, so their maximum stake amount will be 0 + expect(node.tier).eq(0); + expect((await nodeStaking.users(staker3.address)).balance).eq(0); + // admins can set tier + await nodeStaking + .connect(daoRole) + .setMuonNodeTier(staker3.address, tier1); + expect((await nodeStaking.users(staker3.address)).balance) + .eq(await nodeStaking.tiersMaxStakeAmount(1)) + .eq(tier1MaxStake); + }); + }); + + describe("staking", function () { + it("should transfer the NFT from the staker to the staking contract upon adding a Muon node", async function () { + const tokenId = await mintBondedPion( + ONE.mul(10000), + ONE.mul(10000), + staker3 + ); + expect(await bondedPion.ownerOf(tokenId)).eq(staker3.address); + await bondedPion.connect(staker3).approve(nodeStaking.address, tokenId); + await nodeStaking + .connect(staker3) + .addMuonNode(node3.address, peerId3, tokenId); + expect(await bondedPion.ownerOf(tokenId)).eq(nodeStaking.address); + }); + + it("stakers should be able to increase their stakes by locking additional tokens in the NFT", async function () { + const nodeId = 2; + const tokenId = (await nodeStaking.users(staker2.address)).tokenId; + const lockeds1 = await bondedPion.getLockedOf(tokenId, [ + pion.address, + pionLp.address, + ]); + const userStake1 = (await nodeStaking.users(staker2.address)).balance; + const value1 = await nodeStaking.valueOfBondedToken(tokenId); + + const node = await nodeManager.nodes(nodeId); + const maxStakeAmount = await nodeStaking.tiersMaxStakeAmount(node.tier); + + // mint required tokens for staker + const pionAmount = ONE.mul(1000); + const pionLpAmount = ONE.mul(1000); + await pion.connect(deployer).mint(staker2.address, pionAmount); + await pionLp.connect(deployer).mint(staker2.address, pionLpAmount); + + // approve tokens to nodeStaking contract + await pion.connect(staker2).approve(nodeStaking.address, pionAmount); + await pionLp.connect(staker2).approve(nodeStaking.address, pionLpAmount); + + // lock tokens into the NFT + await nodeStaking + .connect(staker2) + .lockToBondedToken( + [pion.address, pionLp.address], + [pionAmount, pionLpAmount] + ); + + const lockeds2 = await bondedPion.getLockedOf(tokenId, [ + pion.address, + pionLp.address, + ]); + const value2 = await nodeStaking.valueOfBondedToken(tokenId); + const userStake2 = (await nodeStaking.users(staker2.address)).balance; + + expect(lockeds2[0]).eq(pionAmount.add(lockeds1[0])); + expect(lockeds2[1]).eq(pionLpAmount.add(lockeds1[1])); + expect(value2).eq(value1.add(pionAmount).add(pionLpAmount.mul(2))); + expect(userStake2) + .eq(BigInt(Math.min(value2, maxStakeAmount))) + .eq(maxStakeAmount); + }); + + it("stakers should have the ability to increase their stakes by merging another NFT", async function () { + const nodeId = 2; + const tokenId = (await nodeStaking.users(staker2.address)).tokenId; + const lockeds1 = await bondedPion.getLockedOf(tokenId, [ + pion.address, + pionLp.address, + ]); + const userStake1 = (await nodeStaking.users(staker2.address)).balance; + const value1 = await nodeStaking.valueOfBondedToken(tokenId); + + const node = await nodeManager.nodes(nodeId); + const maxStakeAmount = await nodeStaking.tiersMaxStakeAmount(node.tier); + + // mint required bondedPion NFT for staker + const pionAmount = ONE.mul(1000); + const pionLpAmount = ONE.mul(1000); + const newTokenId = await mintBondedPion( + pionAmount, + pionLpAmount, + staker2 + ); + + // approve NFT to nodeStaking contract + await bondedPion + .connect(staker2) + .approve(nodeStaking.address, newTokenId); + + // lock tokens into the NFT + await nodeStaking.connect(staker2).mergeBondedTokens(newTokenId); + + const lockeds2 = await bondedPion.getLockedOf(tokenId, [ + pion.address, + pionLp.address, + ]); + + const value2 = await nodeStaking.valueOfBondedToken(tokenId); + const userStake2 = (await nodeStaking.users(staker2.address)).balance; + + expect(lockeds2[0]).eq(pionAmount.add(lockeds1[0])); + expect(lockeds2[1]).eq(pionLpAmount.add(lockeds1[1])); + expect(value2).eq(value1.add(pionAmount).add(pionLpAmount.mul(2))); + expect(userStake2) + .eq(BigInt(Math.min(value2, maxStakeAmount))) + .eq(maxStakeAmount); + }); + }); + + describe("distribute rewards", function () { + it("should accurately update rewards following distribution", async function () { + const totalStaked = await nodeStaking.totalStaked(); + // set initial reward as a multiplier of 30 days and total stake to make sure there is no leftover + const initialReward = (thirtyDays * totalStaked) / 10 ** 18; + await distributeRewards(initialReward); + const rewardPeriod = await nodeStaking.REWARD_PERIOD(); + expect(rewardPeriod).to.be.equal(60 * 60 * 24 * 30); + + const expectedRewardRate = initialReward / rewardPeriod; + const rewardRate = await nodeStaking.rewardRate(); + expect(expectedRewardRate).to.be.equal(rewardRate); + + // Increase time by 15 days + const fifteenDays = 60 * 60 * 24 * 15; + await evmIncreaseTime(fifteenDays); + + const rewardPerToken = await nodeStaking.rewardPerToken(); + const expectedRewardPerToken = parseInt( + (fifteenDays * rewardRate * 10 ** 18) / totalStaked + ); + expect(rewardPerToken).to.be.equal(expectedRewardPerToken); + + let staker1Reward = await nodeStaking.earned(staker1.address); + let staker2Reward = await nodeStaking.earned(staker2.address); + expect(staker1Reward.add(staker2Reward)).to.be.equal(initialReward / 2); + + const staker1ExpectedReward = initialReward / 6; + const staker1ActualReward = await nodeStaking.earned(staker1.address); + expect(staker1ActualReward).to.be.equal(staker1ExpectedReward); + + const staker2ExpectedReward = initialReward / 3; + const staker2ActualReward = await nodeStaking.earned(staker2.address); + expect(staker2ActualReward).to.be.equal(staker2ExpectedReward); + }); + + it("should accurately update rewards after new nodes join", async function () { + await mintBondedPion(ONE.mul(1000), ONE.mul(1000), staker3); + await bondedPion.connect(staker3).approve(nodeStaking.address, 3); + + // set initial reward as a multiplier of 30 days and total stake to make sure there is no leftover + const initialReward = thirtyDays * 18000; + await distributeRewards(initialReward); + const distributeTimestamp = (await ethers.provider.getBlock("latest")) + .timestamp; + + // Increase time by 10 days + const tenDays = 60 * 60 * 24 * 10; + let targetTimestamp = distributeTimestamp + tenDays; + await ethers.provider.send("evm_setNextBlockTimestamp", [ + targetTimestamp, + ]); + + // add new node + await nodeStaking.connect(staker3).addMuonNode(node3.address, peerId3, 3); + await nodeStaking + .connect(daoRole) + .setMuonNodeTier(staker3.address, tier2); + + // Increase time by 10 days + targetTimestamp = distributeTimestamp + 2 * tenDays; + await ethers.provider.send("evm_setNextBlockTimestamp", [ + targetTimestamp, + ]); + await ethers.provider.send("evm_mine", []); + + const staker1ExpectedReward = + (tenDays * 18000) / 3 + (tenDays * 18000) / 6; + const staker1ActualReward = await nodeStaking.earned(staker1.address); + // tolerance for 2 seconds + expect(staker1ActualReward).to.closeTo(staker1ExpectedReward, 18000); + + const staker2ExpectedReward = + ((tenDays * 18000) / 3) * 2 + ((tenDays * 18000) / 6) * 2; + const staker2ActualReward = await nodeStaking.earned(staker2.address); + // tolerance for 2 seconds + expect(staker2ActualReward).to.closeTo(staker2ExpectedReward, 18000); + + const staker3ExpectedReward = ((tenDays * 18000) / 6) * 3; + const staker3ActualReward = await nodeStaking.earned(staker3.address); + // tolerance for 2 seconds + expect(staker3ActualReward).to.closeTo(staker3ExpectedReward, 18000); + }); + + it("should accurately update rewards after increasing the locked amount", async function () { + const tenDays = 60 * 60 * 24 * 10; + + await pion.connect(deployer).mint(staker2.address, ONE.mul(1000)); + await pion.connect(staker2).approve(bondedPion.address, ONE.mul(1000)); + await bondedPion + .connect(staker2) + .lock(2, [pion.address], [ONE.mul(1000)]); + + // set initial reward as a multiplier of 30 days and total stake to make sure there is no leftover + const initialReward = thirtyDays * 12000; + await distributeRewards(initialReward); + const distributeTimestamp = (await ethers.provider.getBlock("latest")) + .timestamp; + + // Increase time by 10 days + let targetTimestamp = distributeTimestamp + tenDays; + await ethers.provider.send("evm_setNextBlockTimestamp", [ + targetTimestamp, + ]); + + const staker2Stake1 = await nodeStaking.users(staker2.address); + // lock more + await nodeStaking.connect(staker2).updateStaking(); + const staker2Stake2 = await nodeStaking.users(staker2.address); + expect(staker2Stake2.balance).to.be.equal( + staker2Stake1.balance.add(ONE.mul(1000)) + ); + expect(staker2Stake2.pendingRewards).to.be.equal( + (tenDays * 12000 * 2) / 3 + ); + expect(staker2Stake2.balance).to.be.equal(ONE.mul(3000)); + + const staker1ExpectedReward1 = (tenDays * 12000) / 3; + const staker1ActualReward1 = await nodeStaking.earned(staker1.address); + expect(staker1ActualReward1).to.be.equal(staker1ExpectedReward1); + + // Increase time by 10 days + targetTimestamp = distributeTimestamp + 2 * tenDays; + await ethers.provider.send("evm_setNextBlockTimestamp", [ + targetTimestamp, + ]); + await ethers.provider.send("evm_mine", []); + + const staker1ExpectedReward = + (tenDays * 12000) / 3 + (tenDays * 12000) / 4; + const staker1ActualReward = await nodeStaking.earned(staker1.address); + expect(staker1ActualReward).to.be.equal(staker1ExpectedReward); + + const staker2ExpectedReward = + ((tenDays * 12000) / 3) * 2 + ((tenDays * 12000) / 4) * 3; + const staker2ActualReward = await nodeStaking.earned(staker2.address); + expect(staker2ActualReward).to.be.equal(staker2ExpectedReward); + }); + + it("should accurately update rewards after two distributions", async function () { + // Distribute initial rewards and wait 10 days then distribute additionalReward + const initialReward = thirtyDays * 3000; + const additionalReward = thirtyDays * 4000; + await pion + .connect(rewardRole) + .transfer(nodeStaking.address, initialReward); + + await pion + .connect(rewardRole) + .transfer(nodeStaking.address, additionalReward); + + await nodeStaking.connect(rewardRole).distributeRewards(initialReward); + const distributeTimestamp = (await ethers.provider.getBlock("latest")) + .timestamp; + + const rewardPeriod = await nodeStaking.REWARD_PERIOD(); + let expectedRewardRate = initialReward / rewardPeriod; + let actualRewardRate = await nodeStaking.rewardRate(); + expect(expectedRewardRate).to.be.equal(actualRewardRate); + + // Increase time by 10 days + const tenDays = 60 * 60 * 24 * 10; + let targetTimestamp = distributeTimestamp + tenDays; + await ethers.provider.send("evm_setNextBlockTimestamp", [ + targetTimestamp, + ]); + + await nodeStaking.connect(rewardRole).distributeRewards(additionalReward); + + const newRevard = additionalReward + (initialReward / 3) * 2; + expectedRewardRate = await nodeStaking.rewardRate(); + actualRewardRate = await nodeStaking.rewardRate(); + expect(expectedRewardRate).to.be.equal(actualRewardRate); + + // Increase time by 10 days + targetTimestamp = distributeTimestamp + 2 * tenDays; + await ethers.provider.send("evm_setNextBlockTimestamp", [ + targetTimestamp, + ]); + await ethers.provider.send("evm_mine", []); + + let staker1ExpectedReward = (tenDays * 3000) / 3 + (tenDays * 6000) / 3; + let staker1ActualReward = await nodeStaking.earned(staker1.address); + expect(staker1ActualReward).to.be.equal(staker1ExpectedReward); + + let staker2ExpectedReward = + ((tenDays * 3000) / 3) * 2 + ((tenDays * 6000) / 3) * 2; + let staker2ActualReward = await nodeStaking.earned(staker2.address); + expect(staker2ActualReward).to.be.equal(staker2ExpectedReward); + }); + }); + + describe("withdraw", function () { + it("should prohibit non-stakers from withdrawing", async function () { + // Distribute rewards + const initialReward = thirtyDays * 3000; + await distributeRewards(initialReward); + + // Increase time by 15 days + await evmIncreaseTime(60 * 60 * 24 * 15); + + // generate a dummy tts sig to withdraw 85% of the maximum reward + const paidReward = (await nodeStaking.users(staker1.address)).paidReward; + const rewardPerToken = await nodeStaking.rewardPerToken(); + const reward85 = parseInt( + ((await nodeStaking.earned(staker1.address)) * 85) / 100 + ); + const withdrawSig = await getDummySig( + staker1.address, + paidReward, + rewardPerToken, + reward85 + ); + + // try to getReward by non-stakers + await expect(getReward(user1, withdrawSig)).to.be.revertedWith( + "Node not found." + ); + }); + + it("stakers should be able to withdraw their rewards using a TSS network signature", async function () { + // Distribute rewards + const initialReward = thirtyDays * 3000; + await distributeRewards(initialReward); + + // Increase time by 15 days + await evmIncreaseTime(60 * 60 * 24 * 15); + + // Check staker1's balance before withdrawal + const balance1 = await pion.balanceOf(staker1.address); + + // generate a dummy tts sig to withdraw 85% of the maximum reward + const paidReward = (await nodeStaking.users(staker1.address)).paidReward; + const rewardPerToken = await nodeStaking.rewardPerToken(); + const earned1 = await nodeStaking.earned(staker1.address); + const reward85 = parseInt((earned1 * 85) / 100); + const withdrawSig = await getDummySig( + staker1.address, + paidReward, + rewardPerToken, + reward85 + ); + + // withdraw 85% of reward + await getReward(staker1, withdrawSig); + + // check the result of withdrawing + const staker1Stake = await nodeStaking.users(staker1.address); + expect(staker1Stake.paidReward).eq(reward85); + expect(staker1Stake.paidRewardPerToken).eq(rewardPerToken); + expect(await pion.balanceOf(staker1.address)).eq(balance1.add(reward85)); + + // tolerance for 2 seconds + expect(await nodeStaking.earned(staker1.address)).to.closeTo(0, 3000); + }); + + it("should prevent stakers from reusing the same TSS network signature", async function () { + // Distribute rewards + await distributeRewards(thirtyDays * 3000); + + // Increase time by 15 days + await evmIncreaseTime(60 * 60 * 24 * 15); + + // Check staker1's balance before withdrawal + const balance1 = await pion.balanceOf(staker1.address); + + // generate a dummy tts sig to withdraw 85% of the maximum reward + const paidReward = (await nodeStaking.users(staker1.address)).paidReward; + const rewardPerToken = await nodeStaking.rewardPerToken(); + const earned1 = await nodeStaking.earned(staker1.address); + const reward85 = parseInt((earned1 * 85) / 100); + const withdrawSig = await getDummySig( + staker1.address, + paidReward, + rewardPerToken, + reward85 + ); + + // withdraw 85% of reward + await getReward(staker1, withdrawSig); + + expect(await pion.balanceOf(staker1.address)).eq(balance1.add(reward85)); + + // try to withdraw again + await expect(getReward(staker1, withdrawSig)).to.be.revertedWith( + "Invalid signature." + ); + }); + + it("stakers should have the ability to withdraw their rewards multiple times", async function () { + // Distribute rewards + const initialReward = thirtyDays * 3000; + await distributeRewards(initialReward); + + // Increase time by 10 days + await evmIncreaseTime(60 * 60 * 24 * 10); + + // Check staker1's balance before withdrawal + const balance1 = await pion.balanceOf(staker1.address); + + // generate a dummy tts sig to withdraw 80% of the maximum reward + const paidReward1 = (await nodeStaking.users(staker1.address)).paidReward; + const rewardPerToken1 = await nodeStaking.rewardPerToken(); + const earned1 = await nodeStaking.earned(staker1.address); + const withdrawSig1 = await getDummySig( + staker1.address, + paidReward1, + rewardPerToken1, + (earned1 * 80) / 100 + ); + + const notPaidRewards1 = await nodeStaking.notPaidRewards(); + expect(notPaidRewards1).eq(0); + + // withdraw 100% of reward + await getReward(staker1, withdrawSig1); + + const notPaidRewards2 = await nodeStaking.notPaidRewards(); + expect(notPaidRewards2).eq((earned1 * 20) / 100); + + // check the result of withdrawing + const staker1Stake1 = await nodeStaking.users(staker1.address); + expect(staker1Stake1.paidReward).eq((earned1 * 80) / 100); + expect(staker1Stake1.paidRewardPerToken).eq(rewardPerToken1); + const balance2 = await pion.balanceOf(staker1.address); + expect(balance2).eq(balance1.add((earned1 * 80) / 100)); + + // Increase time by 5 days + await evmIncreaseTime(60 * 60 * 24 * 5); + + // generate a dummy tts sig to withdraw 100% of the maximum reward + const paidReward2 = (await nodeStaking.users(staker1.address)).paidReward; + const rewardPerToken2 = await nodeStaking.rewardPerToken(); + const earned2 = await nodeStaking.earned(staker1.address); + const withdrawSig2 = await getDummySig( + staker1.address, + paidReward2, + rewardPerToken2, + earned2 + ); + + // withdraw 100% of reward + await getReward(staker1, withdrawSig2); + + const notPaidRewards3 = await nodeStaking.notPaidRewards(); + expect(notPaidRewards3).eq(notPaidRewards2); + + // check the result of withdrawing + const staker1Stake2 = await nodeStaking.users(staker1.address); + expect(staker1Stake2.paidReward).eq(earned2.add((earned1 * 80) / 100)); + expect(staker1Stake2.paidRewardPerToken).eq(rewardPerToken2); + const balance3 = await pion.balanceOf(staker1.address); + expect(balance3).eq(balance2.add(earned2)); + + // Increase time by 5 days + await evmIncreaseTime(60 * 60 * 24 * 5); + + // generate a dummy tts sig to withdraw 50% of the maximum reward + const paidReward3 = (await nodeStaking.users(staker1.address)).paidReward; + const rewardPerToken3 = await nodeStaking.rewardPerToken(); + const earned3 = await nodeStaking.earned(staker1.address); + const withdrawSig3 = await getDummySig( + staker1.address, + paidReward3, + rewardPerToken3, + (earned3 * 50) / 100 + ); + + // withdraw 100% of reward + await getReward(staker1, withdrawSig3); + + const notPaidRewards4 = await nodeStaking.notPaidRewards(); + expect(notPaidRewards4).eq(notPaidRewards3.add((earned3 * 50) / 100)); + + // check the result of withdrawing + const staker1Stake3 = await nodeStaking.users(staker1.address); + expect(staker1Stake3.paidReward).eq( + staker1Stake2.paidReward.add((earned3 * 50) / 100) + ); + expect(staker1Stake3.paidRewardPerToken).eq(rewardPerToken3); + const balance4 = await pion.balanceOf(staker1.address); + expect(balance4).eq(balance3.add((earned3 * 50) / 100)); + + // Redistribute rewards + const periodFinish = await nodeStaking.periodFinish(); + const now = await nodeStaking.lastTimeRewardApplicable(); + const rewardRate = await nodeStaking.rewardRate(); + const remaining = periodFinish.sub(now); + const leftover = remaining.mul(rewardRate); + const expectedRewardRate = Math.floor( + leftover.add(notPaidRewards4).div(30 * 24 * 60 * 60) + ); + await distributeRewards(0); + const newRewardRate = await nodeStaking.rewardRate(); + expect(BigInt(expectedRewardRate)).eq(newRewardRate); + + const notPaidRewards5 = await nodeStaking.notPaidRewards(); + expect(notPaidRewards5).eq(0); + }); + + it("should disallow stakers from withdrawing more than their rewards by obtaining multiple signatures", async function () { + // Distribute rewards + const initialReward = thirtyDays * 3000; + await distributeRewards(initialReward); + + // Increase time by 10 days + await evmIncreaseTime(60 * 60 * 24 * 10); + + // Check staker1's balance before withdrawal + const balance1 = await pion.balanceOf(staker1.address); + + // get first tts sig + const paidReward1 = (await nodeStaking.users(staker1.address)).paidReward; + const rewardPerToken1 = await nodeStaking.rewardPerToken(); + const earned1 = await nodeStaking.earned(staker1.address); + const withdrawSig1 = await getDummySig( + staker1.address, + paidReward1, + rewardPerToken1, + earned1 + ); + + // Increase time by 1 minute + await evmIncreaseTime(60); + + // get second tts sig + const paidReward2 = (await nodeStaking.users(staker1.address)).paidReward; + const rewardPerToken2 = await nodeStaking.rewardPerToken(); + const earned2 = await nodeStaking.earned(staker1.address); + const withdrawSig2 = await getDummySig( + staker1.address, + paidReward2, + rewardPerToken2, + earned2 + ); + + // withdraw first time + await getReward(staker1, withdrawSig1); + + // check the result of withdrawing + const staker1Stake1 = await nodeStaking.users(staker1.address); + expect(staker1Stake1.paidReward).eq(earned1); + expect(staker1Stake1.paidRewardPerToken).eq(rewardPerToken1); + const balance2 = await pion.balanceOf(staker1.address); + expect(balance2).eq(balance1.add(earned1)); + + // try to withdraw second time + await expect(getReward(staker1, withdrawSig2)).to.be.revertedWith( + "Invalid signature." + ); + }); + + it("should enable DAO_ROLE to deactive nodes then stakers hsould be able to withdraw their stake and rewards after the lock period", async function () { + // Distribute rewards + const initialReward = thirtyDays * 3000; + await distributeRewards(initialReward); + + // Increase time by 10 days + await evmIncreaseTime(60 * 60 * 24 * 10); + + const earned1 = await nodeStaking.earned(staker1.address); + const totalStakedBefore = await nodeStaking.totalStaked(); + const u1 = await nodeStaking.users(staker1.address); + expect(u1.balance).eq(ONE.mul(1000)); + + // deactiveMuonNode + await nodeStaking.connect(daoRole).deactiveMuonNode(staker1.address); + + expect(await nodeStaking.totalStaked()).eq( + BigInt(totalStakedBefore) - BigInt(u1.balance) + ); + + const u2 = await nodeStaking.users(staker1.address); + expect(u2.balance).eq(0); + expect(u2.pendingRewards).to.closeTo(earned1, 2000); + expect(u2.tokenId).eq(1); + + expect(await nodeStaking.earned(staker1.address)).to.be.equal( + u2.pendingRewards + ); + + expect((await nodeManager.stakerAddressInfo(staker1.address)).active).to + .be.false; + + const balance1 = await pion.balanceOf(staker1.address); + + // generate a dummy tts sig to withdraw 80% of the maximum reward + const paidReward = (await nodeStaking.users(staker1.address)).paidReward; + const rewardPerToken = await nodeStaking.rewardPerToken(); + const earned2 = parseInt((u2.pendingRewards * 80) / 100); + const withdrawSig = await getDummySig( + staker1.address, + paidReward, + rewardPerToken, + earned2 + ); + // withdraw 80% of reward + await getReward(staker1, withdrawSig); + + const balance2 = await pion.balanceOf(staker1.address); + expect(balance2).to.closeTo(balance1.add(earned2), 2000); + + // Increase time by 7 days + await evmIncreaseTime(60 * 60 * 24 * 7); + + expect(await bondedPion.ownerOf(1)).eq(nodeStaking.address); + + // withdraw + await nodeStaking.connect(staker1).withdraw(); + + const u3 = await nodeStaking.users(staker1.address); + expect(u3.balance).eq(0); + expect(u3.pendingRewards).eq(0); + expect(u3.tokenId).eq(0); + expect(await bondedPion.ownerOf(1)).eq(staker1.address); + + expect(await nodeStaking.earned(staker1.address)).to.be.equal(0); + }); + + it("should enable exited stakers to withdraw their stake and rewards after the lock period", async function () { + // Distribute rewards + const initialReward = thirtyDays * 3000; + await distributeRewards(initialReward); + + // Increase time by 10 days + await evmIncreaseTime(60 * 60 * 24 * 10); + + // generate a dummy tts sig to withdraw 100% of the maximum reward + const paidReward1 = (await nodeStaking.users(staker1.address)).paidReward; + const rewardPerToken1 = await nodeStaking.rewardPerToken(); + const earned1 = await nodeStaking.earned(staker1.address); + const withdrawSig1 = await getDummySig( + staker1.address, + paidReward1, + rewardPerToken1, + earned1 + ); + // withdraw 100% of reward + await getReward(staker1, withdrawSig1); + + const balance1 = await pion.balanceOf(staker1.address); + + // tolerance for 2 seconds + expect(await nodeStaking.earned(staker1.address)).to.be.closeTo(0, 3000); + + const u1 = await nodeStaking.users(staker1.address); + expect(u1.balance).eq(ONE.mul(1000)); + expect(u1.pendingRewards).eq(0); + expect(u1.tokenId).eq(1); + + // Increase time by 10 days + await evmIncreaseTime(60 * 60 * 24 * 10); + + const earned2 = await nodeStaking.earned(staker1.address); + const totalStakedBefore = await nodeStaking.totalStaked(); + + // requestExit + await nodeStaking.connect(staker1).requestExit(); + + expect(await nodeStaking.totalStaked()).eq( + BigInt(totalStakedBefore) - BigInt(u1.balance) + ); + + const u2 = await nodeStaking.users(staker1.address); + expect(u2.balance).eq(0); + expect(u2.pendingRewards).to.closeTo(earned2, 2000); + expect(u2.tokenId).eq(1); + + expect(await nodeStaking.earned(staker1.address)).to.be.equal( + u2.pendingRewards + ); + + // generate a dummy tts sig to withdraw 80% of the maximum reward + const paidReward2 = (await nodeStaking.users(staker1.address)).paidReward; + const rewardPerToken2 = await nodeStaking.rewardPerToken(); + const earned3 = parseInt((u2.pendingRewards * 80) / 100); + const withdrawSig2 = await getDummySig( + staker1.address, + paidReward2, + rewardPerToken2, + earned3 + ); + // withdraw 80% of reward + await getReward(staker1, withdrawSig2); + + const balance2 = await pion.balanceOf(staker1.address); + expect(balance2).to.closeTo(balance1.add(earned3), 2000); + + // try to withdraw stake amount + await expect(nodeStaking.connect(staker1).withdraw()).to.be.revertedWith( + "The exit time has not been reached yet." + ); + + // Increase time by 7 days + await evmIncreaseTime(60 * 60 * 24 * 7); + + expect(await bondedPion.ownerOf(1)).eq(nodeStaking.address); + + // withdraw + await nodeStaking.connect(staker1).withdraw(); + + const u3 = await nodeStaking.users(staker1.address); + expect(u3.balance).eq(0); + expect(u3.pendingRewards).eq(0); + expect(u3.tokenId).eq(0); + expect(await bondedPion.ownerOf(1)).eq(staker1.address); + + expect(await nodeStaking.earned(staker1.address)).to.be.equal(0); + }); + + it("should disallow stakers from withdrawing their stake if it is locked", async function () { + // Distribute rewards + const initialReward = thirtyDays * 3000; + await distributeRewards(initialReward); + + const rewardRate = await nodeStaking.rewardRate(); + + // Increase time by 10 days + await evmIncreaseTime(60 * 60 * 24 * 10); + + // try to lock non exist staker + await expect( + nodeStaking.connect(rewardRole).setStakeLockStatus(user1.address, true) + ).to.be.revertedWith("Node not found."); + + // try to unlock not locked staker + await expect( + nodeStaking + .connect(rewardRole) + .setStakeLockStatus(staker1.address, false) + ).to.be.revertedWith("Already unlocked."); + + const earned1 = await nodeStaking.earned(staker1.address); + + // requestExit + await nodeStaking.connect(staker1).requestExit(); + + // lock the stake + await nodeStaking + .connect(rewardRole) + .setStakeLockStatus(staker1.address, true); + + // Increase time by 7 days + await evmIncreaseTime(60 * 60 * 24 * 7); + + const u1 = await nodeStaking.users(staker1.address); + expect(u1.balance).eq(0); + expect(u1.pendingRewards).to.closeTo(earned1, rewardRate); + expect(u1.paidReward).eq(0); + + // try to withdraw the stake + await expect(nodeStaking.connect(staker1).withdraw()).to.be.revertedWith( + "Stake is locked." + ); + + // unlock the stake + await nodeStaking + .connect(rewardRole) + .setStakeLockStatus(staker1.address, false); + + expect(await bondedPion.ownerOf(1)).eq(nodeStaking.address); + + // withdraw + await nodeStaking.connect(staker1).withdraw(); + + const u2 = await nodeStaking.users(staker1.address); + expect(u2.balance).eq(0); + expect(u2.pendingRewards).eq(u1.pendingRewards); + expect(u2.paidReward).eq(0); + + expect(await nodeStaking.earned(staker1.address)).to.be.equal( + u1.pendingRewards + ); + + expect(await bondedPion.ownerOf(1)).eq(staker1.address); + + // exited nodes should be able to get their unclaimed reward + const paidReward = u2.paidReward; + const rewardPerToken = await nodeStaking.rewardPerToken(); + const earned = u2.pendingRewards; + const withdrawSig = await getDummySig( + staker1.address, + paidReward, + rewardPerToken, + earned + ); + // withdraw reward + await getReward(staker1, withdrawSig); + + const balance2 = await pion.balanceOf(staker1.address); + expect(balance2).eq(u2.pendingRewards); + + const u3 = await nodeStaking.users(staker1.address); + expect(u3.balance).eq(0); + expect(u3.pendingRewards).eq(0); + expect(u3.paidReward).eq(u2.pendingRewards); + + expect(await nodeStaking.earned(staker1.address)).to.be.equal(0); + }); + }); + + describe("DAO functions", function () { + it("DAO should have the ability to update the exitPendingPeriod", async function () { + const newVal = 86400; + await expect(nodeStaking.connect(daoRole).setExitPendingPeriod(newVal)) + .to.emit(nodeStaking, "ExitPendingPeriodUpdated") + .withArgs(newVal); + + expect(await nodeStaking.exitPendingPeriod()).eq(newVal); + }); + + it("DAO should have the ability to update the minStakeAmount", async function () { + const newVal = ONE.mul(10); + await expect(nodeStaking.connect(daoRole).setMinStakeAmount(newVal)) + .to.emit(nodeStaking, "MinStakeAmountUpdated") + .withArgs(newVal); + + expect(await nodeStaking.minStakeAmount()).eq(newVal); + }); + + it("DAO should have the ability to update the muonAppId", async function () { + const newVal = + "1566432988060666016333351531685287278204879617528298155619493815104572633000"; + await expect(nodeStaking.connect(daoRole).setMuonAppId(newVal)) + .to.emit(nodeStaking, "MuonAppIdUpdated") + .withArgs(newVal); + + expect(await nodeStaking.muonAppId()).eq(newVal); + }); + + it("DAO should have the ability to update the muonPublicKey", async function () { + const newPublicKey = { + x: "0x1234567890123456789012345678901234567890123456789012345678901234", + parity: 1, + }; + + await expect(nodeStaking.connect(daoRole).setMuonPublicKey(newPublicKey)) + .to.emit(nodeStaking, "MuonPublicKeyUpdated") + .withArgs([newPublicKey.x, newPublicKey.parity]); + + const updatedPublicKey = await nodeStaking.muonPublicKey(); + expect(updatedPublicKey.x).eq(newPublicKey.x); + expect(updatedPublicKey.parity).eq(newPublicKey.parity); + }); + + it("DAO should have the ability to add a new staking token", async () => { + const dummyToken = ethers.Wallet.createRandom(); + const dummyTokenMultiplier = ONE.mul(3); + await nodeStaking + .connect(daoRole) + .updateStakingTokens([dummyToken.address], [dummyTokenMultiplier]); + expect(await nodeStaking.isStakingToken(dummyToken.address)).eq(3); + expect(await nodeStaking.stakingTokens(2)).eq(dummyToken.address); + expect(await nodeStaking.stakingTokensMultiplier(dummyToken.address)).eq( + dummyTokenMultiplier + ); + }); + + it("DAO should have the ability to update the multiplier of an existing staking token", async () => { + expect(await nodeStaking.stakingTokensMultiplier(pion.address)).eq( + muonTokenMultiplier + ); + const newMuonTokenMultiplier = ONE.mul(3); + await nodeStaking + .connect(daoRole) + .updateStakingTokens([pion.address], [newMuonTokenMultiplier]); + expect(await nodeStaking.isStakingToken(pion.address)).eq(1); + expect(await nodeStaking.isStakingToken(pionLp.address)).eq(2); + expect(await nodeStaking.stakingTokens(0)).eq(pion.address); + expect(await nodeStaking.stakingTokens(1)).eq(pionLp.address); + expect(await nodeStaking.stakingTokensMultiplier(pionLp.address)).eq( + muonLpTokenMultiplier + ); + expect(await nodeStaking.stakingTokensMultiplier(pion.address)).eq( + newMuonTokenMultiplier + ); + }); + + it("DAO should have the ability to remove a staking token", async () => { + const newMuonTokenMultiplier = 0; + expect(await nodeStaking.stakingTokens(0)).eq(pion.address); + await nodeStaking + .connect(daoRole) + .updateStakingTokens([pion.address], [newMuonTokenMultiplier]); + expect(await nodeStaking.isStakingToken(pion.address)).eq(0); + expect(await nodeStaking.stakingTokens(0)).eq(pionLp.address); + expect(await nodeStaking.stakingTokensMultiplier(pion.address)).eq( + newMuonTokenMultiplier + ); + }); + + it("DAO should have the ability to add or update multiple staking tokens", async () => { + const newMuonTokenMultiplier = ONE.mul(4); + const newMuonLpTokenMultiplier = ONE.mul(4); + const dummyToken1 = ethers.Wallet.createRandom(); + const dummyToken1Multiplier = ONE.mul(3); + const dummyToken2 = ethers.Wallet.createRandom(); + const dummyToken2Multiplier = ONE.mul(4); + + expect(await nodeStaking.stakingTokensMultiplier(pion.address)).eq( + muonTokenMultiplier + ); + expect(await nodeStaking.stakingTokensMultiplier(pionLp.address)).eq( + muonLpTokenMultiplier + ); + + await nodeStaking + .connect(daoRole) + .updateStakingTokens( + [ + pion.address, + dummyToken1.address, + dummyToken2.address, + pionLp.address, + ], + [ + newMuonTokenMultiplier, + dummyToken1Multiplier, + dummyToken2Multiplier, + newMuonLpTokenMultiplier, + ] + ); + + expect(await nodeStaking.isStakingToken(pion.address)).eq(1); + expect(await nodeStaking.isStakingToken(pionLp.address)).eq(2); + expect(await nodeStaking.isStakingToken(dummyToken1.address)).eq(3); + expect(await nodeStaking.isStakingToken(dummyToken2.address)).eq(4); + + expect(await nodeStaking.stakingTokens(0)).eq(pion.address); + expect(await nodeStaking.stakingTokens(1)).eq(pionLp.address); + expect(await nodeStaking.stakingTokens(2)).eq(dummyToken1.address); + expect(await nodeStaking.stakingTokens(3)).eq(dummyToken2.address); + expect(await nodeStaking.stakingTokensMultiplier(pion.address)).eq( + newMuonTokenMultiplier + ); + expect(await nodeStaking.stakingTokensMultiplier(pionLp.address)).eq( + newMuonLpTokenMultiplier + ); + expect(await nodeStaking.stakingTokensMultiplier(dummyToken1.address)).eq( + dummyToken1Multiplier + ); + expect(await nodeStaking.stakingTokensMultiplier(dummyToken2.address)).eq( + dummyToken2Multiplier + ); + }); + + it("DAO should have the ability to remove one staking token and update another", async () => { + const newMuonTokenMultiplier = 0; + const newMuonLpTokenMultiplier = ONE.mul(4); + + expect(await nodeStaking.stakingTokensMultiplier(pion.address)).eq( + muonTokenMultiplier + ); + expect(await nodeStaking.stakingTokensMultiplier(pionLp.address)).eq( + muonLpTokenMultiplier + ); + + await nodeStaking + .connect(daoRole) + .updateStakingTokens( + [pionLp.address, pion.address], + [newMuonLpTokenMultiplier, newMuonTokenMultiplier] + ); + + expect(await nodeStaking.isStakingToken(pion.address)).eq(0); + expect(await nodeStaking.isStakingToken(pionLp.address)).eq(1); + expect(await nodeStaking.stakingTokens(0)).eq(pionLp.address); + expect(await nodeStaking.stakingTokensMultiplier(pion.address)).eq( + newMuonTokenMultiplier + ); + expect(await nodeStaking.stakingTokensMultiplier(pionLp.address)).eq( + newMuonLpTokenMultiplier + ); + }); + + it("DAO should have the ability to pause and unpause specific functions", async function () { + const functionName = "addMuonNode"; + expect(await nodeStaking.functionPauseStatus(functionName)).to.be.false; + await expect( + nodeStaking.connect(daoRole).setFunctionPauseStatus(functionName, false) + ).to.be.revertedWith("Already unpaused."); + + const tx = await nodeStaking + .connect(daoRole) + .setFunctionPauseStatus(functionName, true); + await expect( + nodeStaking.connect(daoRole).setFunctionPauseStatus(functionName, true) + ).to.be.revertedWith("Already paused."); + + const eventArgs = await tx + .wait() + .then((receipt) => receipt.events[0].args); + expect(eventArgs.functionName).eq(functionName); + expect(eventArgs.isPaused).eq(true); + + expect(await nodeStaking.functionPauseStatus(functionName)).to.be.true; + + const tokenId = await mintBondedPion( + ONE.mul(1000), + ONE.mul(1000), + staker3 + ); + await bondedPion.connect(staker3).approve(nodeStaking.address, tokenId); + + await expect( + nodeStaking + .connect(staker3) + .addMuonNode(node3.address, peerId3, tokenId) + ).to.be.revertedWith("Function is paused."); + + const tx2 = await nodeStaking + .connect(daoRole) + .setFunctionPauseStatus(functionName, false); + const unpausedFunction = await tx2 + .wait() + .then((receipt) => receipt.events[0].args.functionName); + expect(unpausedFunction).eq(functionName); + + expect(await nodeStaking.functionPauseStatus(functionName)).to.be.false; + + await nodeStaking + .connect(staker3) + .addMuonNode(node3.address, peerId3, tokenId); + + const node = await nodeManager.stakerAddressInfo(staker3.address); + expect(node.id).eq(3); + expect(node.nodeAddress).eq(node3.address); + expect(node.stakerAddress).eq(staker3.address); + expect(node.peerId).eq(peerId3); + expect(node.active).to.be.true; + }); + }); +});