From 6063546b4d2d64cf73252a0ce48e5d4125925f78 Mon Sep 17 00:00:00 2001 From: Siftal Date: Tue, 27 Jun 2023 11:03:53 +0330 Subject: [PATCH 01/46] feat: upload the files --- contracts/MuonNodeManager.sol | 356 ++++++ contracts/MuonNodeStaking.sol | 624 ++++++++++ contracts/interfaces/IBondedToken.sol | 33 + contracts/interfaces/IMuonNodeManager.sol | 32 + contracts/mock/PIONlpTest.sol | 12 + contracts/mock/PIONtest.sol | 10 + contracts/utils/MuonClientBase.sol | 38 + contracts/utils/SchnorrSECP256K1Verifier.sol | 128 ++ test/muonNodeManager.ts | 363 ++++++ test/muonNodeStaking.ts | 1114 ++++++++++++++++++ 10 files changed, 2710 insertions(+) create mode 100644 contracts/MuonNodeManager.sol create mode 100644 contracts/MuonNodeStaking.sol create mode 100644 contracts/interfaces/IBondedToken.sol create mode 100644 contracts/interfaces/IMuonNodeManager.sol create mode 100644 contracts/mock/PIONlpTest.sol create mode 100644 contracts/mock/PIONtest.sol create mode 100644 contracts/utils/MuonClientBase.sol create mode 100644 contracts/utils/SchnorrSECP256K1Verifier.sol create mode 100644 test/muonNodeManager.ts create mode 100644 test/muonNodeStaking.ts diff --git a/contracts/MuonNodeManager.sol b/contracts/MuonNodeManager.sol new file mode 100644 index 0000000..ff2acf6 --- /dev/null +++ b/contracts/MuonNodeManager.sol @@ -0,0 +1,356 @@ +// 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; + + // 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; + + // node id => tier + mapping(uint64 => uint64) public tiers; + + /** + * @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; + _; + } + + 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, "Node address is already registered."); + + require( + stakerAddressIds[_stakerAddress] == 0, + "Staker address is already registered." + ); + + lastNodeId++; + nodes[lastNodeId] = Node({ + id: lastNodeId, + nodeAddress: _nodeAddress, + stakerAddress: _stakerAddress, + peerId: _peerId, + active: _active, + roles: new uint64[](0), + startTime: block.timestamp, + lastEditTime: block.timestamp, + endTime: 0 + }); + + nodeAddressIds[_nodeAddress] = lastNodeId; + stakerAddressIds[_stakerAddress] = lastNodeId; + + 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 ID not found."); + + require(nodes[nodeId].active, "Node is 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(nodes[nodeId].active, "Node is not active."); + + require(roleId > 0 && roleId <= lastRoleId, "Invalid role ID."); + + require( + nodesRoles[roleId][nodeId] == 0, + "Role is already assigned to this node." + ); + + 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, + "Node does not have this role." + ); + + 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 _from The starting node ID. + * @param _to The ending node ID. + * @return nodesList An array of edited nodes. + */ + function getEditedNodes( + uint256 _lastEditTime, + uint64 _from, + uint64 _to + ) public view returns (Node[] memory nodesList) { + _from = _from > 0 ? _from : 1; + _to = _to <= lastNodeId ? _to : lastNodeId; + require(_from <= _to, "Invalid range of node IDs."); + + nodesList = new Node[](100); + uint64 n = 0; + for (uint64 i = _from; i <= _to && n < 100; 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 the information of a node associated with the provided node address. + * @param _addr The node address. + * @return node The node information. + */ + function nodeAddressInfo(address _addr) + public + view + returns (Node memory node) + { + node = nodes[nodeAddressIds[_addr]]; + } + + /** + * @dev Returns the information of a node associated with the provided staker address. + * @param _addr The staker address. + * @return node The node information. + */ + function stakerAddressInfo(address _addr) + public + view + override + returns (Node memory node) + { + node = nodes[stakerAddressIds[_addr]]; + } + + /** + * @dev Returns the tier of a node. + * @param nodeId The ID of the node. + * @return The tier of the node. + */ + function getTier(uint64 nodeId) external view override returns (uint64) { + return tiers[nodeId]; + } + + /** + * @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, uint64 tier) public onlyRole(DAO_ROLE) { + tiers[nodeId] = 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, "This role has already been added."); + + lastRoleId++; + roleIds[role] = lastRoleId; + emit NodeRoleAdded(role, lastRoleId); + } + + // ======== 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, uint64 indexed tier); +} diff --git a/contracts/MuonNodeStaking.sol b/contracts/MuonNodeStaking.sol new file mode 100644 index 0000000..3e92703 --- /dev/null +++ b/contracts/MuonNodeStaking.sol @@ -0,0 +1,624 @@ +// 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/token/ERC20/IERC20.sol"; +import "@openzeppelin/contracts-upgradeable/interfaces/IERC721Upgradeable.sol"; +import "./utils/MuonClientBase.sol"; +import "./interfaces/IMuonNodeManager.sol"; +import "./interfaces/IBondedToken.sol"; + +contract MuonNodeStaking is + Initializable, + AccessControlUpgradeable, + MuonClientBase +{ + 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 totalStaked; + + uint256 public exitPendingPeriod; + + uint256 public minStakeAmountPerNode; + + uint256 public periodFinish; + + uint256 public rewardRate; + + uint256 public lastUpdateTime; + + uint256 public rewardPerTokenStored; + + uint256 public REWARD_PERIOD; + + struct User { + uint256 balance; + uint256 paidReward; + uint256 paidRewardPerToken; + uint256 pendingRewards; + uint256 tokenId; + } + mapping(address => User) public users; + + IMuonNodeManager public nodeManager; + + IERC20 public muonToken; + + // reqId => bool + mapping(bytes => bool) public withdrawRequests; + + // 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(uint64 => uint256) public tiersMaxStakeAmount; + + /** + * @dev Modifier that updates the reward parameters + * before all of the functions that can change the rewards. + * + * `_forAddress` should be address(0) when new rewards are distributing. + */ + modifier updateReward(address _forAddress) { + rewardPerTokenStored = rewardPerToken(); + lastUpdateTime = lastTimeRewardApplicable(); + if (_forAddress != address(0)) { + users[_forAddress].pendingRewards = earned(_forAddress); + users[_forAddress].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 = IERC20(muonTokenAddress); + nodeManager = IMuonNodeManager(nodeManagerAddress); + bondedToken = IBondedToken(bondedTokenAddress); + + exitPendingPeriod = 7 days; + minStakeAmountPerNode = 1000 ether; + REWARD_PERIOD = 30 days; + + 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, + "Mismatch in the length of arrays." + ); + + 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. The multiplier value must be greater than 0." + ); + 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 tokenId The unique identifier of the token. + * @param tokens The array of token addresses to be locked. + * @param amounts The corresponding array of token amounts to be locked. + */ + function lockToBondedToken( + uint256 tokenId, + address[] memory tokens, + uint256[] memory amounts + ) external { + require( + tokens.length == amounts.length, + "Mismatch in the length of arrays." + ); + + for (uint256 i = 0; i < tokens.length; i++) { + uint256 balance = IERC20(tokens[i]).balanceOf(address(this)); + + require( + IERC20(tokens[i]).transferFrom( + msg.sender, + address(this), + amounts[i] + ), + "Failed to transfer tokens from your account to the staker contract." + ); + + uint256 receivedAmount = IERC20(tokens[i]).balanceOf( + address(this) + ) - balance; + require( + amounts[i] == receivedAmount, + "The discrepancy between the received amount and the claimed amount." + ); + + require( + IERC20(tokens[i]).approve(address(bondedToken), amounts[i]), + "Failed to approve to the bondedToken contract to spend tokens on your behalf." + ); + } + + bondedToken.lock(tokenId, tokens, amounts); + + updateStaking(); + } + + /** + * @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. + * @param tokenIdB The id of the second token to be merged. + */ + function mergeBondedTokens(uint256 tokenIdA, uint256 tokenIdB) external { + require( + bondedToken.ownerOf(tokenIdA) == msg.sender, + "The sender is not the owner of the NFT." + ); + + bondedToken.transferFrom(msg.sender, address(this), tokenIdA); + bondedToken.approve(address(bondedToken), tokenIdA); + + bondedToken.merge(tokenIdA, tokenIdB); + + updateStaking(); + } + + /** + * @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() public updateReward(msg.sender) { + IMuonNodeManager.Node memory node = nodeManager.stakerAddressInfo( + msg.sender + ); + require( + node.id != 0 && node.active, + "No active node found for the staker address." + ); + + uint256 tokenId = users[msg.sender].tokenId; + require(tokenId != 0, "No staking found for the staker address."); + + uint256 amount = valueOfBondedToken(tokenId); + require( + amount >= minStakeAmountPerNode, + "Insufficient amount to run a node." + ); + + uint64 tier = nodeManager.getTier(node.id); + uint256 maxStakeAmount = tiersMaxStakeAmount[tier]; + if (amount > maxStakeAmount) { + amount = maxStakeAmount; + } + + if (users[msg.sender].balance != amount) { + totalStaked -= users[msg.sender].balance; + users[msg.sender].balance = amount; + totalStaked += amount; + emit Staked(msg.sender, 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 { + require( + !withdrawRequests[reqId], + "This request has already been submitted." + ); + + require(amount > 0, "Invalid withdrawal amount."); + + IMuonNodeManager.Node memory node = nodeManager.stakerAddressInfo( + msg.sender + ); + require(node.id != 0, "Node not found for the staker address."); + + require( + paidRewardPerToken <= rewardPerToken() || + users[msg.sender].paidRewardPerToken < paidRewardPerToken, + "Invalid paidRewardPerToken value." + ); + + // Verify the authenticity of the withdrawal request. + bytes32 hash = keccak256( + abi.encodePacked( + muonAppId, + reqId, + msg.sender, + users[msg.sender].paidReward, + paidRewardPerToken, + amount + ) + ); + + bool verified = muonVerify( + reqId, + uint256(hash), + signature, + muonPublicKey + ); + require(verified, "Invalid signature."); + + if (node.active) { + require(amount <= earned(msg.sender), "Invalid withdrawal amount."); + } else { + require( + amount <= users[msg.sender].pendingRewards, + "Invalid withdrawal amount." + ); + } + + users[msg.sender].pendingRewards = 0; + users[msg.sender].paidReward += amount; + users[msg.sender].paidRewardPerToken = paidRewardPerToken; + withdrawRequests[reqId] = true; + muonToken.transfer(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() public updateReward(msg.sender) { + IMuonNodeManager.Node memory node = nodeManager.stakerAddressInfo( + msg.sender + ); + require(node.id != 0, "Node not found for the staker address."); + + require(node.active, "The node is already deactivated."); + + require( + users[msg.sender].balance > 0, + "No staked balance available for withdrawal." + ); + + totalStaked -= users[msg.sender].balance; + nodeManager.deactiveNode(node.id); + emit ExitRequested(msg.sender); + } + + /** + * @dev Allows stakers to withdraw their staked amount after exiting the network and exit pending period has passed. + */ + function withdraw() public { + IMuonNodeManager.Node memory node = nodeManager.stakerAddressInfo( + msg.sender + ); + require(node.id != 0, "Node not found for the staker address."); + + require( + !node.active && + (node.endTime + exitPendingPeriod) < block.timestamp, + "The exit time has not been reached yet." + ); + + require( + !lockedStakes[msg.sender], + "Your stake is currently locked and cannot be withdrawn." + ); + + uint256 amount = users[msg.sender].balance; + require(amount > 0, "No staked balance available for withdrawal."); + + users[msg.sender].balance = 0; + uint256 tokenId = users[msg.sender].tokenId; + require(tokenId != 0, "No staking found for the staker address."); + + bondedToken.safeTransferFrom(address(this), msg.sender, tokenId); + users[msg.sender].tokenId = 0; + 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 { + require( + users[msg.sender].tokenId == 0, + "You have already staked an NFT. Multiple staking is not allowed." + ); + + uint256 amount = valueOfBondedToken(tokenId); + require( + amount >= minStakeAmountPerNode, + "Insufficient amount to run a node." + ); + + bondedToken.transferFrom(msg.sender, address(this), tokenId); + users[msg.sender].tokenId = tokenId; + + 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 / REWARD_PERIOD; + } else { + uint256 remaining = periodFinish - block.timestamp; + uint256 leftover = remaining * rewardRate; + rewardRate = (reward + leftover) / REWARD_PERIOD; + } + 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) { + return + totalStaked == 0 + ? rewardPerTokenStored + : rewardPerTokenStored + + (((lastTimeRewardApplicable() - lastUpdateTime) * + rewardRate * + 1e18) / totalStaked); + } + + /** + * @dev Calculates the total rewards earned by a node. + * @param account The staker address of a node. + * @return The total rewards earned by a node. + */ + function earned(address account) public view returns (uint256) { + IMuonNodeManager.Node memory node = nodeManager.stakerAddressInfo( + account + ); + + if (!node.active) { + return 0; + } else { + return + (users[account].balance * + (rewardPerToken() - users[account].paidRewardPerToken)) / + 1e18 + + users[account].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 the specified staker's stake. + * Only callable by the REWARD_ROLE. + * @param stakerAddress The address of the staker. + */ + function lockStake(address stakerAddress) public onlyRole(REWARD_ROLE) { + IMuonNodeManager.Node memory node = nodeManager.stakerAddressInfo( + stakerAddress + ); + require(node.id != 0, "Node not found for the staker address."); + + lockedStakes[stakerAddress] = true; + emit StakeLocked(stakerAddress); + } + + /** + * @dev Unlocks the specified staker's stake. + * Only callable by the REWARD_ROLE. + * @param stakerAddress The address of the staker. + */ + function unlockStake(address stakerAddress) public onlyRole(REWARD_ROLE) { + require(lockedStakes[stakerAddress], "The stake is not locked."); + + lockedStakes[stakerAddress] = false; + emit StakeUnlocked(stakerAddress); + } + + // ======== DAO functions ======== + + function setExitPendingPeriod(uint256 val) public onlyRole(DAO_ROLE) { + exitPendingPeriod = val; + emit ExitPendingPeriodUpdated(val); + } + + function setMinStakeAmountPerNode(uint256 val) public onlyRole(DAO_ROLE) { + minStakeAmountPerNode = val; + emit MinStakeAmountPerNodeUpdated(val); + } + + 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(uint64 tier, uint256 maxStakeAmount) + public + onlyRole(DAO_ROLE) + { + tiersMaxStakeAmount[tier] = maxStakeAmount; + emit TierMaxStakeUpdated(tier, maxStakeAmount); + } + + // ======== 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 MinStakeAmountPerNodeUpdated(uint256 minStakeAmountPerNode); + event MuonAppIdUpdated(uint256 muonAppId); + event MuonPublicKeyUpdated(PublicKey muonPublicKey); + event StakeLocked(address indexed stakerAddress); + event StakeUnlocked(address indexed stakerAddress); + event StakingTokenUpdated(address indexed token, uint256 multiplier); + event TierMaxStakeUpdated(uint64 tier, uint256 maxStakeAmount); +} diff --git a/contracts/interfaces/IBondedToken.sol b/contracts/interfaces/IBondedToken.sol new file mode 100644 index 0000000..3664a4d --- /dev/null +++ b/contracts/interfaces/IBondedToken.sol @@ -0,0 +1,33 @@ +// 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 transferFrom( + 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..ba330a7 --- /dev/null +++ b/contracts/interfaces/IMuonNodeManager.sol @@ -0,0 +1,32 @@ +// 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; + 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 getTier(uint64 nodeId) external view returns (uint64); +} 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..07197db --- /dev/null +++ b/test/muonNodeManager.ts @@ -0,0 +1,363 @@ +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; + }); + + 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("Node address is already registered."); + }); + + 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("Staker address is already registered."); + }); + }); + + 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("Node is already deactivated."); + }); + + it("should not allow deactivating a non-existent node", async function () { + await expect( + nodeManager.connect(adminRole).deactiveNode(2) + ).to.be.revertedWith("Node ID not found."); + }); + }); + + describe("get nods", function () { + it("should retrieve 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.getEditedNodes( + 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]); + + const allNodesList = await nodeManager.getEditedNodes(0, 1, 1000); + expect(allNodesList).to.have.lengthOf(15); + + expect(await nodeManager.lastNodeId()).to.be.equal(15); + }); + }); + + 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.getEditedNodes(0, 1, 1000); + node = nodes[0]; + nodeRoles = node.roles.map((role) => role.toNumber()); + expect(nodeRoles).to.deep.equal([1, 2]); + + const editedNodes = await nodeManager.getEditedNodes(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.getEditedNodes(0, 1, 1000); + node = nodes[0]; + nodeRoles = node.roles.map((role) => role.toNumber()); + expect(nodeRoles).to.deep.equal([1, 2]); + + let editedNodes = await nodeManager.getEditedNodes(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.getEditedNodes(0, 1, 1000); + node = nodes[0]; + nodeRoles = node.roles.map((role) => role.toNumber()); + expect(nodeRoles).to.deep.equal([2]); + + editedNodes = await nodeManager.getEditedNodes(startTime, 1, 1000); + node = editedNodes[0]; + nodeRoles = node.roles.map((role) => role.toNumber()); + expect(nodeRoles).to.deep.equal([2]); + }); + }); +}); diff --git a/test/muonNodeStaking.ts b/test/muonNodeStaking.ts new file mode 100644 index 0000000..74d5ac1 --- /dev/null +++ b/test/muonNodeStaking.ts @@ -0,0 +1,1114 @@ +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: "0x570513014bbf0ddc4b0ac6b71164ff1186f26053a4df9facd79d9268456090c9", + parity: 0, + }; + 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); + // check added node + expect(await bondedPion.ownerOf(1)).eq(nodeStaking.address); + expect((await nodeStaking.users(staker1.address)).tokenId).eq(1); + expect(await nodeStaking.valueOfBondedToken(1)).eq(ONE.mul(3000)); + // newly added nodes' tiers are 0, so their maximum stake amount will be 0 + expect(await nodeManager.getTier(1)).eq(0); + expect((await nodeStaking.users(staker1.address)).balance).eq(0); + // admins can set tier + await nodeManager.connect(daoRole).setTier(1, 1); + await nodeStaking.connect(staker1).updateStaking(); + expect((await nodeStaking.users(staker1.address)).balance).eq( + tier1MaxStake + ); + + 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 nodeManager.connect(daoRole).setTier(2, 2); + await nodeStaking.connect(staker2).updateStaking(); + }); + + const getDummySig = async ( + stakerAddress, + paidReward, + rewardPerToken, + amount + ) => { + // console.log( + // `http://localhost:8000/v1/?app=tss_reward_oracle_test&method=reward¶ms[stakerAddress]=${stakerAddress}¶ms[paidReward]=${paidReward}¶ms[rewardPerToken]=${rewardPerToken}¶ms[amount]=${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 (initialReward) => { + await pion.connect(rewardRole).transfer(nodeStaking.address, initialReward); + await nodeStaking.connect(rewardRole).distributeRewards(initialReward); + }; + + 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.endTime).eq(0); + + 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.endTime).eq(0); + }); + + 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 amount to run a node."); + }); + + 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 nodeId = (await nodeManager.nodeAddressInfo(node3.address)).id; + + 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(await nodeManager.getTier(nodeId)).eq(0); + expect((await nodeStaking.users(staker3.address)).balance).eq(0); + // admins can set tier + await nodeManager.connect(daoRole).setTier(nodeId, 1); + await nodeStaking.connect(staker3).updateStaking(); + 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 tier = await nodeManager.getTier(nodeId); + const maxStakeAmount = await nodeStaking.tiersMaxStakeAmount(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( + tokenId, + [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 tier = await nodeManager.getTier(nodeId); + const maxStakeAmount = await nodeStaking.tiersMaxStakeAmount(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, tokenId); + + 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 nodeManager.connect(daoRole).setTier(3, 2); + await nodeStaking.connect(staker3).updateStaking(); + + // 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 for the staker address." + ); + }); + + 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( + "This request has already been submitted." + ); + }); + + 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 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); + + // 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)); + expect(balance2).eq(Math.floor(initialReward / 9)); + + // 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); + + // check the result of withdrawing + const staker1Stake2 = await nodeStaking.users(staker1.address); + expect(staker1Stake2.paidReward).eq(earned1.add(earned2)); + expect(staker1Stake2.paidRewardPerToken).eq(rewardPerToken2); + const balance3 = await pion.balanceOf(staker1.address); + expect(balance3).eq(balance2.add(earned2)); + // tolerance for 2 seconds + expect(balance3).to.closeTo(Math.floor(initialReward / 6), 3000); + }); + + 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 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); + + // requestExit + await nodeStaking.connect(staker1).requestExit(); + + const u2 = await nodeStaking.users(staker1.address); + expect(u2.balance).eq(ONE.mul(1000)); + expect(u2.pendingRewards).to.closeTo(earned2, 2000); + expect(u2.tokenId).eq(1); + + expect(await nodeStaking.earned(staker1.address)).to.be.equal(0); + + // 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); + }); + + it("should disallow stakers from withdrawing their stake if it is locked", async function () { + // Distribute rewards + const initialReward = thirtyDays * 3000; + await distributeRewards(initialReward); + + // Increase time by 10 days + await evmIncreaseTime(60 * 60 * 24 * 10); + + // try to lock non exist staker + await expect( + nodeStaking.connect(rewardRole).lockStake(user1.address) + ).to.be.revertedWith("Node not found for the staker address."); + + // try to unlock not locked staker + await expect( + nodeStaking.connect(rewardRole).unlockStake(staker1.address) + ).to.be.revertedWith("The stake is not locked."); + + const earned1 = await nodeStaking.earned(staker1.address); + + // requestExit + await nodeStaking.connect(staker1).requestExit(); + + // lock the stake + await nodeStaking.connect(rewardRole).lockStake(staker1.address); + + // Increase time by 7 days + await evmIncreaseTime(60 * 60 * 24 * 7); + + const u1 = await nodeStaking.users(staker1.address); + expect(u1.balance).eq(ONE.mul(1000)); + expect(u1.pendingRewards).to.closeTo(earned1, 2000); + expect(u1.paidReward).eq(0); + + // try to withdraw the stake + await expect(nodeStaking.connect(staker1).withdraw()).to.be.revertedWith( + "Your stake is currently locked and cannot be withdrawn." + ); + + // unlock the stake + await nodeStaking.connect(rewardRole).unlockStake(staker1.address); + + 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 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); + }); + }); + + 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 minStakeAmountPerNode", async function () { + const newVal = ONE.mul(10); + await expect( + nodeStaking.connect(daoRole).setMinStakeAmountPerNode(newVal) + ) + .to.emit(nodeStaking, "MinStakeAmountPerNodeUpdated") + .withArgs(newVal); + + expect(await nodeStaking.minStakeAmountPerNode()).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.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.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.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.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.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 + ); + }); + }); +}); From e93be6624ab59577436d6a413c7950879dcb563c Mon Sep 17 00:00:00 2001 From: Siftal Date: Wed, 28 Jun 2023 18:03:57 +0330 Subject: [PATCH 02/46] fix: fix an issue --- contracts/MuonNodeStaking.sol | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/contracts/MuonNodeStaking.sol b/contracts/MuonNodeStaking.sol index 3e92703..6685fc0 100644 --- a/contracts/MuonNodeStaking.sol +++ b/contracts/MuonNodeStaking.sol @@ -338,8 +338,8 @@ contract MuonNodeStaking is require(node.id != 0, "Node not found for the staker address."); require( - paidRewardPerToken <= rewardPerToken() || - users[msg.sender].paidRewardPerToken < paidRewardPerToken, + users[msg.sender].paidRewardPerToken < paidRewardPerToken && + paidRewardPerToken <= rewardPerToken(), "Invalid paidRewardPerToken value." ); From 12810e6f8515779a373d919db1eb7c250b5ab232 Mon Sep 17 00:00:00 2001 From: Siftal Date: Wed, 28 Jun 2023 23:19:33 +0330 Subject: [PATCH 03/46] fix: remove an extra check --- contracts/MuonNodeStaking.sol | 5 ----- 1 file changed, 5 deletions(-) diff --git a/contracts/MuonNodeStaking.sol b/contracts/MuonNodeStaking.sol index 6685fc0..a3aadbb 100644 --- a/contracts/MuonNodeStaking.sol +++ b/contracts/MuonNodeStaking.sol @@ -392,11 +392,6 @@ contract MuonNodeStaking is require(node.active, "The node is already deactivated."); - require( - users[msg.sender].balance > 0, - "No staked balance available for withdrawal." - ); - totalStaked -= users[msg.sender].balance; nodeManager.deactiveNode(node.id); emit ExitRequested(msg.sender); From 4887e77fbe6316eee01c8175db6c67628f915b0c Mon Sep 17 00:00:00 2001 From: Siftal Date: Wed, 28 Jun 2023 23:37:48 +0330 Subject: [PATCH 04/46] feat: refactor lockToBondedToken to restrict usage to NFT stakers --- contracts/MuonNodeStaking.sol | 9 +++++++-- test/muonNodeStaking.ts | 1 - 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/contracts/MuonNodeStaking.sol b/contracts/MuonNodeStaking.sol index a3aadbb..053ca0f 100644 --- a/contracts/MuonNodeStaking.sol +++ b/contracts/MuonNodeStaking.sol @@ -186,12 +186,10 @@ contract MuonNodeStaking is * @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 tokenId The unique identifier of the token. * @param tokens The array of token addresses to be locked. * @param amounts The corresponding array of token amounts to be locked. */ function lockToBondedToken( - uint256 tokenId, address[] memory tokens, uint256[] memory amounts ) external { @@ -200,6 +198,13 @@ contract MuonNodeStaking is "Mismatch in the length of arrays." ); + uint256 tokenId = users[msg.sender].tokenId; + require(tokenId != 0, "No staking found for the staker address."); + 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 = IERC20(tokens[i]).balanceOf(address(this)); diff --git a/test/muonNodeStaking.ts b/test/muonNodeStaking.ts index 74d5ac1..b4941b2 100644 --- a/test/muonNodeStaking.ts +++ b/test/muonNodeStaking.ts @@ -310,7 +310,6 @@ describe("MuonNodeStaking", function () { await nodeStaking .connect(staker2) .lockToBondedToken( - tokenId, [pion.address, pionLp.address], [pionAmount, pionLpAmount] ); From 26d3e41f3c077e081fb739391ee6c3d25fe3f048 Mon Sep 17 00:00:00 2001 From: Siftal Date: Wed, 28 Jun 2023 23:44:53 +0330 Subject: [PATCH 05/46] feat: refactor mergeBondedTokens to restrict usage to NFT stakers --- contracts/MuonNodeStaking.sol | 10 ++++++++-- test/muonNodeStaking.ts | 2 +- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/contracts/MuonNodeStaking.sol b/contracts/MuonNodeStaking.sol index 053ca0f..14c58de 100644 --- a/contracts/MuonNodeStaking.sol +++ b/contracts/MuonNodeStaking.sol @@ -240,14 +240,20 @@ contract MuonNodeStaking is * @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. - * @param tokenIdB The id of the second token to be merged. */ - function mergeBondedTokens(uint256 tokenIdA, uint256 tokenIdB) external { + function mergeBondedTokens(uint256 tokenIdA) external { require( bondedToken.ownerOf(tokenIdA) == msg.sender, "The sender is not the owner of the NFT." ); + uint256 tokenIdB = users[msg.sender].tokenId; + require(tokenIdB != 0, "No staking found for the staker address."); + require( + bondedToken.ownerOf(tokenIdB) == address(this), + "Staking contract is not the owner of the NFT." + ); + bondedToken.transferFrom(msg.sender, address(this), tokenIdA); bondedToken.approve(address(bondedToken), tokenIdA); diff --git a/test/muonNodeStaking.ts b/test/muonNodeStaking.ts index b4941b2..9f46d05 100644 --- a/test/muonNodeStaking.ts +++ b/test/muonNodeStaking.ts @@ -357,7 +357,7 @@ describe("MuonNodeStaking", function () { .approve(nodeStaking.address, newTokenId); // lock tokens into the NFT - await nodeStaking.connect(staker2).mergeBondedTokens(newTokenId, tokenId); + await nodeStaking.connect(staker2).mergeBondedTokens(newTokenId); const lockeds2 = await bondedPion.getLockedOf(tokenId, [ pion.address, From 4d3403ac75df95f8313922488c4929c9476de462 Mon Sep 17 00:00:00 2001 From: Siftal Date: Fri, 30 Jun 2023 12:07:27 +0330 Subject: [PATCH 06/46] feat: remove tier mapping and add tier field to the Node struct --- contracts/MuonNodeManager.sol | 8 +++----- contracts/interfaces/IMuonNodeManager.sol | 1 + test/muonNodeManager.ts | 22 ++++++++++++++++++++++ 3 files changed, 26 insertions(+), 5 deletions(-) diff --git a/contracts/MuonNodeManager.sol b/contracts/MuonNodeManager.sol index ff2acf6..5f64518 100644 --- a/contracts/MuonNodeManager.sol +++ b/contracts/MuonNodeManager.sol @@ -38,9 +38,6 @@ contract MuonNodeManager is // role id => node id => index + 1 mapping(uint64 => mapping(uint64 => uint16)) public nodesRoles; - // node id => tier - mapping(uint64 => uint64) public tiers; - /** * @dev Modifier to update the lastUpdateTime state variable. */ @@ -114,6 +111,7 @@ contract MuonNodeManager is nodeAddress: _nodeAddress, stakerAddress: _stakerAddress, peerId: _peerId, + tier: 0, active: _active, roles: new uint64[](0), startTime: block.timestamp, @@ -304,7 +302,7 @@ contract MuonNodeManager is * @return The tier of the node. */ function getTier(uint64 nodeId) external view override returns (uint64) { - return tiers[nodeId]; + return nodes[nodeId].tier; } /** @@ -314,7 +312,7 @@ contract MuonNodeManager is * @param tier The tier to set. */ function setTier(uint64 nodeId, uint64 tier) public onlyRole(DAO_ROLE) { - tiers[nodeId] = tier; + nodes[nodeId].tier = tier; emit TierSet(nodeId, tier); } diff --git a/contracts/interfaces/IMuonNodeManager.sol b/contracts/interfaces/IMuonNodeManager.sol index ba330a7..bc3e266 100644 --- a/contracts/interfaces/IMuonNodeManager.sol +++ b/contracts/interfaces/IMuonNodeManager.sol @@ -8,6 +8,7 @@ interface IMuonNodeManager { address stakerAddress; string peerId; // p2p peer ID bool active; + uint64 tier; uint64[] roles; uint256 startTime; uint256 endTime; diff --git a/test/muonNodeManager.ts b/test/muonNodeManager.ts index 07197db..3d27a2f 100644 --- a/test/muonNodeManager.ts +++ b/test/muonNodeManager.ts @@ -62,6 +62,10 @@ describe("MuonNodeManager", function () { 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 () { @@ -360,4 +364,22 @@ describe("MuonNodeManager", function () { expect(nodeRoles).to.deep.equal([2]); }); }); + + describe("node tier", function () { + it("the DAO should be able to set node tier", async function () { + const nodeId = 1; + let node = await nodeManager.nodes(nodeId); + expect(node.tier).eq(0); + let tier = await nodeManager.getTier(nodeId); + expect(tier).eq(0); + + const newTier = 2; + await nodeManager.setTier(nodeId, newTier); + node = await nodeManager.nodes(nodeId); + expect(node.tier).eq(newTier); + tier = await nodeManager.getTier(nodeId); + expect(tier).eq(newTier); + }); + }); + }); From 350da1cd1f0b2e11240c23a29308aab0eb024f5a Mon Sep 17 00:00:00 2001 From: Siftal Date: Tue, 4 Jul 2023 21:29:20 +0330 Subject: [PATCH 07/46] feat: change tier type from uint64 to uint8 --- contracts/MuonNodeManager.sol | 6 +++--- contracts/MuonNodeStaking.sol | 8 ++++---- contracts/interfaces/IMuonNodeManager.sol | 4 ++-- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/contracts/MuonNodeManager.sol b/contracts/MuonNodeManager.sol index 5f64518..0eebb07 100644 --- a/contracts/MuonNodeManager.sol +++ b/contracts/MuonNodeManager.sol @@ -301,7 +301,7 @@ contract MuonNodeManager is * @param nodeId The ID of the node. * @return The tier of the node. */ - function getTier(uint64 nodeId) external view override returns (uint64) { + function getTier(uint64 nodeId) external view override returns (uint8) { return nodes[nodeId].tier; } @@ -311,7 +311,7 @@ contract MuonNodeManager is * @param nodeId The ID of the node. * @param tier The tier to set. */ - function setTier(uint64 nodeId, uint64 tier) public onlyRole(DAO_ROLE) { + function setTier(uint64 nodeId, uint8 tier) public onlyRole(DAO_ROLE) { nodes[nodeId].tier = tier; emit TierSet(nodeId, tier); } @@ -350,5 +350,5 @@ contract MuonNodeManager is 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, uint64 indexed tier); + event TierSet(uint64 indexed nodeId, uint8 indexed tier); } diff --git a/contracts/MuonNodeStaking.sol b/contracts/MuonNodeStaking.sol index 14c58de..da6fb04 100644 --- a/contracts/MuonNodeStaking.sol +++ b/contracts/MuonNodeStaking.sol @@ -65,7 +65,7 @@ contract MuonNodeStaking is mapping(address => uint256) public stakingTokensMultiplier; // tier => maxStakeAmount - mapping(uint64 => uint256) public tiersMaxStakeAmount; + mapping(uint8 => uint256) public tiersMaxStakeAmount; /** * @dev Modifier that updates the reward parameters @@ -310,7 +310,7 @@ contract MuonNodeStaking is "Insufficient amount to run a node." ); - uint64 tier = nodeManager.getTier(node.id); + uint8 tier = nodeManager.getTier(node.id); uint256 maxStakeAmount = tiersMaxStakeAmount[tier]; if (amount > maxStakeAmount) { amount = maxStakeAmount; @@ -596,7 +596,7 @@ contract MuonNodeStaking is emit MuonPublicKeyUpdated(_muonPublicKey); } - function setTierMaxStakeAmount(uint64 tier, uint256 maxStakeAmount) + function setTierMaxStakeAmount(uint8 tier, uint256 maxStakeAmount) public onlyRole(DAO_ROLE) { @@ -626,5 +626,5 @@ contract MuonNodeStaking is event StakeLocked(address indexed stakerAddress); event StakeUnlocked(address indexed stakerAddress); event StakingTokenUpdated(address indexed token, uint256 multiplier); - event TierMaxStakeUpdated(uint64 tier, uint256 maxStakeAmount); + event TierMaxStakeUpdated(uint8 tier, uint256 maxStakeAmount); } diff --git a/contracts/interfaces/IMuonNodeManager.sol b/contracts/interfaces/IMuonNodeManager.sol index bc3e266..2466489 100644 --- a/contracts/interfaces/IMuonNodeManager.sol +++ b/contracts/interfaces/IMuonNodeManager.sol @@ -8,7 +8,7 @@ interface IMuonNodeManager { address stakerAddress; string peerId; // p2p peer ID bool active; - uint64 tier; + uint8 tier; uint64[] roles; uint256 startTime; uint256 endTime; @@ -29,5 +29,5 @@ interface IMuonNodeManager { view returns (Node memory node); - function getTier(uint64 nodeId) external view returns (uint64); + function getTier(uint64 nodeId) external view returns (uint8); } From 0a0db3f369469448ef70c99af6339d19c603177b Mon Sep 17 00:00:00 2001 From: Siftal Date: Tue, 4 Jul 2023 21:43:18 +0330 Subject: [PATCH 08/46] feat: remove unnecessary getTier function --- contracts/MuonNodeManager.sol | 9 --------- contracts/MuonNodeStaking.sol | 3 +-- contracts/interfaces/IMuonNodeManager.sol | 1 - test/muonNodeManager.ts | 4 ---- test/muonNodeStaking.ts | 16 +++++++--------- 5 files changed, 8 insertions(+), 25 deletions(-) diff --git a/contracts/MuonNodeManager.sol b/contracts/MuonNodeManager.sol index 0eebb07..8880fab 100644 --- a/contracts/MuonNodeManager.sol +++ b/contracts/MuonNodeManager.sol @@ -296,15 +296,6 @@ contract MuonNodeManager is node = nodes[stakerAddressIds[_addr]]; } - /** - * @dev Returns the tier of a node. - * @param nodeId The ID of the node. - * @return The tier of the node. - */ - function getTier(uint64 nodeId) external view override returns (uint8) { - return nodes[nodeId].tier; - } - /** * @dev Sets the tier of a node. * Only callable by the DAO_ROLE. diff --git a/contracts/MuonNodeStaking.sol b/contracts/MuonNodeStaking.sol index da6fb04..e8f5c29 100644 --- a/contracts/MuonNodeStaking.sol +++ b/contracts/MuonNodeStaking.sol @@ -310,8 +310,7 @@ contract MuonNodeStaking is "Insufficient amount to run a node." ); - uint8 tier = nodeManager.getTier(node.id); - uint256 maxStakeAmount = tiersMaxStakeAmount[tier]; + uint256 maxStakeAmount = tiersMaxStakeAmount[node.tier]; if (amount > maxStakeAmount) { amount = maxStakeAmount; } diff --git a/contracts/interfaces/IMuonNodeManager.sol b/contracts/interfaces/IMuonNodeManager.sol index 2466489..c65228e 100644 --- a/contracts/interfaces/IMuonNodeManager.sol +++ b/contracts/interfaces/IMuonNodeManager.sol @@ -29,5 +29,4 @@ interface IMuonNodeManager { view returns (Node memory node); - function getTier(uint64 nodeId) external view returns (uint8); } diff --git a/test/muonNodeManager.ts b/test/muonNodeManager.ts index 3d27a2f..5db5e52 100644 --- a/test/muonNodeManager.ts +++ b/test/muonNodeManager.ts @@ -370,15 +370,11 @@ describe("MuonNodeManager", function () { const nodeId = 1; let node = await nodeManager.nodes(nodeId); expect(node.tier).eq(0); - let tier = await nodeManager.getTier(nodeId); - expect(tier).eq(0); const newTier = 2; await nodeManager.setTier(nodeId, newTier); node = await nodeManager.nodes(nodeId); expect(node.tier).eq(newTier); - tier = await nodeManager.getTier(nodeId); - expect(tier).eq(newTier); }); }); diff --git a/test/muonNodeStaking.ts b/test/muonNodeStaking.ts index 9f46d05..08f5e54 100644 --- a/test/muonNodeStaking.ts +++ b/test/muonNodeStaking.ts @@ -140,7 +140,6 @@ describe("MuonNodeStaking", function () { expect((await nodeStaking.users(staker1.address)).tokenId).eq(1); expect(await nodeStaking.valueOfBondedToken(1)).eq(ONE.mul(3000)); // newly added nodes' tiers are 0, so their maximum stake amount will be 0 - expect(await nodeManager.getTier(1)).eq(0); expect((await nodeStaking.users(staker1.address)).balance).eq(0); // admins can set tier await nodeManager.connect(daoRole).setTier(1, 1); @@ -252,15 +251,14 @@ describe("MuonNodeStaking", function () { await nodeStaking .connect(staker3) .addMuonNode(node3.address, peerId3, tokenId); - const nodeId = (await nodeManager.nodeAddressInfo(node3.address)).id; - + 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(await nodeManager.getTier(nodeId)).eq(0); + expect(node.tier).eq(0); expect((await nodeStaking.users(staker3.address)).balance).eq(0); // admins can set tier - await nodeManager.connect(daoRole).setTier(nodeId, 1); + await nodeManager.connect(daoRole).setTier(node.id, 1); await nodeStaking.connect(staker3).updateStaking(); expect((await nodeStaking.users(staker3.address)).balance) .eq(await nodeStaking.tiersMaxStakeAmount(1)) @@ -293,8 +291,8 @@ describe("MuonNodeStaking", function () { const userStake1 = (await nodeStaking.users(staker2.address)).balance; const value1 = await nodeStaking.valueOfBondedToken(tokenId); - const tier = await nodeManager.getTier(nodeId); - const maxStakeAmount = await nodeStaking.tiersMaxStakeAmount(tier); + const node = await nodeManager.nodes(nodeId); + const maxStakeAmount = await nodeStaking.tiersMaxStakeAmount(node.tier); // mint required tokens for staker const pionAmount = ONE.mul(1000); @@ -339,8 +337,8 @@ describe("MuonNodeStaking", function () { const userStake1 = (await nodeStaking.users(staker2.address)).balance; const value1 = await nodeStaking.valueOfBondedToken(tokenId); - const tier = await nodeManager.getTier(nodeId); - const maxStakeAmount = await nodeStaking.tiersMaxStakeAmount(tier); + const node = await nodeManager.nodes(nodeId); + const maxStakeAmount = await nodeStaking.tiersMaxStakeAmount(node.tier); // mint required bondedPion NFT for staker const pionAmount = ONE.mul(1000); From 7c4920ab60d7eae9911fdb9b320d63e5d6c793a0 Mon Sep 17 00:00:00 2001 From: Siftal Date: Tue, 4 Jul 2023 21:49:49 +0330 Subject: [PATCH 09/46] fix: fix an issue --- contracts/MuonNodeManager.sol | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/contracts/MuonNodeManager.sol b/contracts/MuonNodeManager.sol index 8880fab..c5ee78d 100644 --- a/contracts/MuonNodeManager.sol +++ b/contracts/MuonNodeManager.sol @@ -92,13 +92,11 @@ contract MuonNodeManager is address _stakerAddress, string calldata _peerId, bool _active - ) - public - override - onlyRole(ADMIN_ROLE) - updateState - { - require(nodeAddressIds[_nodeAddress] == 0, "Node address is already registered."); + ) public override onlyRole(ADMIN_ROLE) updateState { + require( + nodeAddressIds[_nodeAddress] == 0, + "Node address is already registered." + ); require( stakerAddressIds[_stakerAddress] == 0, @@ -302,7 +300,12 @@ contract MuonNodeManager is * @param nodeId The ID of the node. * @param tier The tier to set. */ - function setTier(uint64 nodeId, uint8 tier) public onlyRole(DAO_ROLE) { + function setTier(uint64 nodeId, uint8 tier) + public + onlyRole(DAO_ROLE) + updateState + updateNodeState(nodeId) + { nodes[nodeId].tier = tier; emit TierSet(nodeId, tier); } From d14feb9a8df1ef63fae324341ba6eabc2069a882 Mon Sep 17 00:00:00 2001 From: Siftal Date: Tue, 4 Jul 2023 21:53:25 +0330 Subject: [PATCH 10/46] feat: remove unnecessary check --- contracts/MuonNodeManager.sol | 2 -- 1 file changed, 2 deletions(-) diff --git a/contracts/MuonNodeManager.sol b/contracts/MuonNodeManager.sol index c5ee78d..cab3dba 100644 --- a/contracts/MuonNodeManager.sol +++ b/contracts/MuonNodeManager.sol @@ -157,8 +157,6 @@ contract MuonNodeManager is updateState updateNodeState(nodeId) { - require(nodes[nodeId].active, "Node is not active."); - require(roleId > 0 && roleId <= lastRoleId, "Invalid role ID."); require( From 518dd50c6614f661f66e09f60bff2bc1b4adf030 Mon Sep 17 00:00:00 2001 From: Siftal Date: Tue, 4 Jul 2023 22:01:23 +0330 Subject: [PATCH 11/46] fix: fix an issue --- contracts/MuonNodeStaking.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contracts/MuonNodeStaking.sol b/contracts/MuonNodeStaking.sol index e8f5c29..e2c0d85 100644 --- a/contracts/MuonNodeStaking.sol +++ b/contracts/MuonNodeStaking.sol @@ -348,7 +348,7 @@ contract MuonNodeStaking is require(node.id != 0, "Node not found for the staker address."); require( - users[msg.sender].paidRewardPerToken < paidRewardPerToken && + users[msg.sender].paidRewardPerToken <= paidRewardPerToken && paidRewardPerToken <= rewardPerToken(), "Invalid paidRewardPerToken value." ); From eacd683c2331a5ec6c409e514ef1e70aad6d1df6 Mon Sep 17 00:00:00 2001 From: Siftal Date: Wed, 5 Jul 2023 20:47:34 +0330 Subject: [PATCH 12/46] feat: change minStakeAmountPerNode to minStakeAmount --- contracts/MuonNodeStaking.sol | 16 ++++++++-------- test/muonNodeStaking.ts | 8 ++++---- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/contracts/MuonNodeStaking.sol b/contracts/MuonNodeStaking.sol index e2c0d85..b45eddc 100644 --- a/contracts/MuonNodeStaking.sol +++ b/contracts/MuonNodeStaking.sol @@ -22,7 +22,7 @@ contract MuonNodeStaking is uint256 public exitPendingPeriod; - uint256 public minStakeAmountPerNode; + uint256 public minStakeAmount; uint256 public periodFinish; @@ -101,7 +101,7 @@ contract MuonNodeStaking is bondedToken = IBondedToken(bondedTokenAddress); exitPendingPeriod = 7 days; - minStakeAmountPerNode = 1000 ether; + minStakeAmount = 1000 ether; REWARD_PERIOD = 30 days; validatePubKey(_muonPublicKey.x); @@ -306,7 +306,7 @@ contract MuonNodeStaking is uint256 amount = valueOfBondedToken(tokenId); require( - amount >= minStakeAmountPerNode, + amount >= minStakeAmount, "Insufficient amount to run a node." ); @@ -458,7 +458,7 @@ contract MuonNodeStaking is uint256 amount = valueOfBondedToken(tokenId); require( - amount >= minStakeAmountPerNode, + amount >= minStakeAmount, "Insufficient amount to run a node." ); @@ -575,9 +575,9 @@ contract MuonNodeStaking is emit ExitPendingPeriodUpdated(val); } - function setMinStakeAmountPerNode(uint256 val) public onlyRole(DAO_ROLE) { - minStakeAmountPerNode = val; - emit MinStakeAmountPerNodeUpdated(val); + function setMinStakeAmount(uint256 val) public onlyRole(DAO_ROLE) { + minStakeAmount = val; + emit MinStakeAmountUpdated(val); } function setMuonAppId(uint256 _muonAppId) public onlyRole(DAO_ROLE) { @@ -619,7 +619,7 @@ contract MuonNodeStaking is uint256 rewardPeriod ); event ExitPendingPeriodUpdated(uint256 exitPendingPeriod); - event MinStakeAmountPerNodeUpdated(uint256 minStakeAmountPerNode); + event MinStakeAmountUpdated(uint256 minStakeAmount); event MuonAppIdUpdated(uint256 muonAppId); event MuonPublicKeyUpdated(PublicKey muonPublicKey); event StakeLocked(address indexed stakerAddress); diff --git a/test/muonNodeStaking.ts b/test/muonNodeStaking.ts index 08f5e54..f968372 100644 --- a/test/muonNodeStaking.ts +++ b/test/muonNodeStaking.ts @@ -943,15 +943,15 @@ describe("MuonNodeStaking", function () { expect(await nodeStaking.exitPendingPeriod()).eq(newVal); }); - it("DAO should have the ability to update the minStakeAmountPerNode", async function () { + it("DAO should have the ability to update the minStakeAmount", async function () { const newVal = ONE.mul(10); await expect( - nodeStaking.connect(daoRole).setMinStakeAmountPerNode(newVal) + nodeStaking.connect(daoRole).setMinStakeAmount(newVal) ) - .to.emit(nodeStaking, "MinStakeAmountPerNodeUpdated") + .to.emit(nodeStaking, "MinStakeAmountUpdated") .withArgs(newVal); - expect(await nodeStaking.minStakeAmountPerNode()).eq(newVal); + expect(await nodeStaking.minStakeAmount()).eq(newVal); }); it("DAO should have the ability to update the muonAppId", async function () { From ad192c9a2707359288c88d2852fc84c9427e271a Mon Sep 17 00:00:00 2001 From: Siftal Date: Wed, 5 Jul 2023 23:56:10 +0330 Subject: [PATCH 13/46] feat: add setMuonNodeTire function --- contracts/MuonNodeManager.sol | 4 ++- contracts/MuonNodeStaking.sol | 38 +++++++++++++++++------ contracts/interfaces/IMuonNodeManager.sol | 2 ++ test/muonNodeStaking.ts | 12 +++---- 4 files changed, 38 insertions(+), 18 deletions(-) diff --git a/contracts/MuonNodeManager.sol b/contracts/MuonNodeManager.sol index cab3dba..b6f8e79 100644 --- a/contracts/MuonNodeManager.sol +++ b/contracts/MuonNodeManager.sol @@ -300,10 +300,12 @@ contract MuonNodeManager is */ function setTier(uint64 nodeId, uint8 tier) public - onlyRole(DAO_ROLE) + onlyRole(ADMIN_ROLE) updateState updateNodeState(nodeId) { + require(nodes[nodeId].tier != tier, "Already set."); + nodes[nodeId].tier = tier; emit TierSet(nodeId, tier); } diff --git a/contracts/MuonNodeStaking.sol b/contracts/MuonNodeStaking.sol index b45eddc..a91c599 100644 --- a/contracts/MuonNodeStaking.sol +++ b/contracts/MuonNodeStaking.sol @@ -233,7 +233,7 @@ contract MuonNodeStaking is bondedToken.lock(tokenId, tokens, amounts); - updateStaking(); + _updateStaking(msg.sender); } /** @@ -259,7 +259,7 @@ contract MuonNodeStaking is bondedToken.merge(tokenIdA, tokenIdB); - updateStaking(); + _updateStaking(msg.sender); } /** @@ -292,16 +292,23 @@ contract MuonNodeStaking is * and updates the balance and total staked amount accordingly. * Only callable by staker. */ - function updateStaking() public updateReward(msg.sender) { + function updateStaking() external { + _updateStaking(msg.sender); + } + + function _updateStaking(address stakerAddress) + private + updateReward(stakerAddress) + { IMuonNodeManager.Node memory node = nodeManager.stakerAddressInfo( - msg.sender + stakerAddress ); require( node.id != 0 && node.active, "No active node found for the staker address." ); - uint256 tokenId = users[msg.sender].tokenId; + uint256 tokenId = users[stakerAddress].tokenId; require(tokenId != 0, "No staking found for the staker address."); uint256 amount = valueOfBondedToken(tokenId); @@ -315,11 +322,11 @@ contract MuonNodeStaking is amount = maxStakeAmount; } - if (users[msg.sender].balance != amount) { - totalStaked -= users[msg.sender].balance; - users[msg.sender].balance = amount; + if (users[stakerAddress].balance != amount) { + totalStaked -= users[stakerAddress].balance; + users[stakerAddress].balance = amount; totalStaked += amount; - emit Staked(msg.sender, amount); + emit Staked(stakerAddress, amount); } } @@ -603,6 +610,19 @@ contract MuonNodeStaking is emit TierMaxStakeUpdated(tier, maxStakeAmount); } + function setMuonNodeTire(address stakerAddress, uint8 tier) + public + onlyRole(DAO_ROLE) + updateReward(stakerAddress) + { + IMuonNodeManager.Node memory node = nodeManager.stakerAddressInfo( + stakerAddress + ); + + nodeManager.setTier(node.id, tier); + _updateStaking(stakerAddress); + } + // ======== Events ======== event Staked(address indexed stakerAddress, uint256 amount); event Withdrawn(address indexed stakerAddress, uint256 tokenId); diff --git a/contracts/interfaces/IMuonNodeManager.sol b/contracts/interfaces/IMuonNodeManager.sol index c65228e..c0500c8 100644 --- a/contracts/interfaces/IMuonNodeManager.sol +++ b/contracts/interfaces/IMuonNodeManager.sol @@ -29,4 +29,6 @@ interface IMuonNodeManager { view returns (Node memory node); + function setTier(uint64 nodeId, uint8 tier) external; + } diff --git a/test/muonNodeStaking.ts b/test/muonNodeStaking.ts index f968372..5980c29 100644 --- a/test/muonNodeStaking.ts +++ b/test/muonNodeStaking.ts @@ -142,8 +142,7 @@ describe("MuonNodeStaking", function () { // newly added nodes' tiers are 0, so their maximum stake amount will be 0 expect((await nodeStaking.users(staker1.address)).balance).eq(0); // admins can set tier - await nodeManager.connect(daoRole).setTier(1, 1); - await nodeStaking.connect(staker1).updateStaking(); + await nodeStaking.connect(daoRole).setMuonNodeTire(staker1.address, 1); expect((await nodeStaking.users(staker1.address)).balance).eq( tier1MaxStake ); @@ -151,8 +150,7 @@ describe("MuonNodeStaking", function () { 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 nodeManager.connect(daoRole).setTier(2, 2); - await nodeStaking.connect(staker2).updateStaking(); + await nodeStaking.connect(daoRole).setMuonNodeTire(staker2.address, 2); }); const getDummySig = async ( @@ -258,8 +256,7 @@ describe("MuonNodeStaking", function () { expect(node.tier).eq(0); expect((await nodeStaking.users(staker3.address)).balance).eq(0); // admins can set tier - await nodeManager.connect(daoRole).setTier(node.id, 1); - await nodeStaking.connect(staker3).updateStaking(); + await nodeStaking.connect(daoRole).setMuonNodeTire(staker3.address, 1); expect((await nodeStaking.users(staker3.address)).balance) .eq(await nodeStaking.tiersMaxStakeAmount(1)) .eq(tier1MaxStake); @@ -429,8 +426,7 @@ describe("MuonNodeStaking", function () { // add new node await nodeStaking.connect(staker3).addMuonNode(node3.address, peerId3, 3); - await nodeManager.connect(daoRole).setTier(3, 2); - await nodeStaking.connect(staker3).updateStaking(); + await nodeStaking.connect(daoRole).setMuonNodeTire(staker3.address, 2); // Increase time by 10 days targetTimestamp = distributeTimestamp + 2 * tenDays; From 01ddb15e63fddcf2395281b581bb205d19d0a79e Mon Sep 17 00:00:00 2001 From: Siftal Date: Thu, 6 Jul 2023 00:11:42 +0330 Subject: [PATCH 14/46] feat: remove unnecessary check --- contracts/MuonNodeStaking.sol | 9 --------- test/muonNodeStaking.ts | 6 ++---- 2 files changed, 2 insertions(+), 13 deletions(-) diff --git a/contracts/MuonNodeStaking.sol b/contracts/MuonNodeStaking.sol index a91c599..b6f10ca 100644 --- a/contracts/MuonNodeStaking.sol +++ b/contracts/MuonNodeStaking.sol @@ -47,9 +47,6 @@ contract MuonNodeStaking is IERC20 public muonToken; - // reqId => bool - mapping(bytes => bool) public withdrawRequests; - // stakerAddress => bool mapping(address => bool) public lockedStakes; @@ -342,11 +339,6 @@ contract MuonNodeStaking is bytes calldata reqId, SchnorrSign calldata signature ) public { - require( - !withdrawRequests[reqId], - "This request has already been submitted." - ); - require(amount > 0, "Invalid withdrawal amount."); IMuonNodeManager.Node memory node = nodeManager.stakerAddressInfo( @@ -392,7 +384,6 @@ contract MuonNodeStaking is users[msg.sender].pendingRewards = 0; users[msg.sender].paidReward += amount; users[msg.sender].paidRewardPerToken = paidRewardPerToken; - withdrawRequests[reqId] = true; muonToken.transfer(msg.sender, amount); emit RewardGot(reqId, msg.sender, amount); } diff --git a/test/muonNodeStaking.ts b/test/muonNodeStaking.ts index 5980c29..35e5f19 100644 --- a/test/muonNodeStaking.ts +++ b/test/muonNodeStaking.ts @@ -654,7 +654,7 @@ describe("MuonNodeStaking", function () { // try to withdraw again await expect(getReward(staker1, withdrawSig)).to.be.revertedWith( - "This request has already been submitted." + "Invalid signature." ); }); @@ -941,9 +941,7 @@ describe("MuonNodeStaking", function () { it("DAO should have the ability to update the minStakeAmount", async function () { const newVal = ONE.mul(10); - await expect( - nodeStaking.connect(daoRole).setMinStakeAmount(newVal) - ) + await expect(nodeStaking.connect(daoRole).setMinStakeAmount(newVal)) .to.emit(nodeStaking, "MinStakeAmountUpdated") .withArgs(newVal); From 7e64a4041fe7572937ca42571715111712bfa64e Mon Sep 17 00:00:00 2001 From: Siftal Date: Sat, 8 Jul 2023 13:47:11 +0330 Subject: [PATCH 15/46] feat: set balance to zero on node exit --- contracts/MuonNodeStaking.sol | 42 +++++++++-------------------------- test/muonNodeStaking.ts | 25 +++++++++++++++++---- 2 files changed, 31 insertions(+), 36 deletions(-) diff --git a/contracts/MuonNodeStaking.sol b/contracts/MuonNodeStaking.sol index b6f10ca..d20343b 100644 --- a/contracts/MuonNodeStaking.sol +++ b/contracts/MuonNodeStaking.sol @@ -372,14 +372,7 @@ contract MuonNodeStaking is ); require(verified, "Invalid signature."); - if (node.active) { - require(amount <= earned(msg.sender), "Invalid withdrawal amount."); - } else { - require( - amount <= users[msg.sender].pendingRewards, - "Invalid withdrawal amount." - ); - } + require(amount <= earned(msg.sender), "Invalid withdrawal amount."); users[msg.sender].pendingRewards = 0; users[msg.sender].paidReward += amount; @@ -401,6 +394,7 @@ contract MuonNodeStaking is require(node.active, "The node is already deactivated."); totalStaked -= users[msg.sender].balance; + users[msg.sender].balance = 0; nodeManager.deactiveNode(node.id); emit ExitRequested(msg.sender); } @@ -425,10 +419,6 @@ contract MuonNodeStaking is "Your stake is currently locked and cannot be withdrawn." ); - uint256 amount = users[msg.sender].balance; - require(amount > 0, "No staked balance available for withdrawal."); - - users[msg.sender].balance = 0; uint256 tokenId = users[msg.sender].tokenId; require(tokenId != 0, "No staking found for the staker address."); @@ -501,13 +491,12 @@ contract MuonNodeStaking is * @return The current reward per token. */ function rewardPerToken() public view returns (uint256) { - return - totalStaked == 0 - ? rewardPerTokenStored - : rewardPerTokenStored + - (((lastTimeRewardApplicable() - lastUpdateTime) * - rewardRate * - 1e18) / totalStaked); + if (totalStaked == 0) { + return rewardPerTokenStored; + } else { + return rewardPerTokenStored + + (lastTimeRewardApplicable() - lastUpdateTime) * rewardRate * 1e18 / totalStaked; + } } /** @@ -516,19 +505,8 @@ contract MuonNodeStaking is * @return The total rewards earned by a node. */ function earned(address account) public view returns (uint256) { - IMuonNodeManager.Node memory node = nodeManager.stakerAddressInfo( - account - ); - - if (!node.active) { - return 0; - } else { - return - (users[account].balance * - (rewardPerToken() - users[account].paidRewardPerToken)) / - 1e18 + - users[account].pendingRewards; - } + User memory user = users[account]; + return user.balance * (rewardPerToken() - user.paidRewardPerToken) / 1e18 + user.pendingRewards; } /** diff --git a/test/muonNodeStaking.ts b/test/muonNodeStaking.ts index 35e5f19..3708aee 100644 --- a/test/muonNodeStaking.ts +++ b/test/muonNodeStaking.ts @@ -805,16 +805,23 @@ describe("MuonNodeStaking", function () { 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(ONE.mul(1000)); + 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(0); + 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; @@ -850,6 +857,8 @@ describe("MuonNodeStaking", function () { 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 () { @@ -857,6 +866,8 @@ describe("MuonNodeStaking", function () { const initialReward = thirtyDays * 3000; await distributeRewards(initialReward); + const rewardRate = await nodeStaking.rewardRate(); + // Increase time by 10 days await evmIncreaseTime(60 * 60 * 24 * 10); @@ -882,8 +893,8 @@ describe("MuonNodeStaking", function () { await evmIncreaseTime(60 * 60 * 24 * 7); const u1 = await nodeStaking.users(staker1.address); - expect(u1.balance).eq(ONE.mul(1000)); - expect(u1.pendingRewards).to.closeTo(earned1, 2000); + expect(u1.balance).eq(0); + expect(u1.pendingRewards).to.closeTo(earned1, rewardRate); expect(u1.paidReward).eq(0); // try to withdraw the stake @@ -904,6 +915,10 @@ describe("MuonNodeStaking", function () { 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 @@ -926,6 +941,8 @@ describe("MuonNodeStaking", function () { 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); }); }); From 2abbcee92890aacd6ab5e0f0dc58adf119b82f76 Mon Sep 17 00:00:00 2001 From: Siftal Date: Sat, 8 Jul 2023 22:31:05 +0330 Subject: [PATCH 16/46] feat: calculate not paid rewards and redistribute them in the next distribution round automatically --- contracts/MuonNodeStaking.sol | 17 ++++++--- test/muonNodeStaking.ts | 66 ++++++++++++++++++++++++++++++----- 2 files changed, 70 insertions(+), 13 deletions(-) diff --git a/contracts/MuonNodeStaking.sol b/contracts/MuonNodeStaking.sol index d20343b..46869b3 100644 --- a/contracts/MuonNodeStaking.sol +++ b/contracts/MuonNodeStaking.sol @@ -20,6 +20,8 @@ contract MuonNodeStaking is uint256 public totalStaked; + uint256 public notPaidRewards; + uint256 public exitPendingPeriod; uint256 public minStakeAmount; @@ -346,8 +348,9 @@ contract MuonNodeStaking is ); require(node.id != 0, "Node not found for the staker address."); + User memory user = users[msg.sender]; require( - users[msg.sender].paidRewardPerToken <= paidRewardPerToken && + user.paidRewardPerToken <= paidRewardPerToken && paidRewardPerToken <= rewardPerToken(), "Invalid paidRewardPerToken value." ); @@ -358,7 +361,7 @@ contract MuonNodeStaking is muonAppId, reqId, msg.sender, - users[msg.sender].paidReward, + user.paidReward, paidRewardPerToken, amount ) @@ -372,6 +375,10 @@ contract MuonNodeStaking is ); 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; @@ -474,12 +481,14 @@ contract MuonNodeStaking is onlyRole(REWARD_ROLE) { if (block.timestamp >= periodFinish) { - rewardRate = reward / REWARD_PERIOD; + rewardRate = (reward + notPaidRewards) / REWARD_PERIOD; } else { uint256 remaining = periodFinish - block.timestamp; uint256 leftover = remaining * rewardRate; - rewardRate = (reward + leftover) / REWARD_PERIOD; + rewardRate = (reward + leftover + notPaidRewards) / REWARD_PERIOD; } + + notPaidRewards = 0; lastUpdateTime = block.timestamp; periodFinish = block.timestamp + REWARD_PERIOD; emit RewardsDistributed(reward, block.timestamp, REWARD_PERIOD); diff --git a/test/muonNodeStaking.ts b/test/muonNodeStaking.ts index 3708aee..2207c5b 100644 --- a/test/muonNodeStaking.ts +++ b/test/muonNodeStaking.ts @@ -669,27 +669,32 @@ describe("MuonNodeStaking", function () { // Check staker1's balance before withdrawal const balance1 = await pion.balanceOf(staker1.address); - // generate a dummy tts sig to withdraw 100% of the maximum reward + // 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 earned1 = (await nodeStaking.earned(staker1.address)); const withdrawSig1 = await getDummySig( staker1.address, paidReward1, rewardPerToken1, - earned1 + 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); + 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)); - expect(balance2).eq(Math.floor(initialReward / 9)); + expect(balance2).eq(balance1.add(earned1 * 80 / 100)); // Increase time by 5 days await evmIncreaseTime(60 * 60 * 24 * 5); @@ -708,14 +713,57 @@ describe("MuonNodeStaking", function () { // 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(earned1.add(earned2)); + 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)); - // tolerance for 2 seconds - expect(balance3).to.closeTo(Math.floor(initialReward / 6), 3000); + + // 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 () { From 585f9aff5f28cf2654eac46c0e260425266ae2c7 Mon Sep 17 00:00:00 2001 From: Siftal Date: Sat, 8 Jul 2023 22:37:12 +0330 Subject: [PATCH 17/46] style: format the code --- contracts/MuonNodeStaking.sol | 28 ++++++++++++++++------------ test/muonNodeStaking.ts | 27 +++++++++++++++------------ 2 files changed, 31 insertions(+), 24 deletions(-) diff --git a/contracts/MuonNodeStaking.sol b/contracts/MuonNodeStaking.sol index 46869b3..69d1218 100644 --- a/contracts/MuonNodeStaking.sol +++ b/contracts/MuonNodeStaking.sol @@ -311,10 +311,7 @@ contract MuonNodeStaking is require(tokenId != 0, "No staking found for the staker address."); uint256 amount = valueOfBondedToken(tokenId); - require( - amount >= minStakeAmount, - "Insufficient amount to run a node." - ); + require(amount >= minStakeAmount, "Insufficient amount to run a node."); uint256 maxStakeAmount = tiersMaxStakeAmount[node.tier]; if (amount > maxStakeAmount) { @@ -375,7 +372,10 @@ contract MuonNodeStaking is ); require(verified, "Invalid signature."); - uint256 maxReward = user.balance * (paidRewardPerToken - user.paidRewardPerToken) / 1e18 + user.pendingRewards; + uint256 maxReward = (user.balance * + (paidRewardPerToken - user.paidRewardPerToken)) / + 1e18 + + user.pendingRewards; require(amount <= maxReward, "Invalid withdrawal amount."); notPaidRewards += (maxReward - amount); @@ -452,10 +452,7 @@ contract MuonNodeStaking is ); uint256 amount = valueOfBondedToken(tokenId); - require( - amount >= minStakeAmount, - "Insufficient amount to run a node." - ); + require(amount >= minStakeAmount, "Insufficient amount to run a node."); bondedToken.transferFrom(msg.sender, address(this), tokenId); users[msg.sender].tokenId = tokenId; @@ -503,8 +500,12 @@ contract MuonNodeStaking is if (totalStaked == 0) { return rewardPerTokenStored; } else { - return rewardPerTokenStored + - (lastTimeRewardApplicable() - lastUpdateTime) * rewardRate * 1e18 / totalStaked; + return + rewardPerTokenStored + + ((lastTimeRewardApplicable() - lastUpdateTime) * + rewardRate * + 1e18) / + totalStaked; } } @@ -515,7 +516,10 @@ contract MuonNodeStaking is */ function earned(address account) public view returns (uint256) { User memory user = users[account]; - return user.balance * (rewardPerToken() - user.paidRewardPerToken) / 1e18 + user.pendingRewards; + return + (user.balance * (rewardPerToken() - user.paidRewardPerToken)) / + 1e18 + + user.pendingRewards; } /** diff --git a/test/muonNodeStaking.ts b/test/muonNodeStaking.ts index 2207c5b..68ef4f7 100644 --- a/test/muonNodeStaking.ts +++ b/test/muonNodeStaking.ts @@ -672,12 +672,12 @@ describe("MuonNodeStaking", function () { // 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 earned1 = await nodeStaking.earned(staker1.address); const withdrawSig1 = await getDummySig( staker1.address, paidReward1, rewardPerToken1, - earned1 * 80 / 100 + (earned1 * 80) / 100 ); const notPaidRewards1 = await nodeStaking.notPaidRewards(); @@ -687,14 +687,14 @@ describe("MuonNodeStaking", function () { await getReward(staker1, withdrawSig1); const notPaidRewards2 = await nodeStaking.notPaidRewards(); - expect(notPaidRewards2).eq(earned1 * 20 / 100); + 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.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)); + expect(balance2).eq(balance1.add((earned1 * 80) / 100)); // Increase time by 5 days await evmIncreaseTime(60 * 60 * 24 * 5); @@ -718,7 +718,7 @@ describe("MuonNodeStaking", function () { // check the result of withdrawing const staker1Stake2 = await nodeStaking.users(staker1.address); - expect(staker1Stake2.paidReward).eq(earned2.add(earned1 * 80 / 100)); + 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)); @@ -734,21 +734,23 @@ describe("MuonNodeStaking", function () { staker1.address, paidReward3, rewardPerToken3, - earned3 * 50 / 100 + (earned3 * 50) / 100 ); // withdraw 100% of reward await getReward(staker1, withdrawSig3); const notPaidRewards4 = await nodeStaking.notPaidRewards(); - expect(notPaidRewards4).eq(notPaidRewards3.add(earned3 * 50 / 100)); + 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.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)); + expect(balance4).eq(balance3.add((earned3 * 50) / 100)); // Redistribute rewards const periodFinish = await nodeStaking.periodFinish(); @@ -756,14 +758,15 @@ describe("MuonNodeStaking", function () { 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)); + 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 () { From c62cd7018e2e5bcfab2f22ff2d86707a1af024ea Mon Sep 17 00:00:00 2001 From: Siftal Date: Sun, 9 Jul 2023 00:00:03 +0330 Subject: [PATCH 18/46] feat: add getInfo function --- contracts/MuonNodeManager.sol | 26 ++++++++++++++++++++++++++ test/muonNodeManager.ts | 27 +++++++++++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/contracts/MuonNodeManager.sol b/contracts/MuonNodeManager.sol index b6f8e79..0042029 100644 --- a/contracts/MuonNodeManager.sol +++ b/contracts/MuonNodeManager.sol @@ -337,6 +337,32 @@ contract MuonNodeManager is 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); diff --git a/test/muonNodeManager.ts b/test/muonNodeManager.ts index 5db5e52..f8bbce7 100644 --- a/test/muonNodeManager.ts +++ b/test/muonNodeManager.ts @@ -363,6 +363,33 @@ describe("MuonNodeManager", function () { 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 () { From 5c603408271f2376204ff1b367c8c3afcefab0d1 Mon Sep 17 00:00:00 2001 From: Siftal Date: Wed, 12 Jul 2023 15:31:47 +0330 Subject: [PATCH 19/46] test: update tests --- test/muonNodeStaking.ts | 62 ++++++++++++++++++++++++++++------------- 1 file changed, 42 insertions(+), 20 deletions(-) diff --git a/test/muonNodeStaking.ts b/test/muonNodeStaking.ts index 68ef4f7..fae93f9 100644 --- a/test/muonNodeStaking.ts +++ b/test/muonNodeStaking.ts @@ -44,6 +44,11 @@ describe("MuonNodeStaking", function () { x: "0x570513014bbf0ddc4b0ac6b71164ff1186f26053a4df9facd79d9268456090c9", parity: 0, }; + + const tier1 = 1; + const tier2 = 2; + const tier3 = 3; + const tier1MaxStake = ONE.mul(1000); const tier2MaxStake = ONE.mul(4000); const tier3MaxStake = ONE.mul(10000); @@ -135,22 +140,12 @@ describe("MuonNodeStaking", function () { 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); - // check added node - expect(await bondedPion.ownerOf(1)).eq(nodeStaking.address); - expect((await nodeStaking.users(staker1.address)).tokenId).eq(1); - expect(await nodeStaking.valueOfBondedToken(1)).eq(ONE.mul(3000)); - // newly added nodes' tiers are 0, so their maximum stake amount will be 0 - expect((await nodeStaking.users(staker1.address)).balance).eq(0); - // admins can set tier - await nodeStaking.connect(daoRole).setMuonNodeTire(staker1.address, 1); - expect((await nodeStaking.users(staker1.address)).balance).eq( - tier1MaxStake - ); + await nodeStaking.connect(daoRole).setMuonNodeTire(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).setMuonNodeTire(staker2.address, 2); + await nodeStaking.connect(daoRole).setMuonNodeTire(staker2.address, tier2); }); const getDummySig = async ( @@ -159,9 +154,6 @@ describe("MuonNodeStaking", function () { rewardPerToken, amount ) => { - // console.log( - // `http://localhost:8000/v1/?app=tss_reward_oracle_test&method=reward¶ms[stakerAddress]=${stakerAddress}¶ms[paidReward]=${paidReward}¶ms[rewardPerToken]=${rewardPerToken}¶ms[amount]=${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}` ); @@ -187,9 +179,9 @@ describe("MuonNodeStaking", function () { return tokenId; }; - const distributeRewards = async (initialReward) => { - await pion.connect(rewardRole).transfer(nodeStaking.address, initialReward); - await nodeStaking.connect(rewardRole).distributeRewards(initialReward); + const distributeRewards = async (rewardAmount) => { + await pion.connect(rewardRole).transfer(nodeStaking.address, rewardAmount); + await nodeStaking.connect(rewardRole).distributeRewards(rewardAmount); }; const evmIncreaseTime = async (amount) => { @@ -219,7 +211,21 @@ describe("MuonNodeStaking", function () { 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); @@ -227,7 +233,21 @@ describe("MuonNodeStaking", function () { 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 () { @@ -256,7 +276,9 @@ describe("MuonNodeStaking", function () { expect(node.tier).eq(0); expect((await nodeStaking.users(staker3.address)).balance).eq(0); // admins can set tier - await nodeStaking.connect(daoRole).setMuonNodeTire(staker3.address, 1); + await nodeStaking + .connect(daoRole) + .setMuonNodeTire(staker3.address, tier1); expect((await nodeStaking.users(staker3.address)).balance) .eq(await nodeStaking.tiersMaxStakeAmount(1)) .eq(tier1MaxStake); @@ -426,7 +448,7 @@ describe("MuonNodeStaking", function () { // add new node await nodeStaking.connect(staker3).addMuonNode(node3.address, peerId3, 3); - await nodeStaking.connect(daoRole).setMuonNodeTire(staker3.address, 2); + await nodeStaking.connect(daoRole).setMuonNodeTire(staker3.address, tier2); // Increase time by 10 days targetTimestamp = distributeTimestamp + 2 * tenDays; From ee75a25f7fb3afdccc3716f0c44aedd211223201 Mon Sep 17 00:00:00 2001 From: Siftal Date: Mon, 17 Jul 2023 17:08:51 +0330 Subject: [PATCH 20/46] feat: add a new getEditedNodes function --- contracts/MuonNodeManager.sol | 52 +++++++++++++++++++++++++-- test/muonNodeManager.ts | 68 +++++++++++++++++++++++++++++------ 2 files changed, 108 insertions(+), 12 deletions(-) diff --git a/contracts/MuonNodeManager.sol b/contracts/MuonNodeManager.sol index 0042029..07764d6 100644 --- a/contracts/MuonNodeManager.sol +++ b/contracts/MuonNodeManager.sol @@ -27,6 +27,12 @@ contract MuonNodeManager is // 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; @@ -52,6 +58,7 @@ contract MuonNodeManager is */ modifier updateNodeState(uint64 nodeId) { nodes[nodeId].lastEditTime = block.timestamp; + editLogs.push(EditLog(nodeId, block.timestamp)); _; } @@ -120,6 +127,8 @@ contract MuonNodeManager is nodeAddressIds[_nodeAddress] = lastNodeId; stakerAddressIds[_stakerAddress] = lastNodeId; + editLogs.push(EditLog(lastNodeId, block.timestamp)); + emit NodeAdded(lastNodeId, nodes[lastNodeId]); } @@ -238,7 +247,7 @@ contract MuonNodeManager is * @param _to The ending node ID. * @return nodesList An array of edited nodes. */ - function getEditedNodes( + function getAllNodes( uint256 _lastEditTime, uint64 _from, uint64 _to @@ -248,7 +257,7 @@ contract MuonNodeManager is require(_from <= _to, "Invalid range of node IDs."); nodesList = new Node[](100); - uint64 n = 0; + uint8 n = 0; for (uint64 i = _from; i <= _to && n < 100; i++) { Node memory node = nodes[i]; @@ -265,6 +274,45 @@ contract MuonNodeManager is } } + /** + * @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) + public + view + returns (Node[] memory nodesList, uint256 lastIndex) + { + uint256 startIndex = index == 0 ? editLogs.length - 1 : index - 1; + nodesList = new Node[](100); + uint8 nodesIndex = 0; + lastIndex = 0; + + for (uint256 i = startIndex; i > 0; i--) { + EditLog memory log = editLogs[i]; + + if (log.editTime > _lastEditTime) { + if (log.editTime == nodes[log.nodeId].lastEditTime) { + nodesList[nodesIndex] = nodes[editLogs[i].nodeId]; + nodesIndex++; + } + + if (nodesIndex == 100) { + lastIndex = i; + 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 _addr The node address. diff --git a/test/muonNodeManager.ts b/test/muonNodeManager.ts index f8bbce7..8646c47 100644 --- a/test/muonNodeManager.ts +++ b/test/muonNodeManager.ts @@ -124,7 +124,7 @@ describe("MuonNodeManager", function () { }); describe("get nods", function () { - it("should retrieve edited nodes or all nodes", async () => { + 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++) { @@ -167,7 +167,7 @@ describe("MuonNodeManager", function () { // 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.getEditedNodes( + const editedNodesList = await nodeManager.getAllNodes( lastEditTime, 1, 1000 @@ -178,11 +178,60 @@ describe("MuonNodeManager", function () { const nodeRoles = node.roles.map((role) => role.toNumber()); expect(nodeRoles).to.deep.equal([1]); - const allNodesList = await nodeManager.getEditedNodes(0, 1, 1000); + const allNodesList = await nodeManager.getAllNodes(0, 1, 1000); expect(allNodesList).to.have.lengthOf(15); expect(await nodeManager.lastNodeId()).to.be.equal(15); }); + + 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); + editedNodesList.push(...resp.nodesList); + lastIndex = resp.lastIndex; + if (lastIndex == 0) { + break; + } + } + + expect(editedNodesList).to.have.lengthOf(102); + }); }); describe("nodeAddressInfo", function () { @@ -296,12 +345,12 @@ describe("MuonNodeManager", function () { expect(nodeRoleSetEvents[1].args.nodeId).eq(nodeId); expect(nodeRoleSetEvents[1].args.roleId).eq(roleIdPoa); - const nodes = await nodeManager.getEditedNodes(0, 1, 1000); + 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.getEditedNodes(startTime, 1, 1000); + 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]); @@ -334,12 +383,12 @@ describe("MuonNodeManager", function () { let nodeRoles = node.roles.map((role) => role.toNumber()); expect(nodeRoles).to.deep.equal([1, 2]); - let nodes = await nodeManager.getEditedNodes(0, 1, 1000); + 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.getEditedNodes(startTime, 1, 1000); + 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]); @@ -353,12 +402,12 @@ describe("MuonNodeManager", function () { expect(nodeRoles.includes(1)).to.be.false; expect(nodeRoles.includes(2)).to.be.true; - nodes = await nodeManager.getEditedNodes(0, 1, 1000); + 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.getEditedNodes(startTime, 1, 1000); + editedNodes = await nodeManager.getAllNodes(startTime, 1, 1000); node = editedNodes[0]; nodeRoles = node.roles.map((role) => role.toNumber()); expect(nodeRoles).to.deep.equal([2]); @@ -404,5 +453,4 @@ describe("MuonNodeManager", function () { expect(node.tier).eq(newTier); }); }); - }); From c5c150680dac24d1058c1f395922c0a068d94d2b Mon Sep 17 00:00:00 2001 From: Siftal Date: Mon, 17 Jul 2023 17:47:07 +0330 Subject: [PATCH 21/46] feat: let DAO_ROLE to deactivate a node --- contracts/MuonNodeStaking.sol | 26 ++++++++++--- test/muonNodeStaking.ts | 71 ++++++++++++++++++++++++++++++++++- 2 files changed, 91 insertions(+), 6 deletions(-) diff --git a/contracts/MuonNodeStaking.sol b/contracts/MuonNodeStaking.sol index 69d1218..157f937 100644 --- a/contracts/MuonNodeStaking.sol +++ b/contracts/MuonNodeStaking.sol @@ -392,18 +392,34 @@ contract MuonNodeStaking is * @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() public updateReward(msg.sender) { + 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( - msg.sender + stakerAddress ); require(node.id != 0, "Node not found for the staker address."); require(node.active, "The node is already deactivated."); - totalStaked -= users[msg.sender].balance; - users[msg.sender].balance = 0; + totalStaked -= users[stakerAddress].balance; + users[stakerAddress].balance = 0; nodeManager.deactiveNode(node.id); - emit ExitRequested(msg.sender); } /** diff --git a/test/muonNodeStaking.ts b/test/muonNodeStaking.ts index fae93f9..3a6c1af 100644 --- a/test/muonNodeStaking.ts +++ b/test/muonNodeStaking.ts @@ -448,7 +448,9 @@ describe("MuonNodeStaking", function () { // add new node await nodeStaking.connect(staker3).addMuonNode(node3.address, peerId3, 3); - await nodeStaking.connect(daoRole).setMuonNodeTire(staker3.address, tier2); + await nodeStaking + .connect(daoRole) + .setMuonNodeTire(staker3.address, tier2); // Increase time by 10 days targetTimestamp = distributeTimestamp + 2 * tenDays; @@ -843,6 +845,73 @@ describe("MuonNodeStaking", function () { ); }); + 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; From edcfd0bd0a558b452d674316c9b5017eef24b9da Mon Sep 17 00:00:00 2001 From: Siftal Date: Mon, 17 Jul 2023 18:24:03 +0330 Subject: [PATCH 22/46] feat: check the balance of the node before withdrawing the stake --- contracts/MuonNodeStaking.sol | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/contracts/MuonNodeStaking.sol b/contracts/MuonNodeStaking.sol index 157f937..3e6b806 100644 --- a/contracts/MuonNodeStaking.sol +++ b/contracts/MuonNodeStaking.sol @@ -445,6 +445,11 @@ contract MuonNodeStaking is uint256 tokenId = users[msg.sender].tokenId; require(tokenId != 0, "No staking found for the staker address."); + if (users[msg.sender].balance > 0) { + totalStaked -= users[msg.sender].balance; + users[msg.sender].balance = 0; + } + bondedToken.safeTransferFrom(address(this), msg.sender, tokenId); users[msg.sender].tokenId = 0; emit Withdrawn(msg.sender, tokenId); From 46e3368114908ab4c321a2e7f76ff76cf0a8bc5d Mon Sep 17 00:00:00 2001 From: Siftal Date: Tue, 18 Jul 2023 21:02:07 +0330 Subject: [PATCH 23/46] fix: add nodes' roles --- contracts/MuonNodeManager.sol | 1 + 1 file changed, 1 insertion(+) diff --git a/contracts/MuonNodeManager.sol b/contracts/MuonNodeManager.sol index 07764d6..7c71bd4 100644 --- a/contracts/MuonNodeManager.sol +++ b/contracts/MuonNodeManager.sol @@ -297,6 +297,7 @@ contract MuonNodeManager is if (log.editTime > _lastEditTime) { if (log.editTime == nodes[log.nodeId].lastEditTime) { nodesList[nodesIndex] = nodes[editLogs[i].nodeId]; + nodesList[nodesIndex].roles = getNodeRoles(editLogs[i].nodeId); nodesIndex++; } From 4e7b630d17bfb7b506e8d3470bbf2bf58c872daf Mon Sep 17 00:00:00 2001 From: Siftal Date: Tue, 18 Jul 2023 21:56:30 +0330 Subject: [PATCH 24/46] feat: get response length as a parameter --- contracts/MuonNodeManager.sol | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/contracts/MuonNodeManager.sol b/contracts/MuonNodeManager.sol index 7c71bd4..3b54eb2 100644 --- a/contracts/MuonNodeManager.sol +++ b/contracts/MuonNodeManager.sol @@ -281,13 +281,13 @@ contract MuonNodeManager is * @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) + function getEditedNodes(uint256 _lastEditTime, uint256 index, uint8 _max) public view returns (Node[] memory nodesList, uint256 lastIndex) { uint256 startIndex = index == 0 ? editLogs.length - 1 : index - 1; - nodesList = new Node[](100); + nodesList = new Node[](_max); uint8 nodesIndex = 0; lastIndex = 0; @@ -301,7 +301,7 @@ contract MuonNodeManager is nodesIndex++; } - if (nodesIndex == 100) { + if (nodesIndex == _max) { lastIndex = i; break; } @@ -311,6 +311,7 @@ contract MuonNodeManager is assembly { mstore(nodesList, nodesIndex) } + return (nodesList, lastIndex); } From c46dd01701bc34abfe48a34ac5acb3107bb7de81 Mon Sep 17 00:00:00 2001 From: Siftal Date: Tue, 18 Jul 2023 22:29:00 +0330 Subject: [PATCH 25/46] fix: fix an issue --- contracts/MuonNodeManager.sol | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/contracts/MuonNodeManager.sol b/contracts/MuonNodeManager.sol index 3b54eb2..cd9345b 100644 --- a/contracts/MuonNodeManager.sol +++ b/contracts/MuonNodeManager.sol @@ -281,28 +281,28 @@ contract MuonNodeManager is * @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, uint8 _max) - public - view - returns (Node[] memory nodesList, uint256 lastIndex) - { + function getEditedNodes( + uint256 _lastEditTime, + uint256 index, + uint16 _max + ) public view returns (Node[] memory nodesList, uint256 lastIndex) { uint256 startIndex = index == 0 ? editLogs.length - 1 : index - 1; nodesList = new Node[](_max); uint8 nodesIndex = 0; lastIndex = 0; - for (uint256 i = startIndex; i > 0; i--) { - EditLog memory log = editLogs[i]; + for (uint256 i = startIndex + 1; i > 0; i--) { + EditLog memory log = editLogs[i - 1]; if (log.editTime > _lastEditTime) { if (log.editTime == nodes[log.nodeId].lastEditTime) { - nodesList[nodesIndex] = nodes[editLogs[i].nodeId]; - nodesList[nodesIndex].roles = getNodeRoles(editLogs[i].nodeId); + nodesList[nodesIndex] = nodes[log.nodeId]; + nodesList[nodesIndex].roles = getNodeRoles(log.nodeId); nodesIndex++; } if (nodesIndex == _max) { - lastIndex = i; + lastIndex = i - 1; break; } } From e04c7da0e36b6f6299215e2166ff517ca027f676 Mon Sep 17 00:00:00 2001 From: Siftal Date: Tue, 18 Jul 2023 22:37:39 +0330 Subject: [PATCH 26/46] feat: to improve performance break the loop instead of checking the condition --- contracts/MuonNodeManager.sol | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/contracts/MuonNodeManager.sol b/contracts/MuonNodeManager.sol index cd9345b..abac249 100644 --- a/contracts/MuonNodeManager.sol +++ b/contracts/MuonNodeManager.sol @@ -294,17 +294,19 @@ contract MuonNodeManager is for (uint256 i = startIndex + 1; i > 0; i--) { EditLog memory log = editLogs[i - 1]; - if (log.editTime > _lastEditTime) { - if (log.editTime == nodes[log.nodeId].lastEditTime) { - nodesList[nodesIndex] = nodes[log.nodeId]; - nodesList[nodesIndex].roles = getNodeRoles(log.nodeId); - nodesIndex++; - } - - if (nodesIndex == _max) { - lastIndex = i - 1; - break; - } + 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 == _max) { + lastIndex = i - 1; + break; } } // Resize the array to remove any unused elements From 27e6e6b0388186269f2457e24059e7f80d8b3f54 Mon Sep 17 00:00:00 2001 From: Siftal Date: Tue, 18 Jul 2023 22:54:27 +0330 Subject: [PATCH 27/46] feat: make the nodesList size dynamic --- contracts/MuonNodeManager.sol | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/contracts/MuonNodeManager.sol b/contracts/MuonNodeManager.sol index abac249..4b31201 100644 --- a/contracts/MuonNodeManager.sol +++ b/contracts/MuonNodeManager.sol @@ -256,9 +256,9 @@ contract MuonNodeManager is _to = _to <= lastNodeId ? _to : lastNodeId; require(_from <= _to, "Invalid range of node IDs."); - nodesList = new Node[](100); + nodesList = new Node[](_to - _from + 1); uint8 n = 0; - for (uint64 i = _from; i <= _to && n < 100; i++) { + for (uint64 i = _from; i <= _to; i++) { Node memory node = nodes[i]; if (node.lastEditTime > _lastEditTime) { From 012bd68a8048c0f3931c73de1f2511dfa16abb74 Mon Sep 17 00:00:00 2001 From: Siftal Date: Tue, 18 Jul 2023 22:57:06 +0330 Subject: [PATCH 28/46] test: update tests --- test/muonNodeManager.ts | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/test/muonNodeManager.ts b/test/muonNodeManager.ts index 8646c47..affdd52 100644 --- a/test/muonNodeManager.ts +++ b/test/muonNodeManager.ts @@ -178,10 +178,21 @@ describe("MuonNodeManager", function () { 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(15); + expect(allNodesList).to.have.lengthOf(115); - expect(await nodeManager.lastNodeId()).to.be.equal(15); + expect(await nodeManager.lastNodeId()).to.be.equal(115); }); it("should retrieve edited nodes", async () => { @@ -222,7 +233,11 @@ describe("MuonNodeManager", function () { const editedNodesList = []; let lastIndex = 0; while (true) { - const resp = await nodeManager.getEditedNodes(lastEditTime, lastIndex); + const resp = await nodeManager.getEditedNodes( + lastEditTime, + lastIndex, + 50 + ); editedNodesList.push(...resp.nodesList); lastIndex = resp.lastIndex; if (lastIndex == 0) { From 2cf81c369fe58d4ec72ac686a8e7b4f3402b7a68 Mon Sep 17 00:00:00 2001 From: Siftal Date: Sat, 22 Jul 2023 22:52:04 +0330 Subject: [PATCH 29/46] feat: ables DAO to pause/unpause specific functions --- contracts/MuonNodeStaking.sol | 49 +++++++++++++++++++++++++++++++---- 1 file changed, 44 insertions(+), 5 deletions(-) diff --git a/contracts/MuonNodeStaking.sol b/contracts/MuonNodeStaking.sol index 3e6b806..d6cc9de 100644 --- a/contracts/MuonNodeStaking.sol +++ b/contracts/MuonNodeStaking.sol @@ -66,6 +66,22 @@ contract MuonNodeStaking is // tier => maxStakeAmount mapping(uint8 => uint256) public tiersMaxStakeAmount; + struct FunctionPauseState { + bool isPaused; + } + mapping(string => FunctionPauseState) public functionPauseStates; + + /** + * @dev Modifier to make a function callable only when the contract is not paused. + */ + modifier whenFunctionNotPaused(string memory functionName) { + require( + !functionPauseStates[functionName].isPaused, + "Function is paused." + ); + _; + } + /** * @dev Modifier that updates the reward parameters * before all of the functions that can change the rewards. @@ -191,7 +207,7 @@ contract MuonNodeStaking is function lockToBondedToken( address[] memory tokens, uint256[] memory amounts - ) external { + ) external whenFunctionNotPaused("lockToBondedToken") { require( tokens.length == amounts.length, "Mismatch in the length of arrays." @@ -240,7 +256,10 @@ contract MuonNodeStaking is * 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 { + function mergeBondedTokens(uint256 tokenIdA) + external + whenFunctionNotPaused("mergeBondedTokens") + { require( bondedToken.ownerOf(tokenIdA) == msg.sender, "The sender is not the owner of the NFT." @@ -337,7 +356,7 @@ contract MuonNodeStaking is uint256 paidRewardPerToken, bytes calldata reqId, SchnorrSign calldata signature - ) public { + ) public whenFunctionNotPaused("getReward") { require(amount > 0, "Invalid withdrawal amount."); IMuonNodeManager.Node memory node = nodeManager.stakerAddressInfo( @@ -425,7 +444,7 @@ contract MuonNodeStaking is /** * @dev Allows stakers to withdraw their staked amount after exiting the network and exit pending period has passed. */ - function withdraw() public { + function withdraw() public whenFunctionNotPaused("withdraw") { IMuonNodeManager.Node memory node = nodeManager.stakerAddressInfo( msg.sender ); @@ -466,7 +485,7 @@ contract MuonNodeStaking is address nodeAddress, string calldata peerId, uint256 tokenId - ) public { + ) public whenFunctionNotPaused("addMuonNode") { require( users[msg.sender].tokenId == 0, "You have already staked an NFT. Multiple staking is not allowed." @@ -626,6 +645,24 @@ contract MuonNodeStaking is _updateStaking(stakerAddress); } + function pauseFunction(string memory functionName) + external + onlyRole(DAO_ROLE) + { + functionPauseStates[functionName].isPaused = true; + + emit Paused(functionName); + } + + function unpauseFunction(string memory functionName) + external + onlyRole(DAO_ROLE) + { + functionPauseStates[functionName].isPaused = false; + + emit Unpaused(functionName); + } + // ======== Events ======== event Staked(address indexed stakerAddress, uint256 amount); event Withdrawn(address indexed stakerAddress, uint256 tokenId); @@ -649,4 +686,6 @@ contract MuonNodeStaking is event StakeUnlocked(address indexed stakerAddress); event StakingTokenUpdated(address indexed token, uint256 multiplier); event TierMaxStakeUpdated(uint8 tier, uint256 maxStakeAmount); + event Paused(string functionName); + event Unpaused(string functionName); } From 3420da0ab4626109531796645923f67f582e4608 Mon Sep 17 00:00:00 2001 From: Siftal Date: Sat, 22 Jul 2023 22:52:21 +0330 Subject: [PATCH 30/46] test: update tests --- test/muonNodeStaking.ts | 112 ++++++++++++++++++++++++++++------------ 1 file changed, 80 insertions(+), 32 deletions(-) diff --git a/test/muonNodeStaking.ts b/test/muonNodeStaking.ts index 3a6c1af..f56619e 100644 --- a/test/muonNodeStaking.ts +++ b/test/muonNodeStaking.ts @@ -41,8 +41,8 @@ describe("MuonNodeStaking", function () { const muonAppId = "1566432988060666016333351531685287278204879617528298155619493815104572633831"; const muonPublicKey = { - x: "0x570513014bbf0ddc4b0ac6b71164ff1186f26053a4df9facd79d9268456090c9", - parity: 0, + x: "0x708f698d97949cd4385f02b1cc5283d394e9a7da68e3b6d2871c830b0751a5bb", + parity: 1, }; const tier1 = 1; @@ -1135,10 +1135,9 @@ describe("MuonNodeStaking", function () { 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.updateStakingTokens( - [dummyToken.address], - [dummyTokenMultiplier] - ); + 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( @@ -1151,10 +1150,9 @@ describe("MuonNodeStaking", function () { muonTokenMultiplier ); const newMuonTokenMultiplier = ONE.mul(3); - await nodeStaking.updateStakingTokens( - [pion.address], - [newMuonTokenMultiplier] - ); + 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); @@ -1170,10 +1168,9 @@ describe("MuonNodeStaking", function () { 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.updateStakingTokens( - [pion.address], - [newMuonTokenMultiplier] - ); + 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( @@ -1196,20 +1193,22 @@ describe("MuonNodeStaking", function () { muonLpTokenMultiplier ); - await nodeStaking.updateStakingTokens( - [ - pion.address, - dummyToken1.address, - dummyToken2.address, - pionLp.address, - ], - [ - newMuonTokenMultiplier, - dummyToken1Multiplier, - dummyToken2Multiplier, - newMuonLpTokenMultiplier, - ] - ); + 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); @@ -1245,10 +1244,12 @@ describe("MuonNodeStaking", function () { muonLpTokenMultiplier ); - await nodeStaking.updateStakingTokens( - [pionLp.address, pion.address], - [newMuonLpTokenMultiplier, newMuonTokenMultiplier] - ); + 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); @@ -1260,5 +1261,52 @@ describe("MuonNodeStaking", function () { newMuonLpTokenMultiplier ); }); + + it("DAO should have the ability to pause and unpause specific functions", async function () { + const functionName = "addMuonNode"; + expect(await nodeStaking.functionPauseStates(functionName)).to.be.false; + + const tx = await nodeStaking.connect(daoRole).pauseFunction(functionName); + const pausedFunction = await tx + .wait() + .then((receipt) => receipt.events[0].args.functionName); + expect(pausedFunction).eq(functionName); + + expect(await nodeStaking.functionPauseStates(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) + .unpauseFunction(functionName); + const unpausedFunction = await tx2 + .wait() + .then((receipt) => receipt.events[0].args.functionName); + expect(unpausedFunction).eq(functionName); + + expect(await nodeStaking.functionPauseStates(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; + }); }); }); From c1267aa47916e02be30e00283b047fbc8f5068e9 Mon Sep 17 00:00:00 2001 From: Siftal Date: Sat, 22 Jul 2023 23:15:43 +0330 Subject: [PATCH 31/46] feat: use IERC20Upgradeable instead of IERC20 --- contracts/MuonNodeStaking.sol | 31 ++++++++++++++----------------- 1 file changed, 14 insertions(+), 17 deletions(-) diff --git a/contracts/MuonNodeStaking.sol b/contracts/MuonNodeStaking.sol index d6cc9de..e00daf1 100644 --- a/contracts/MuonNodeStaking.sol +++ b/contracts/MuonNodeStaking.sol @@ -3,7 +3,8 @@ pragma solidity 0.8.19; import "@openzeppelin/contracts-upgradeable/access/AccessControlUpgradeable.sol"; import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; -import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import "@openzeppelin/contracts-upgradeable/interfaces/IERC20Upgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/token/ERC20/utils/SafeERC20Upgradeable.sol"; import "@openzeppelin/contracts-upgradeable/interfaces/IERC721Upgradeable.sol"; import "./utils/MuonClientBase.sol"; import "./interfaces/IMuonNodeManager.sol"; @@ -14,6 +15,8 @@ contract MuonNodeStaking is 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"); @@ -47,7 +50,7 @@ contract MuonNodeStaking is IMuonNodeManager public nodeManager; - IERC20 public muonToken; + IERC20Upgradeable public muonToken; // stakerAddress => bool mapping(address => bool) public lockedStakes; @@ -111,7 +114,7 @@ contract MuonNodeStaking is _setupRole(ADMIN_ROLE, msg.sender); _setupRole(DAO_ROLE, msg.sender); - muonToken = IERC20(muonTokenAddress); + muonToken = IERC20Upgradeable(muonTokenAddress); nodeManager = IMuonNodeManager(nodeManagerAddress); bondedToken = IBondedToken(bondedTokenAddress); @@ -221,18 +224,15 @@ contract MuonNodeStaking is ); for (uint256 i = 0; i < tokens.length; i++) { - uint256 balance = IERC20(tokens[i]).balanceOf(address(this)); + uint256 balance = IERC20Upgradeable(tokens[i]).balanceOf(address(this)); - require( - IERC20(tokens[i]).transferFrom( - msg.sender, - address(this), - amounts[i] - ), - "Failed to transfer tokens from your account to the staker contract." + IERC20Upgradeable(tokens[i]).safeTransferFrom( + msg.sender, + address(this), + amounts[i] ); - uint256 receivedAmount = IERC20(tokens[i]).balanceOf( + uint256 receivedAmount = IERC20Upgradeable(tokens[i]).balanceOf( address(this) ) - balance; require( @@ -240,10 +240,7 @@ contract MuonNodeStaking is "The discrepancy between the received amount and the claimed amount." ); - require( - IERC20(tokens[i]).approve(address(bondedToken), amounts[i]), - "Failed to approve to the bondedToken contract to spend tokens on your behalf." - ); + IERC20Upgradeable(tokens[i]).safeApprove(address(bondedToken), amounts[i]); } bondedToken.lock(tokenId, tokens, amounts); @@ -403,7 +400,7 @@ contract MuonNodeStaking is users[msg.sender].pendingRewards = 0; users[msg.sender].paidReward += amount; users[msg.sender].paidRewardPerToken = paidRewardPerToken; - muonToken.transfer(msg.sender, amount); + muonToken.safeTransfer(msg.sender, amount); emit RewardGot(reqId, msg.sender, amount); } From 245d8a0ae58f99168fd23455e8b1a6e982efdaf1 Mon Sep 17 00:00:00 2001 From: Siftal Date: Sat, 22 Jul 2023 23:44:32 +0330 Subject: [PATCH 32/46] fix: remove an extra import --- contracts/MuonNodeStaking.sol | 1 - 1 file changed, 1 deletion(-) diff --git a/contracts/MuonNodeStaking.sol b/contracts/MuonNodeStaking.sol index e00daf1..a29cbe7 100644 --- a/contracts/MuonNodeStaking.sol +++ b/contracts/MuonNodeStaking.sol @@ -5,7 +5,6 @@ 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 "@openzeppelin/contracts-upgradeable/interfaces/IERC721Upgradeable.sol"; import "./utils/MuonClientBase.sol"; import "./interfaces/IMuonNodeManager.sol"; import "./interfaces/IBondedToken.sol"; From c1bbf8c09121e4ab3bbd75d5d655838011477159 Mon Sep 17 00:00:00 2001 From: Siftal Date: Sun, 23 Jul 2023 00:07:28 +0330 Subject: [PATCH 33/46] feat: add constant for reward period --- contracts/MuonNodeStaking.sol | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/contracts/MuonNodeStaking.sol b/contracts/MuonNodeStaking.sol index a29cbe7..35241ad 100644 --- a/contracts/MuonNodeStaking.sol +++ b/contracts/MuonNodeStaking.sol @@ -20,6 +20,8 @@ contract MuonNodeStaking is 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; @@ -36,8 +38,6 @@ contract MuonNodeStaking is uint256 public rewardPerTokenStored; - uint256 public REWARD_PERIOD; - struct User { uint256 balance; uint256 paidReward; @@ -119,7 +119,6 @@ contract MuonNodeStaking is exitPendingPeriod = 7 days; minStakeAmount = 1000 ether; - REWARD_PERIOD = 30 days; validatePubKey(_muonPublicKey.x); muonPublicKey = _muonPublicKey; From 6797572eff02e11eba5f16bb89fd979b424dde51 Mon Sep 17 00:00:00 2001 From: Siftal Date: Sun, 23 Jul 2023 00:35:29 +0330 Subject: [PATCH 34/46] feat: check the NFT is received --- contracts/MuonNodeStaking.sol | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/contracts/MuonNodeStaking.sol b/contracts/MuonNodeStaking.sol index 35241ad..30bfc04 100644 --- a/contracts/MuonNodeStaking.sol +++ b/contracts/MuonNodeStaking.sol @@ -489,9 +489,11 @@ contract MuonNodeStaking is uint256 amount = valueOfBondedToken(tokenId); require(amount >= minStakeAmount, "Insufficient amount to run a node."); - bondedToken.transferFrom(msg.sender, address(this), tokenId); users[msg.sender].tokenId = tokenId; + bondedToken.transferFrom(msg.sender, address(this), tokenId); + require(bondedToken.ownerOf(tokenId) == address(this), "Not received the NFT."); + nodeManager.addNode( nodeAddress, msg.sender, // stakerAddress, From 3b3dbddff864b9c745163b84467a2819911925c7 Mon Sep 17 00:00:00 2001 From: Siftal Date: Sun, 23 Jul 2023 00:37:56 +0330 Subject: [PATCH 35/46] feat: write state variables before the call --- contracts/MuonNodeStaking.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contracts/MuonNodeStaking.sol b/contracts/MuonNodeStaking.sol index 30bfc04..4ebdbff 100644 --- a/contracts/MuonNodeStaking.sol +++ b/contracts/MuonNodeStaking.sol @@ -464,8 +464,8 @@ contract MuonNodeStaking is users[msg.sender].balance = 0; } - bondedToken.safeTransferFrom(address(this), msg.sender, tokenId); users[msg.sender].tokenId = 0; + bondedToken.safeTransferFrom(address(this), msg.sender, tokenId); emit Withdrawn(msg.sender, tokenId); } From ac0b9dbca3207107563c7917dd7dd34c22873204 Mon Sep 17 00:00:00 2001 From: Siftal Date: Sun, 23 Jul 2023 10:01:37 +0330 Subject: [PATCH 36/46] feat: add onERC721Received function --- contracts/MuonNodeStaking.sol | 32 +++++++++++++++++++++++++++----- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/contracts/MuonNodeStaking.sol b/contracts/MuonNodeStaking.sol index 4ebdbff..99bdadc 100644 --- a/contracts/MuonNodeStaking.sol +++ b/contracts/MuonNodeStaking.sol @@ -222,7 +222,9 @@ contract MuonNodeStaking is ); for (uint256 i = 0; i < tokens.length; i++) { - uint256 balance = IERC20Upgradeable(tokens[i]).balanceOf(address(this)); + uint256 balance = IERC20Upgradeable(tokens[i]).balanceOf( + address(this) + ); IERC20Upgradeable(tokens[i]).safeTransferFrom( msg.sender, @@ -238,7 +240,10 @@ contract MuonNodeStaking is "The discrepancy between the received amount and the claimed amount." ); - IERC20Upgradeable(tokens[i]).safeApprove(address(bondedToken), amounts[i]); + IERC20Upgradeable(tokens[i]).safeApprove( + address(bondedToken), + amounts[i] + ); } bondedToken.lock(tokenId, tokens, amounts); @@ -491,12 +496,15 @@ contract MuonNodeStaking is users[msg.sender].tokenId = tokenId; - bondedToken.transferFrom(msg.sender, address(this), tokenId); - require(bondedToken.ownerOf(tokenId) == address(this), "Not received the NFT."); + bondedToken.safeTransferFrom(msg.sender, address(this), tokenId); + require( + bondedToken.ownerOf(tokenId) == address(this), + "Not received the NFT." + ); nodeManager.addNode( nodeAddress, - msg.sender, // stakerAddress, + msg.sender, // stakerAddress peerId, true // active ); @@ -594,6 +602,20 @@ contract MuonNodeStaking is emit StakeUnlocked(stakerAddress); } + /** + * @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 val) public onlyRole(DAO_ROLE) { From 7e3bcc7596d3f833995446684e540ff5cab5a7ac Mon Sep 17 00:00:00 2001 From: Siftal Date: Sun, 23 Jul 2023 10:17:42 +0330 Subject: [PATCH 37/46] feat: check the node exists before setting the tier --- contracts/MuonNodeStaking.sol | 1 + 1 file changed, 1 insertion(+) diff --git a/contracts/MuonNodeStaking.sol b/contracts/MuonNodeStaking.sol index 99bdadc..5c0e89c 100644 --- a/contracts/MuonNodeStaking.sol +++ b/contracts/MuonNodeStaking.sol @@ -659,6 +659,7 @@ contract MuonNodeStaking is IMuonNodeManager.Node memory node = nodeManager.stakerAddressInfo( stakerAddress ); + require(node.id != 0, "Node not found for the staker address."); nodeManager.setTier(node.id, tier); _updateStaking(stakerAddress); From 11531bf1825c473f1955efab0ca9bc0b1127fb26 Mon Sep 17 00:00:00 2001 From: Siftal Date: Sun, 23 Jul 2023 10:27:29 +0330 Subject: [PATCH 38/46] feat: check the node exists before setting the tier --- contracts/MuonNodeManager.sol | 2 ++ contracts/MuonNodeStaking.sol | 2 -- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/contracts/MuonNodeManager.sol b/contracts/MuonNodeManager.sol index 4b31201..ebb96b9 100644 --- a/contracts/MuonNodeManager.sol +++ b/contracts/MuonNodeManager.sol @@ -356,6 +356,8 @@ contract MuonNodeManager is updateState updateNodeState(nodeId) { + require(nodes[nodeId].id == nodeId, "Node not found."); + require(nodes[nodeId].tier != tier, "Already set."); nodes[nodeId].tier = tier; diff --git a/contracts/MuonNodeStaking.sol b/contracts/MuonNodeStaking.sol index 5c0e89c..0299b01 100644 --- a/contracts/MuonNodeStaking.sol +++ b/contracts/MuonNodeStaking.sol @@ -659,8 +659,6 @@ contract MuonNodeStaking is IMuonNodeManager.Node memory node = nodeManager.stakerAddressInfo( stakerAddress ); - require(node.id != 0, "Node not found for the staker address."); - nodeManager.setTier(node.id, tier); _updateStaking(stakerAddress); } From b1361a8fbfb08b2eeb6cb3dc1db1e30e842925be Mon Sep 17 00:00:00 2001 From: Siftal Date: Sun, 23 Jul 2023 11:06:39 +0330 Subject: [PATCH 39/46] feat: update messages --- contracts/MuonNodeManager.sol | 25 +++++--------- contracts/MuonNodeStaking.sol | 61 ++++++++++++----------------------- 2 files changed, 28 insertions(+), 58 deletions(-) diff --git a/contracts/MuonNodeManager.sol b/contracts/MuonNodeManager.sol index ebb96b9..e717d23 100644 --- a/contracts/MuonNodeManager.sol +++ b/contracts/MuonNodeManager.sol @@ -100,14 +100,11 @@ contract MuonNodeManager is string calldata _peerId, bool _active ) public override onlyRole(ADMIN_ROLE) updateState { - require( - nodeAddressIds[_nodeAddress] == 0, - "Node address is already registered." - ); + require(nodeAddressIds[_nodeAddress] == 0, "Duplicate node address."); require( stakerAddressIds[_stakerAddress] == 0, - "Staker address is already registered." + "Duplicate staker address." ); lastNodeId++; @@ -144,9 +141,9 @@ contract MuonNodeManager is updateState updateNodeState(nodeId) { - require(nodes[nodeId].id == nodeId, "Node ID not found."); + require(nodes[nodeId].id == nodeId, "Node not found."); - require(nodes[nodeId].active, "Node is already deactivated."); + require(nodes[nodeId].active, "Already deactivated."); nodes[nodeId].endTime = block.timestamp; nodes[nodeId].active = false; @@ -168,10 +165,7 @@ contract MuonNodeManager is { require(roleId > 0 && roleId <= lastRoleId, "Invalid role ID."); - require( - nodesRoles[roleId][nodeId] == 0, - "Role is already assigned to this node." - ); + require(nodesRoles[roleId][nodeId] == 0, "Already set."); nodes[nodeId].roles.push(roleId); nodesRoles[roleId][nodeId] = uint16(nodes[nodeId].roles.length); @@ -192,10 +186,7 @@ contract MuonNodeManager is { require(roleId > 0 && roleId <= lastRoleId, "Invalid role ID."); - require( - nodesRoles[roleId][nodeId] > 0, - "Node does not have this role." - ); + require(nodesRoles[roleId][nodeId] > 0, "Already unset."); uint16 index = nodesRoles[roleId][nodeId] - 1; uint64 lRoleId = nodes[nodeId].roles[nodes[nodeId].roles.length - 1]; @@ -254,7 +245,7 @@ contract MuonNodeManager is ) public view returns (Node[] memory nodesList) { _from = _from > 0 ? _from : 1; _to = _to <= lastNodeId ? _to : lastNodeId; - require(_from <= _to, "Invalid range of node IDs."); + require(_from <= _to, "Invalid range."); nodesList = new Node[](_to - _from + 1); uint8 n = 0; @@ -384,7 +375,7 @@ contract MuonNodeManager is * @param role The role to be added. */ function addNodeRole(bytes32 role) public onlyRole(DAO_ROLE) { - require(roleIds[role] == 0, "This role has already been added."); + require(roleIds[role] == 0, "Already added."); lastRoleId++; roleIds[role] = lastRoleId; diff --git a/contracts/MuonNodeStaking.sol b/contracts/MuonNodeStaking.sol index 0299b01..363d1f3 100644 --- a/contracts/MuonNodeStaking.sol +++ b/contracts/MuonNodeStaking.sol @@ -164,10 +164,7 @@ contract MuonNodeStaking is address[] calldata tokens, uint256[] calldata multipliers ) external onlyRole(DAO_ROLE) { - require( - tokens.length == multipliers.length, - "Mismatch in the length of arrays." - ); + require(tokens.length == multipliers.length, "Arrays length mismatch."); for (uint256 i = 0; i < tokens.length; i++) { address token = tokens[i]; @@ -186,10 +183,7 @@ contract MuonNodeStaking is stakingTokensMultiplier[token] = multiplier; } else { - require( - multiplier > 0, - "Invalid multiplier. The multiplier value must be greater than 0." - ); + require(multiplier > 0, "Invalid multiplier."); stakingTokens.push(token); stakingTokensMultiplier[token] = multiplier; isStakingToken[token] = uint16(stakingTokens.length); @@ -209,13 +203,10 @@ contract MuonNodeStaking is address[] memory tokens, uint256[] memory amounts ) external whenFunctionNotPaused("lockToBondedToken") { - require( - tokens.length == amounts.length, - "Mismatch in the length of arrays." - ); + require(tokens.length == amounts.length, "Arrays length mismatch."); uint256 tokenId = users[msg.sender].tokenId; - require(tokenId != 0, "No staking found for the staker address."); + require(tokenId != 0, "No staking found."); require( bondedToken.ownerOf(tokenId) == address(this), "Staking contract is not the owner of the NFT." @@ -262,14 +253,14 @@ contract MuonNodeStaking is { require( bondedToken.ownerOf(tokenIdA) == msg.sender, - "The sender is not the owner of the NFT." + "Caller is not token owner." ); uint256 tokenIdB = users[msg.sender].tokenId; - require(tokenIdB != 0, "No staking found for the staker address."); + require(tokenIdB != 0, "No staking found."); require( bondedToken.ownerOf(tokenIdB) == address(this), - "Staking contract is not the owner of the NFT." + "Staking contract is not token owner." ); bondedToken.transferFrom(msg.sender, address(this), tokenIdA); @@ -321,16 +312,13 @@ contract MuonNodeStaking is IMuonNodeManager.Node memory node = nodeManager.stakerAddressInfo( stakerAddress ); - require( - node.id != 0 && node.active, - "No active node found for the staker address." - ); + require(node.id != 0 && node.active, "No active node found."); uint256 tokenId = users[stakerAddress].tokenId; - require(tokenId != 0, "No staking found for the staker address."); + require(tokenId != 0, "No staking found."); uint256 amount = valueOfBondedToken(tokenId); - require(amount >= minStakeAmount, "Insufficient amount to run a node."); + require(amount >= minStakeAmount, "Insufficient staking."); uint256 maxStakeAmount = tiersMaxStakeAmount[node.tier]; if (amount > maxStakeAmount) { @@ -357,18 +345,18 @@ contract MuonNodeStaking is bytes calldata reqId, SchnorrSign calldata signature ) public whenFunctionNotPaused("getReward") { - require(amount > 0, "Invalid withdrawal amount."); + require(amount > 0, "Invalid amount."); IMuonNodeManager.Node memory node = nodeManager.stakerAddressInfo( msg.sender ); - require(node.id != 0, "Node not found for the staker address."); + require(node.id != 0, "Node not found."); User memory user = users[msg.sender]; require( user.paidRewardPerToken <= paidRewardPerToken && paidRewardPerToken <= rewardPerToken(), - "Invalid paidRewardPerToken value." + "Invalid paidRewardPerToken." ); // Verify the authenticity of the withdrawal request. @@ -432,9 +420,6 @@ contract MuonNodeStaking is IMuonNodeManager.Node memory node = nodeManager.stakerAddressInfo( stakerAddress ); - require(node.id != 0, "Node not found for the staker address."); - - require(node.active, "The node is already deactivated."); totalStaked -= users[stakerAddress].balance; users[stakerAddress].balance = 0; @@ -448,7 +433,7 @@ contract MuonNodeStaking is IMuonNodeManager.Node memory node = nodeManager.stakerAddressInfo( msg.sender ); - require(node.id != 0, "Node not found for the staker address."); + require(node.id != 0, "Node not found."); require( !node.active && @@ -456,13 +441,10 @@ contract MuonNodeStaking is "The exit time has not been reached yet." ); - require( - !lockedStakes[msg.sender], - "Your stake is currently locked and cannot be withdrawn." - ); + require(!lockedStakes[msg.sender], "Stake is locked."); uint256 tokenId = users[msg.sender].tokenId; - require(tokenId != 0, "No staking found for the staker address."); + require(tokenId != 0, "No staking found."); if (users[msg.sender].balance > 0) { totalStaked -= users[msg.sender].balance; @@ -486,13 +468,10 @@ contract MuonNodeStaking is string calldata peerId, uint256 tokenId ) public whenFunctionNotPaused("addMuonNode") { - require( - users[msg.sender].tokenId == 0, - "You have already staked an NFT. Multiple staking is not allowed." - ); + require(users[msg.sender].tokenId == 0, "Already staked an NFT."); uint256 amount = valueOfBondedToken(tokenId); - require(amount >= minStakeAmount, "Insufficient amount to run a node."); + require(amount >= minStakeAmount, "Insufficient staking."); users[msg.sender].tokenId = tokenId; @@ -584,7 +563,7 @@ contract MuonNodeStaking is IMuonNodeManager.Node memory node = nodeManager.stakerAddressInfo( stakerAddress ); - require(node.id != 0, "Node not found for the staker address."); + require(node.id != 0, "Node not found."); lockedStakes[stakerAddress] = true; emit StakeLocked(stakerAddress); @@ -596,7 +575,7 @@ contract MuonNodeStaking is * @param stakerAddress The address of the staker. */ function unlockStake(address stakerAddress) public onlyRole(REWARD_ROLE) { - require(lockedStakes[stakerAddress], "The stake is not locked."); + require(lockedStakes[stakerAddress], "Already unlocked."); lockedStakes[stakerAddress] = false; emit StakeUnlocked(stakerAddress); From ea5925ab370c6c96ed5267fb98742b650f186f6d Mon Sep 17 00:00:00 2001 From: Siftal Date: Sun, 23 Jul 2023 13:38:16 +0330 Subject: [PATCH 40/46] style: clean the code --- contracts/MuonNodeManager.sol | 82 +++++++++++++++++------------------ contracts/MuonNodeStaking.sol | 36 ++++++++------- 2 files changed, 61 insertions(+), 57 deletions(-) diff --git a/contracts/MuonNodeManager.sol b/contracts/MuonNodeManager.sol index e717d23..d1b208a 100644 --- a/contracts/MuonNodeManager.sol +++ b/contracts/MuonNodeManager.sol @@ -33,7 +33,7 @@ contract MuonNodeManager is } EditLog[] public editLogs; - // commit_id => git commit id + // commit id => git commit id mapping(string => string) public configs; uint64 public lastRoleId; @@ -89,40 +89,40 @@ contract MuonNodeManager is /** * @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. + * @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 + address nodeAddress, + address stakerAddress, + string calldata peerId, + bool active ) public override onlyRole(ADMIN_ROLE) updateState { - require(nodeAddressIds[_nodeAddress] == 0, "Duplicate node address."); + require(nodeAddressIds[nodeAddress] == 0, "Duplicate node address."); require( - stakerAddressIds[_stakerAddress] == 0, + stakerAddressIds[stakerAddress] == 0, "Duplicate staker address." ); lastNodeId++; nodes[lastNodeId] = Node({ id: lastNodeId, - nodeAddress: _nodeAddress, - stakerAddress: _stakerAddress, - peerId: _peerId, + nodeAddress: nodeAddress, + stakerAddress: stakerAddress, + peerId: peerId, tier: 0, - active: _active, + active: active, roles: new uint64[](0), startTime: block.timestamp, lastEditTime: block.timestamp, endTime: 0 }); - nodeAddressIds[_nodeAddress] = lastNodeId; - stakerAddressIds[_stakerAddress] = lastNodeId; + nodeAddressIds[nodeAddress] = lastNodeId; + stakerAddressIds[stakerAddress] = lastNodeId; editLogs.push(EditLog(lastNodeId, block.timestamp)); @@ -233,26 +233,26 @@ contract MuonNodeManager is /** * @dev Returns a list of nodes that have been edited. - * @param _lastEditTime The time of the last edit. - * @param _from The starting node ID. - * @param _to The ending node ID. + * @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 _from, - uint64 _to + uint256 lastEditTime, + uint64 startId, + uint64 endId ) public view returns (Node[] memory nodesList) { - _from = _from > 0 ? _from : 1; - _to = _to <= lastNodeId ? _to : lastNodeId; - require(_from <= _to, "Invalid range."); + startId = startId > 0 ? startId : 1; + endId = endId <= lastNodeId ? endId : lastNodeId; + require(startId <= endId, "Invalid range."); - nodesList = new Node[](_to - _from + 1); + nodesList = new Node[](endId - startId + 1); uint8 n = 0; - for (uint64 i = _from; i <= _to; i++) { + for (uint64 i = startId; i <= endId; i++) { Node memory node = nodes[i]; - if (node.lastEditTime > _lastEditTime) { + if (node.lastEditTime > lastEditTime) { nodesList[n] = node; nodesList[n].roles = getNodeRoles(i); n++; @@ -267,25 +267,25 @@ contract MuonNodeManager is /** * @dev Returns a list of nodes that have been edited. - * @param _lastEditTime The time of the last edit. + * @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 lastEditTime, uint256 index, - uint16 _max + uint16 maxNodesToRetrieve ) public view returns (Node[] memory nodesList, uint256 lastIndex) { uint256 startIndex = index == 0 ? editLogs.length - 1 : index - 1; - nodesList = new Node[](_max); + 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) { + if (log.editTime <= lastEditTime) { break; } @@ -295,7 +295,7 @@ contract MuonNodeManager is nodesIndex++; } - if (nodesIndex == _max) { + if (nodesIndex == maxNodesToRetrieve) { lastIndex = i - 1; break; } @@ -310,29 +310,29 @@ contract MuonNodeManager is /** * @dev Returns the information of a node associated with the provided node address. - * @param _addr The node address. + * @param nodeAddress The node address. * @return node The node information. */ - function nodeAddressInfo(address _addr) + function nodeAddressInfo(address nodeAddress) public view returns (Node memory node) { - node = nodes[nodeAddressIds[_addr]]; + node = nodes[nodeAddressIds[nodeAddress]]; } /** * @dev Returns the information of a node associated with the provided staker address. - * @param _addr The staker address. + * @param stakerAddress The staker address. * @return node The node information. */ - function stakerAddressInfo(address _addr) + function stakerAddressInfo(address stakerAddress) public view override returns (Node memory node) { - node = nodes[stakerAddressIds[_addr]]; + node = nodes[stakerAddressIds[stakerAddress]]; } /** diff --git a/contracts/MuonNodeStaking.sol b/contracts/MuonNodeStaking.sol index 363d1f3..d88fa1d 100644 --- a/contracts/MuonNodeStaking.sol +++ b/contracts/MuonNodeStaking.sol @@ -88,14 +88,14 @@ contract MuonNodeStaking is * @dev Modifier that updates the reward parameters * before all of the functions that can change the rewards. * - * `_forAddress` should be address(0) when new rewards are distributing. + * `stakerAddress` should be address(0) when new rewards are distributing. */ - modifier updateReward(address _forAddress) { + modifier updateReward(address stakerAddress) { rewardPerTokenStored = rewardPerToken(); lastUpdateTime = lastTimeRewardApplicable(); - if (_forAddress != address(0)) { - users[_forAddress].pendingRewards = earned(_forAddress); - users[_forAddress].paidRewardPerToken = rewardPerTokenStored; + if (stakerAddress != address(0)) { + users[stakerAddress].pendingRewards = earned(stakerAddress); + users[stakerAddress].paidRewardPerToken = rewardPerTokenStored; } _; } @@ -535,11 +535,11 @@ contract MuonNodeStaking is /** * @dev Calculates the total rewards earned by a node. - * @param account The staker address of a node. + * @param stakerAddress The staker address of a node. * @return The total rewards earned by a node. */ - function earned(address account) public view returns (uint256) { - User memory user = users[account]; + function earned(address stakerAddress) public view returns (uint256) { + User memory user = users[stakerAddress]; return (user.balance * (rewardPerToken() - user.paidRewardPerToken)) / 1e18 + @@ -597,14 +597,20 @@ contract MuonNodeStaking is // ======== DAO functions ======== - function setExitPendingPeriod(uint256 val) public onlyRole(DAO_ROLE) { - exitPendingPeriod = val; - emit ExitPendingPeriodUpdated(val); + function setExitPendingPeriod(uint256 _exitPendingPeriod) + public + onlyRole(DAO_ROLE) + { + exitPendingPeriod = _exitPendingPeriod; + emit ExitPendingPeriodUpdated(_exitPendingPeriod); } - function setMinStakeAmount(uint256 val) public onlyRole(DAO_ROLE) { - minStakeAmount = val; - emit MinStakeAmountUpdated(val); + function setMinStakeAmount(uint256 _minStakeAmount) + public + onlyRole(DAO_ROLE) + { + minStakeAmount = _minStakeAmount; + emit MinStakeAmountUpdated(_minStakeAmount); } function setMuonAppId(uint256 _muonAppId) public onlyRole(DAO_ROLE) { @@ -647,7 +653,6 @@ contract MuonNodeStaking is onlyRole(DAO_ROLE) { functionPauseStates[functionName].isPaused = true; - emit Paused(functionName); } @@ -656,7 +661,6 @@ contract MuonNodeStaking is onlyRole(DAO_ROLE) { functionPauseStates[functionName].isPaused = false; - emit Unpaused(functionName); } From 74de8f49dd11f688a4fe752e0dbb2ad66a653925 Mon Sep 17 00:00:00 2001 From: Siftal Date: Sun, 23 Jul 2023 13:38:51 +0330 Subject: [PATCH 41/46] test: update tests --- test/muonNodeManager.ts | 12 ++++++++---- test/muonNodeStaking.ts | 10 +++++----- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/test/muonNodeManager.ts b/test/muonNodeManager.ts index affdd52..799d997 100644 --- a/test/muonNodeManager.ts +++ b/test/muonNodeManager.ts @@ -77,7 +77,7 @@ describe("MuonNodeManager", function () { nodeManager .connect(adminRole) .addNode(node1.address, staker2.address, peerId2, true) - ).to.be.revertedWith("Node address is already registered."); + ).to.be.revertedWith("Duplicate node address."); }); it("should not allow adding a node with a duplicate stakerAddress", async function () { @@ -89,7 +89,7 @@ describe("MuonNodeManager", function () { nodeManager .connect(adminRole) .addNode(node2.address, staker1.address, peerId2, true) - ).to.be.revertedWith("Staker address is already registered."); + ).to.be.revertedWith("Duplicate staker address."); }); }); @@ -113,13 +113,13 @@ describe("MuonNodeManager", function () { await nodeManager.connect(adminRole).deactiveNode(1); await expect( nodeManager.connect(adminRole).deactiveNode(1) - ).to.be.revertedWith("Node is already deactivated."); + ).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 ID not found."); + ).to.be.revertedWith("Node not found."); }); }); @@ -459,6 +459,10 @@ describe("MuonNodeManager", function () { 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); diff --git a/test/muonNodeStaking.ts b/test/muonNodeStaking.ts index f56619e..62899b8 100644 --- a/test/muonNodeStaking.ts +++ b/test/muonNodeStaking.ts @@ -256,7 +256,7 @@ describe("MuonNodeStaking", function () { nodeStaking .connect(staker3) .addMuonNode(node3.address, peerId3, tokenId) - ).to.be.revertedWith("Insufficient amount to run a node."); + ).to.be.revertedWith("Insufficient staking."); }); it("nodes are restricted from staking more than the MaxStakeAmount of their tier", async function () { @@ -609,7 +609,7 @@ describe("MuonNodeStaking", function () { // try to getReward by non-stakers await expect(getReward(user1, withdrawSig)).to.be.revertedWith( - "Node not found for the staker address." + "Node not found." ); }); @@ -1016,12 +1016,12 @@ describe("MuonNodeStaking", function () { // try to lock non exist staker await expect( nodeStaking.connect(rewardRole).lockStake(user1.address) - ).to.be.revertedWith("Node not found for the staker address."); + ).to.be.revertedWith("Node not found."); // try to unlock not locked staker await expect( nodeStaking.connect(rewardRole).unlockStake(staker1.address) - ).to.be.revertedWith("The stake is not locked."); + ).to.be.revertedWith("Already unlocked."); const earned1 = await nodeStaking.earned(staker1.address); @@ -1041,7 +1041,7 @@ describe("MuonNodeStaking", function () { // try to withdraw the stake await expect(nodeStaking.connect(staker1).withdraw()).to.be.revertedWith( - "Your stake is currently locked and cannot be withdrawn." + "Stake is locked." ); // unlock the stake From 5e59c8adb009396b0ec09b5371ed833a824e2d0c Mon Sep 17 00:00:00 2001 From: Siftal Date: Sun, 23 Jul 2023 14:10:47 +0330 Subject: [PATCH 42/46] feat: combine lockStake and unlockStake --- contracts/MuonNodeStaking.sol | 30 +++++++++--------------------- test/muonNodeStaking.ts | 8 ++++---- 2 files changed, 13 insertions(+), 25 deletions(-) diff --git a/contracts/MuonNodeStaking.sol b/contracts/MuonNodeStaking.sol index d88fa1d..0dd57f6 100644 --- a/contracts/MuonNodeStaking.sol +++ b/contracts/MuonNodeStaking.sol @@ -487,7 +487,6 @@ contract MuonNodeStaking is peerId, true // active ); - emit MuonNodeAdded(nodeAddress, msg.sender, peerId); } @@ -555,30 +554,20 @@ contract MuonNodeStaking is } /** - * @dev Locks the specified staker's stake. + * @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 lockStake(address stakerAddress) public onlyRole(REWARD_ROLE) { - IMuonNodeManager.Node memory node = nodeManager.stakerAddressInfo( - stakerAddress - ); + function setStakeLockStatus(address stakerAddress, bool lockStatus) external onlyRole(REWARD_ROLE) { + IMuonNodeManager.Node memory node = nodeManager.stakerAddressInfo(stakerAddress); require(node.id != 0, "Node not found."); - lockedStakes[stakerAddress] = true; - emit StakeLocked(stakerAddress); - } - - /** - * @dev Unlocks the specified staker's stake. - * Only callable by the REWARD_ROLE. - * @param stakerAddress The address of the staker. - */ - function unlockStake(address stakerAddress) public onlyRole(REWARD_ROLE) { - require(lockedStakes[stakerAddress], "Already unlocked."); + bool currentLockStatus = lockedStakes[stakerAddress]; + require(currentLockStatus != lockStatus, lockStatus ? "Already locked." : "Already unlocked."); - lockedStakes[stakerAddress] = false; - emit StakeUnlocked(stakerAddress); + lockedStakes[stakerAddress] = lockStatus; + emit StakeLockStatusChanged(stakerAddress, lockStatus); } /** @@ -683,8 +672,7 @@ contract MuonNodeStaking is event MinStakeAmountUpdated(uint256 minStakeAmount); event MuonAppIdUpdated(uint256 muonAppId); event MuonPublicKeyUpdated(PublicKey muonPublicKey); - event StakeLocked(address indexed stakerAddress); - event StakeUnlocked(address indexed stakerAddress); + event StakeLockStatusChanged(address indexed stakerAddress, bool locked); event StakingTokenUpdated(address indexed token, uint256 multiplier); event TierMaxStakeUpdated(uint8 tier, uint256 maxStakeAmount); event Paused(string functionName); diff --git a/test/muonNodeStaking.ts b/test/muonNodeStaking.ts index 62899b8..307e295 100644 --- a/test/muonNodeStaking.ts +++ b/test/muonNodeStaking.ts @@ -1015,12 +1015,12 @@ describe("MuonNodeStaking", function () { // try to lock non exist staker await expect( - nodeStaking.connect(rewardRole).lockStake(user1.address) + nodeStaking.connect(rewardRole).setStakeLockStatus(user1.address, true) ).to.be.revertedWith("Node not found."); // try to unlock not locked staker await expect( - nodeStaking.connect(rewardRole).unlockStake(staker1.address) + nodeStaking.connect(rewardRole).setStakeLockStatus(staker1.address, false) ).to.be.revertedWith("Already unlocked."); const earned1 = await nodeStaking.earned(staker1.address); @@ -1029,7 +1029,7 @@ describe("MuonNodeStaking", function () { await nodeStaking.connect(staker1).requestExit(); // lock the stake - await nodeStaking.connect(rewardRole).lockStake(staker1.address); + await nodeStaking.connect(rewardRole).setStakeLockStatus(staker1.address, true); // Increase time by 7 days await evmIncreaseTime(60 * 60 * 24 * 7); @@ -1045,7 +1045,7 @@ describe("MuonNodeStaking", function () { ); // unlock the stake - await nodeStaking.connect(rewardRole).unlockStake(staker1.address); + await nodeStaking.connect(rewardRole).setStakeLockStatus(staker1.address, false); expect(await bondedPion.ownerOf(1)).eq(nodeStaking.address); From 12c7c18f00c7a018b5238406a1ac78573d811965 Mon Sep 17 00:00:00 2001 From: Siftal Date: Sun, 23 Jul 2023 18:41:38 +0330 Subject: [PATCH 43/46] feat: use safeTransferFrom instead of transferFrom --- contracts/MuonNodeStaking.sol | 2 +- contracts/interfaces/IBondedToken.sol | 6 ------ 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/contracts/MuonNodeStaking.sol b/contracts/MuonNodeStaking.sol index 0dd57f6..5c59b59 100644 --- a/contracts/MuonNodeStaking.sol +++ b/contracts/MuonNodeStaking.sol @@ -263,7 +263,7 @@ contract MuonNodeStaking is "Staking contract is not token owner." ); - bondedToken.transferFrom(msg.sender, address(this), tokenIdA); + bondedToken.safeTransferFrom(msg.sender, address(this), tokenIdA); bondedToken.approve(address(bondedToken), tokenIdA); bondedToken.merge(tokenIdA, tokenIdB); diff --git a/contracts/interfaces/IBondedToken.sol b/contracts/interfaces/IBondedToken.sol index 3664a4d..ee6f681 100644 --- a/contracts/interfaces/IBondedToken.sol +++ b/contracts/interfaces/IBondedToken.sol @@ -21,12 +21,6 @@ interface IBondedToken { uint256 tokenId ) external; - function transferFrom( - address from, - address to, - uint256 tokenId - ) external; - function approve(address to, uint256 tokenId) external; function ownerOf(uint256 tokenId) external view returns (address owner); From 544b82915a8ab1d7cb974f4c2bada7e3304f3bc6 Mon Sep 17 00:00:00 2001 From: Siftal Date: Tue, 25 Jul 2023 10:58:39 +0330 Subject: [PATCH 44/46] feat: combine pauseFunction and unpauseFunction --- contracts/MuonNodeStaking.sol | 52 +++++++++++++++++------------------ test/muonNodeStaking.ts | 38 +++++++++++++++++-------- 2 files changed, 53 insertions(+), 37 deletions(-) diff --git a/contracts/MuonNodeStaking.sol b/contracts/MuonNodeStaking.sol index 5c59b59..75c0889 100644 --- a/contracts/MuonNodeStaking.sol +++ b/contracts/MuonNodeStaking.sol @@ -68,19 +68,14 @@ contract MuonNodeStaking is // tier => maxStakeAmount mapping(uint8 => uint256) public tiersMaxStakeAmount; - struct FunctionPauseState { - bool isPaused; - } - mapping(string => FunctionPauseState) public functionPauseStates; + // 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( - !functionPauseStates[functionName].isPaused, - "Function is paused." - ); + require(!functionPauseStatus[functionName], "Function is paused."); _; } @@ -559,12 +554,20 @@ contract MuonNodeStaking is * @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); + 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."); + require( + currentLockStatus != lockStatus, + lockStatus ? "Already locked." : "Already unlocked." + ); lockedStakes[stakerAddress] = lockStatus; emit StakeLockStatusChanged(stakerAddress, lockStatus); @@ -637,20 +640,18 @@ contract MuonNodeStaking is _updateStaking(stakerAddress); } - function pauseFunction(string memory functionName) - external - onlyRole(DAO_ROLE) - { - functionPauseStates[functionName].isPaused = true; - emit Paused(functionName); - } + function setFunctionPauseStatus( + string memory functionName, + bool pauseStatus + ) external onlyRole(DAO_ROLE) { + bool currentStatus = functionPauseStatus[functionName]; + require( + currentStatus != pauseStatus, + pauseStatus ? "Already paused." : "Already unpaused." + ); - function unpauseFunction(string memory functionName) - external - onlyRole(DAO_ROLE) - { - functionPauseStates[functionName].isPaused = false; - emit Unpaused(functionName); + functionPauseStatus[functionName] = pauseStatus; + emit FunctionPauseStatusChanged(functionName, pauseStatus); } // ======== Events ======== @@ -675,6 +676,5 @@ contract MuonNodeStaking is event StakeLockStatusChanged(address indexed stakerAddress, bool locked); event StakingTokenUpdated(address indexed token, uint256 multiplier); event TierMaxStakeUpdated(uint8 tier, uint256 maxStakeAmount); - event Paused(string functionName); - event Unpaused(string functionName); + event FunctionPauseStatusChanged(string functionName, bool isPaused); } diff --git a/test/muonNodeStaking.ts b/test/muonNodeStaking.ts index 307e295..f34af76 100644 --- a/test/muonNodeStaking.ts +++ b/test/muonNodeStaking.ts @@ -1020,7 +1020,9 @@ describe("MuonNodeStaking", function () { // try to unlock not locked staker await expect( - nodeStaking.connect(rewardRole).setStakeLockStatus(staker1.address, false) + nodeStaking + .connect(rewardRole) + .setStakeLockStatus(staker1.address, false) ).to.be.revertedWith("Already unlocked."); const earned1 = await nodeStaking.earned(staker1.address); @@ -1029,7 +1031,9 @@ describe("MuonNodeStaking", function () { await nodeStaking.connect(staker1).requestExit(); // lock the stake - await nodeStaking.connect(rewardRole).setStakeLockStatus(staker1.address, true); + await nodeStaking + .connect(rewardRole) + .setStakeLockStatus(staker1.address, true); // Increase time by 7 days await evmIncreaseTime(60 * 60 * 24 * 7); @@ -1045,7 +1049,9 @@ describe("MuonNodeStaking", function () { ); // unlock the stake - await nodeStaking.connect(rewardRole).setStakeLockStatus(staker1.address, false); + await nodeStaking + .connect(rewardRole) + .setStakeLockStatus(staker1.address, false); expect(await bondedPion.ownerOf(1)).eq(nodeStaking.address); @@ -1264,15 +1270,25 @@ describe("MuonNodeStaking", function () { it("DAO should have the ability to pause and unpause specific functions", async function () { const functionName = "addMuonNode"; - expect(await nodeStaking.functionPauseStates(functionName)).to.be.false; + 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).pauseFunction(functionName); - const pausedFunction = await tx + 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.functionName); - expect(pausedFunction).eq(functionName); + .then((receipt) => receipt.events[0].args); + expect(eventArgs.functionName).eq(functionName); + expect(eventArgs.isPaused).eq(true); - expect(await nodeStaking.functionPauseStates(functionName)).to.be.true; + expect(await nodeStaking.functionPauseStatus(functionName)).to.be.true; const tokenId = await mintBondedPion( ONE.mul(1000), @@ -1289,13 +1305,13 @@ describe("MuonNodeStaking", function () { const tx2 = await nodeStaking .connect(daoRole) - .unpauseFunction(functionName); + .setFunctionPauseStatus(functionName, false); const unpausedFunction = await tx2 .wait() .then((receipt) => receipt.events[0].args.functionName); expect(unpausedFunction).eq(functionName); - expect(await nodeStaking.functionPauseStates(functionName)).to.be.false; + expect(await nodeStaking.functionPauseStatus(functionName)).to.be.false; await nodeStaking .connect(staker3) From fce9c5e4fcefbed680ce26ea89c8c71cf7b309d1 Mon Sep 17 00:00:00 2001 From: Siftal Date: Mon, 14 Aug 2023 05:55:49 +0330 Subject: [PATCH 45/46] fix: fix a typo --- contracts/MuonNodeStaking.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contracts/MuonNodeStaking.sol b/contracts/MuonNodeStaking.sol index 75c0889..f6cdb0d 100644 --- a/contracts/MuonNodeStaking.sol +++ b/contracts/MuonNodeStaking.sol @@ -628,7 +628,7 @@ contract MuonNodeStaking is emit TierMaxStakeUpdated(tier, maxStakeAmount); } - function setMuonNodeTire(address stakerAddress, uint8 tier) + function setMuonNodeTier(address stakerAddress, uint8 tier) public onlyRole(DAO_ROLE) updateReward(stakerAddress) From 64e6044bd0b32ff06895987827b8bf8330b91774 Mon Sep 17 00:00:00 2001 From: Siftal Date: Mon, 14 Aug 2023 06:53:45 +0330 Subject: [PATCH 46/46] fix: fix a typo --- test/muonNodeStaking.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/test/muonNodeStaking.ts b/test/muonNodeStaking.ts index f34af76..28bb192 100644 --- a/test/muonNodeStaking.ts +++ b/test/muonNodeStaking.ts @@ -140,12 +140,12 @@ describe("MuonNodeStaking", function () { 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).setMuonNodeTire(staker1.address, tier1); + 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).setMuonNodeTire(staker2.address, tier2); + await nodeStaking.connect(daoRole).setMuonNodeTier(staker2.address, tier2); }); const getDummySig = async ( @@ -278,7 +278,7 @@ describe("MuonNodeStaking", function () { // admins can set tier await nodeStaking .connect(daoRole) - .setMuonNodeTire(staker3.address, tier1); + .setMuonNodeTier(staker3.address, tier1); expect((await nodeStaking.users(staker3.address)).balance) .eq(await nodeStaking.tiersMaxStakeAmount(1)) .eq(tier1MaxStake); @@ -450,7 +450,7 @@ describe("MuonNodeStaking", function () { await nodeStaking.connect(staker3).addMuonNode(node3.address, peerId3, 3); await nodeStaking .connect(daoRole) - .setMuonNodeTire(staker3.address, tier2); + .setMuonNodeTier(staker3.address, tier2); // Increase time by 10 days targetTimestamp = distributeTimestamp + 2 * tenDays;