diff --git a/contracts/staking/ServiceStakingBase.sol b/contracts/staking/ServiceStakingBase.sol index e4247cf5..36785f68 100644 --- a/contracts/staking/ServiceStakingBase.sol +++ b/contracts/staking/ServiceStakingBase.sol @@ -1,6 +1,7 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.21; +import {ERC721TokenReceiver} from "../../lib/solmate/src/tokens/ERC721.sol"; import "../interfaces/IErrorsRegistries.sol"; // Multisig interface @@ -12,11 +13,16 @@ interface IMultisig { // Service Registry interface interface IService { + enum UnitType { + Component, + Agent + } + /// @dev Transfers the service that was previously approved to this contract address. /// @param from Account address to transfer from. /// @param to Account address to transfer to. /// @param id Service Id. - function transferFrom(address from, address to, uint256 id) external; + function safeTransferFrom(address from, address to, uint256 id) external; /// @dev Gets service parameters from the map of services. /// @param serviceId Service Id. @@ -36,6 +42,14 @@ interface IService { uint32 numAgentInstances, uint8 state ); + + /// @dev Gets the full set of linearized components / canonical agent Ids for a specified service. + /// @notice The service must be / have been deployed in order to get the actual data. + /// @param serviceId Service Id. + /// @return numUnitIds Number of component / agent Ids. + /// @return unitIds Set of component / agent Ids. + function getUnitIdsOfService(UnitType unitType, uint256 serviceId) external view + returns (uint256 numUnitIds, uint32[] memory unitIds); } /// @dev No rewards are available in the contract. @@ -50,6 +64,10 @@ error MaxNumServicesReached(uint256 maxNumServices); /// @param expected Expected value. error LowerThan(uint256 provided, uint256 expected); +/// @dev Required service configuration is wrong. +/// @param serviceId Service Id. +error WrongServiceConfiguration(uint256 serviceId); + /// @dev Service is not staked. /// @param serviceId Service Id. error ServiceNotStaked(uint256 serviceId); @@ -72,7 +90,28 @@ struct ServiceInfo { /// @author Aleksandr Kuperman - /// @author Andrey Lebedev - /// @author Mariapia Moscatiello - -abstract contract ServiceStakingBase is IErrorsRegistries { +abstract contract ServiceStakingBase is ERC721TokenReceiver, IErrorsRegistries { + struct StakingParams { + // Maximum number of staking services + uint256 maxNumServices; + // Rewards per second + uint256 rewardsPerSecond; + // Minimum service staking deposit value required for staking + uint256 minStakingDeposit; + // Liveness period + uint256 livenessPeriod; + // Liveness ratio in the format of 1e18 + uint256 livenessRatio; + // Number of agent instances in the service + uint256 numAgentInstances; + // Optional agent Ids requirement + uint256[] agentIds; + // Optional service multisig threshold requirement + uint256 threshold; + // Optional service configuration hash requirement + bytes32 configHash; + } + event ServiceStaked(uint256 indexed serviceId, address indexed owner, address indexed multisig, uint256 nonce); event Checkpoint(uint256 availableRewards, uint256 numServices); event ServiceUnstaked(uint256 indexed serviceId, address indexed owner, address indexed multisig, uint256 nonce, @@ -80,14 +119,24 @@ abstract contract ServiceStakingBase is IErrorsRegistries { event Deposit(address indexed sender, uint256 amount, uint256 balance, uint256 availableRewards); event Withdraw(address indexed to, uint256 amount); + // Contract version + string public constant VERSION = "0.1.0"; // Maximum number of staking services uint256 public immutable maxNumServices; // Rewards per second uint256 public immutable rewardsPerSecond; // Minimum service staking deposit value required for staking uint256 public immutable minStakingDeposit; + // Liveness period + uint256 public immutable livenessPeriod; // Liveness ratio in the format of 1e18 uint256 public immutable livenessRatio; + // Number of agent instances in the service + uint256 public immutable numAgentInstances; + // Optional service multisig threshold requirement + uint256 public immutable threshold; + // Optional service configuration hash requirement + bytes32 public immutable configHash; // ServiceRegistry contract address address public immutable serviceRegistry; @@ -97,37 +146,56 @@ abstract contract ServiceStakingBase is IErrorsRegistries { uint256 public availableRewards; // Timestamp of the last checkpoint uint256 public tsCheckpoint; + // Optional agent Ids requirement + uint256[] public agentIds; // Mapping of serviceId => staking service info mapping (uint256 => ServiceInfo) public mapServiceInfo; // Set of currently staking serviceIds uint256[] public setServiceIds; /// @dev ServiceStakingBase constructor. - /// @param _maxNumServices Maximum number of staking services. - /// @param _rewardsPerSecond Staking rewards per second (in single digits). - /// @param _minStakingDeposit Minimum staking deposit for a service to be eligible to stake. - /// @param _livenessRatio Liveness ratio: number of nonces per second (in 18 digits). + /// @param _stakingParams Service staking parameters. /// @param _serviceRegistry ServiceRegistry contract address. - constructor( - uint256 _maxNumServices, - uint256 _rewardsPerSecond, - uint256 _minStakingDeposit, - uint256 _livenessRatio, - address _serviceRegistry) - { + constructor(StakingParams memory _stakingParams, address _serviceRegistry) { // Initial checks - if (_maxNumServices == 0 || _rewardsPerSecond == 0 || _minStakingDeposit == 0 || _livenessRatio == 0) { + if (_stakingParams.maxNumServices == 0 || _stakingParams.rewardsPerSecond == 0 || + _stakingParams.minStakingDeposit == 0 || _stakingParams.livenessPeriod == 0 || + _stakingParams.livenessRatio == 0 || _stakingParams.numAgentInstances == 0) { revert ZeroValue(); } if (_serviceRegistry == address(0)) { revert ZeroAddress(); } - maxNumServices = _maxNumServices; - rewardsPerSecond = _rewardsPerSecond; - minStakingDeposit = _minStakingDeposit; - livenessRatio = _livenessRatio; + // Assign all the required parameters + maxNumServices = _stakingParams.maxNumServices; + rewardsPerSecond = _stakingParams.rewardsPerSecond; + minStakingDeposit = _stakingParams.minStakingDeposit; + livenessPeriod = _stakingParams.livenessPeriod; + livenessRatio = _stakingParams.livenessRatio; + numAgentInstances = _stakingParams.numAgentInstances; serviceRegistry = _serviceRegistry; + + // Assign optional parameters + threshold = _stakingParams.threshold; + configHash = _stakingParams.configHash; + + // Assign agent Ids, if applicable + uint256 size = _stakingParams.agentIds.length; + uint256 agentId; + if (size > 0) { + for (uint256 i = 0; i < size; ++i) { + // Agent Ids must be unique and in ascending order + if (_stakingParams.agentIds[i] <= agentId) { + revert WrongAgentId(_stakingParams.agentIds[i]); + } + agentId = _stakingParams.agentIds[i]; + agentIds.push(agentId); + } + } + + // Set the checkpoint timestamp to be the deployment one + tsCheckpoint = block.timestamp; } /// @dev Checks token / ETH staking deposit. @@ -142,7 +210,7 @@ abstract contract ServiceStakingBase is IErrorsRegistries { /// @dev Withdraws the reward amount to a service owner. /// @param to Address to. /// @param amount Amount to withdraw. - function _withdraw(address to, uint256 amount) internal virtual {} + function _withdraw(address to, uint256 amount) internal virtual; /// @dev Stakes the service. /// @param serviceId Service Id. @@ -159,18 +227,49 @@ abstract contract ServiceStakingBase is IErrorsRegistries { } // Check the service conditions for staking - (uint96 stakingDeposit, address multisig, , , , , uint8 state) = IService(serviceRegistry).mapServices(serviceId); + (uint96 stakingDeposit, address multisig, bytes32 hash, uint256 agentThreshold, uint256 maxNumInstances, , uint8 state) = + IService(serviceRegistry).mapServices(serviceId); + + // Check the number of agent instances + if (numAgentInstances != maxNumInstances) { + revert WrongServiceConfiguration(serviceId); + } + + // Check the configuration hash, if applicable + if (configHash != bytes32(0) && configHash != hash) { + revert WrongServiceConfiguration(serviceId); + } + // Check the threshold, if applicable + if (threshold > 0 && threshold != agentThreshold) { + revert WrongServiceConfiguration(serviceId); + } // The service must be deployed if (state != 4) { revert WrongServiceState(state, serviceId); } + // Check the agent Ids requirement, if applicable + uint256 size = agentIds.length; + if (size > 0) { + (uint256 numAgents, uint32[] memory agents) = + IService(serviceRegistry).getUnitIdsOfService(IService.UnitType.Agent, serviceId); + + if (size != numAgents) { + revert WrongServiceConfiguration(serviceId); + } + for (uint256 i = 0; i < numAgents; ++i) { + if (agentIds[i] != agents[i]) { + revert WrongAgentId(agentIds[i]); + } + } + } + // Check service staking deposit and token, if applicable _checkTokenStakingDeposit(serviceId, stakingDeposit); // Transfer the service for staking - IService(serviceRegistry).transferFrom(msg.sender, address(this), serviceId); + IService(serviceRegistry).safeTransferFrom(msg.sender, address(this), serviceId); - // ServiceInfo struct will be an empty one since otherwise the transferFrom above would fail + // ServiceInfo struct will be an empty one since otherwise the safeTransferFrom above would fail ServiceInfo storage sInfo = mapServiceInfo[serviceId]; sInfo.multisig = multisig; sInfo.owner = msg.sender; @@ -202,60 +301,64 @@ abstract contract ServiceStakingBase is IErrorsRegistries { uint256[] memory serviceNonces ) { - // Get available rewards and last checkpoint timestamp - lastAvailableRewards = availableRewards; - uint256 tsCheckpointLast = tsCheckpoint; - // Get the service Ids set length uint256 size = setServiceIds.length; serviceIds = new uint256[](size); - serviceNonces = new uint256[](size); - // Record service Ids and nonces + // Record service Ids for (uint256 i = 0; i < size; ++i) { // Get current service Id serviceIds[i] = setServiceIds[i]; - - // Get current service multisig nonce - address multisig = mapServiceInfo[serviceIds[i]].multisig; - serviceNonces[i] = IMultisig(multisig).nonce(); } - // If available rewards are not zero, proceed with staking calculation - if (lastAvailableRewards > 0) { - // Get necessary arrays - eligibleServiceIds = new uint256[](size); - eligibleServiceRewards = new uint256[](size); + // Check the last checkpoint timestamp and the liveness period + uint256 tsCheckpointLast = tsCheckpoint; + if (block.timestamp - tsCheckpointLast >= livenessPeriod) { + // Get available rewards and last checkpoint timestamp + lastAvailableRewards = availableRewards; + + // If available rewards are not zero, proceed with staking calculation + if (lastAvailableRewards > 0) { + // Get necessary arrays + eligibleServiceIds = new uint256[](size); + eligibleServiceRewards = new uint256[](size); + serviceNonces = new uint256[](size); + + // Calculate each staked service reward eligibility + for (uint256 i = 0; i < size; ++i) { + // Get the service info + ServiceInfo storage curInfo = mapServiceInfo[serviceIds[i]]; + + // Get current service multisig nonce + serviceNonces[i] = IMultisig(curInfo.multisig).nonce(); + + // Calculate the liveness nonce ratio + // Get the last service checkpoint: staking start time or the global checkpoint timestamp + uint256 serviceCheckpoint = tsCheckpointLast; + uint256 ts = curInfo.tsStart; + // Adjust the service checkpoint time if the service was staking less than the current staking period + if (ts > serviceCheckpoint) { + serviceCheckpoint = ts; + } - // Calculate each staked service reward eligibility - for (uint256 i = 0; i < size; ++i) { - // Get the service info - uint256 curServiceId = serviceIds[i]; - ServiceInfo storage curInfo = mapServiceInfo[curServiceId]; - - // Calculate the liveness nonce ratio - // Get the last service checkpoint: staking start time or the global checkpoint timestamp - uint256 serviceCheckpoint = tsCheckpointLast; - // Adjust the service checkpoint time if the service was staking less than the current staking period - if (curInfo.tsStart > tsCheckpointLast) { - serviceCheckpoint = curInfo.tsStart; - } - // Calculate the liveness ratio in 1e18 value - uint256 ratio; - // If the checkpoint was called in the exactly same block, the ratio is zero - if (block.timestamp > serviceCheckpoint) { - uint256 nonce = serviceNonces[i]; - ratio = ((nonce - curInfo.nonce) * 1e18) / (block.timestamp - serviceCheckpoint); - } + // Calculate the liveness ratio in 1e18 value + // This subtraction is always positive or zero, as the last checkpoint can be at most block.timestamp + ts = block.timestamp - serviceCheckpoint; + uint256 ratio; + // If the checkpoint was called in the exact same block, the ratio is zero + if (ts > 0) { + ratio = ((serviceNonces[i] - curInfo.nonce) * 1e18) / ts; + } - // Record the reward for the service if it has provided enough transactions - if (ratio >= livenessRatio) { - // Calculate the reward up until now and record its value for the corresponding service - uint256 reward = rewardsPerSecond * (block.timestamp - serviceCheckpoint); - totalRewards += reward; - eligibleServiceRewards[numServices] = reward; - eligibleServiceIds[numServices] = curServiceId; - ++numServices; + // Record the reward for the service if it has provided enough transactions + if (ratio >= livenessRatio) { + // Calculate the reward up until now and record its value for the corresponding service + uint256 reward = rewardsPerSecond * ts; + totalRewards += reward; + eligibleServiceRewards[numServices] = reward; + eligibleServiceIds[numServices] = serviceIds[i]; + ++numServices; + } } } } @@ -263,35 +366,53 @@ abstract contract ServiceStakingBase is IErrorsRegistries { /// @dev Checkpoint to allocate rewards up until a current time. /// @return All staking service Ids. - /// @return Number of staking eligible services. + /// @return All staking updated nonces. + /// @return Number of reward-eligible staking services during current checkpoint period. /// @return Eligible service Ids. /// @return Eligible service rewards. - function checkpoint() public returns (uint256[] memory, uint256, uint256[] memory, uint256[] memory) { + /// @return success True, if the checkpoint was successful. + function checkpoint() public returns ( + uint256[] memory, + uint256[] memory, + uint256, + uint256[] memory, + uint256[] memory, + bool success + ) + { // Calculate staking rewards (uint256 lastAvailableRewards, uint256 numServices, uint256 totalRewards, uint256[] memory eligibleServiceIds, uint256[] memory eligibleServiceRewards, uint256[] memory serviceIds, uint256[] memory serviceNonces) = _calculateStakingRewards(); - // If available rewards are not zero, proceed with staking calculation - if (lastAvailableRewards > 0) { + // If there are eligible services, proceed with staking calculation and update rewards + if (numServices > 0) { // If total allocated rewards are not enough, adjust the reward value if (totalRewards > lastAvailableRewards) { // Traverse all the eligible services and adjust their rewards proportional to leftovers - uint256 updatedTotalRewards = 0; - for (uint256 i = 0; i < numServices; ++i) { + uint256 updatedReward; + uint256 updatedTotalRewards; + uint256 curServiceId; + for (uint256 i = 1; i < numServices; ++i) { // Calculate the updated reward - uint256 updatedReward = (eligibleServiceRewards[i] * lastAvailableRewards) / totalRewards; + updatedReward = (eligibleServiceRewards[i] * lastAvailableRewards) / totalRewards; // Add to the total updated reward updatedTotalRewards += updatedReward; - // Add reward to the service overall reward - uint256 curServiceId = eligibleServiceIds[i]; + // Add reward to the overall service reward + curServiceId = eligibleServiceIds[i]; mapServiceInfo[curServiceId].reward += updatedReward; } - // If the reward adjustment happened to have small leftovers, add it to the last traversed service + // Process the first service in the set + updatedReward = (eligibleServiceRewards[0] * lastAvailableRewards) / totalRewards; + updatedTotalRewards += updatedReward; + curServiceId = eligibleServiceIds[0]; + // If the reward adjustment happened to have small leftovers, add it to the first service if (lastAvailableRewards > updatedTotalRewards) { - mapServiceInfo[numServices - 1].reward += lastAvailableRewards - updatedTotalRewards; + updatedReward += lastAvailableRewards - updatedTotalRewards; } + // Add reward to the overall service reward + mapServiceInfo[curServiceId].reward += updatedReward; // Set available rewards to zero lastAvailableRewards = 0; } else { @@ -311,32 +432,37 @@ abstract contract ServiceStakingBase is IErrorsRegistries { availableRewards = lastAvailableRewards; } - // Updated current service nonces - for (uint256 i = 0; i < serviceIds.length; ++i) { - // Get the current service Id - uint256 curServiceId = serviceIds[i]; - mapServiceInfo[curServiceId].nonce = serviceNonces[i]; - } + // If service nonces were updated, then the checkpoint takes place, otherwise only service Ids are returned + if (serviceNonces.length > 0) { + // Updated current service nonces + for (uint256 i = 0; i < serviceIds.length; ++i) { + // Get the current service Id + uint256 curServiceId = serviceIds[i]; + mapServiceInfo[curServiceId].nonce = serviceNonces[i]; + } - // Record the current timestamp such that next calculations start from this point of time - tsCheckpoint = block.timestamp; + // Record the current timestamp such that next calculations start from this point of time + tsCheckpoint = block.timestamp; + + success = true; - emit Checkpoint(lastAvailableRewards, numServices); + emit Checkpoint(lastAvailableRewards, numServices); + } - return (serviceIds, numServices, eligibleServiceIds, eligibleServiceRewards); + return (serviceIds, serviceNonces, numServices, eligibleServiceIds, eligibleServiceRewards, success); } /// @dev Unstakes the service. /// @param serviceId Service Id. function unstake(uint256 serviceId) external { - ServiceInfo memory sInfo = mapServiceInfo[serviceId]; + ServiceInfo storage sInfo = mapServiceInfo[serviceId]; // Check for the service ownership if (msg.sender != sInfo.owner) { revert OwnerOnly(msg.sender, sInfo.owner); } // Call the checkpoint - (uint256[] memory serviceIds, , , ) = checkpoint(); + (uint256[] memory serviceIds, , , , , ) = checkpoint(); // Get the service index in the set of services // The index must always exist as the service is currently staked, otherwise it has no record in the map @@ -348,7 +474,7 @@ abstract contract ServiceStakingBase is IErrorsRegistries { } // Transfer the service back to the owner - IService(serviceRegistry).transferFrom(address(this), msg.sender, serviceId); + IService(serviceRegistry).safeTransferFrom(address(this), msg.sender, serviceId); // Transfer accumulated rewards to the service multisig if (sInfo.reward > 0) { @@ -380,11 +506,11 @@ abstract contract ServiceStakingBase is IErrorsRegistries { } // Calculate overall staking rewards - (uint256 lastAvailableRewards, , uint256 totalRewards, uint256[] memory eligibleServiceIds, + (uint256 lastAvailableRewards, uint256 numServices, uint256 totalRewards, uint256[] memory eligibleServiceIds, uint256[] memory eligibleServiceRewards, , ) = _calculateStakingRewards(); - // If available rewards are not zero, proceed with staking calculation - if (lastAvailableRewards > 0) { + // If there are eligible services, proceed with staking calculation and update rewards for the service Id + if (numServices > 0) { // Get the service index in the eligible service set and calculate its latest reward for (uint256 i = 0; i < eligibleServiceIds.length; ++i) { if (eligibleServiceIds[i] == serviceId) { diff --git a/contracts/staking/ServiceStaking.sol b/contracts/staking/ServiceStakingNativeToken.sol similarity index 61% rename from contracts/staking/ServiceStaking.sol rename to contracts/staking/ServiceStakingNativeToken.sol index 6c93fc23..22ed0b36 100644 --- a/contracts/staking/ServiceStaking.sol +++ b/contracts/staking/ServiceStakingNativeToken.sol @@ -3,25 +3,16 @@ pragma solidity ^0.8.21; import {ServiceStakingBase} from "./ServiceStakingBase.sol"; -/// @title ServiceStakingToken - Smart contract for staking a service by its owner when the service has an ETH as the deposit +/// @title ServiceStakingNativeToken - Smart contract for staking a service with the service having a native network token as the deposit /// @author Aleksandr Kuperman - /// @author Andrey Lebedev - /// @author Mariapia Moscatiello - -contract ServiceStaking is ServiceStakingBase { - /// @dev ServiceStaking constructor. - /// @param _maxNumServices Maximum number of staking services. - /// @param _rewardsPerSecond Staking rewards per second (in single digits). - /// @param _minStakingDeposit Minimum staking deposit for a service to be eligible to stake. - /// @param _livenessRatio Liveness ratio: number of nonces per second (in 18 digits). +contract ServiceStakingNativeToken is ServiceStakingBase { + /// @dev ServiceStakingNativeToken constructor. + /// @param _stakingParams Service staking parameters. /// @param _serviceRegistry ServiceRegistry contract address. - constructor( - uint256 _maxNumServices, - uint256 _rewardsPerSecond, - uint256 _minStakingDeposit, - uint256 _livenessRatio, - address _serviceRegistry - ) - ServiceStakingBase(_maxNumServices, _rewardsPerSecond, _minStakingDeposit, _livenessRatio, _serviceRegistry) + constructor(StakingParams memory _stakingParams, address _serviceRegistry) + ServiceStakingBase(_stakingParams, _serviceRegistry) {} /// @dev Withdraws the reward amount to a service owner. diff --git a/contracts/staking/ServiceStakingToken.sol b/contracts/staking/ServiceStakingToken.sol index 50c4a7cc..9f459b5a 100644 --- a/contracts/staking/ServiceStakingToken.sol +++ b/contracts/staking/ServiceStakingToken.sol @@ -40,23 +40,17 @@ contract ServiceStakingToken is ServiceStakingBase { address public immutable stakingToken; /// @dev ServiceStakingToken constructor. - /// @param _maxNumServices Maximum number of staking services. - /// @param _rewardsPerSecond Staking rewards per second (in single digits). - /// @param _minStakingDeposit Minimum staking deposit for a service to be eligible to stake. - /// @param _livenessRatio Liveness ratio: number of nonces per second (in 18 digits). + /// @param _stakingParams Service staking parameters. /// @param _serviceRegistry ServiceRegistry contract address. /// @param _serviceRegistryTokenUtility ServiceRegistryTokenUtility contract address. /// @param _stakingToken Address of a service staking token. constructor( - uint256 _maxNumServices, - uint256 _rewardsPerSecond, - uint256 _minStakingDeposit, - uint256 _livenessRatio, + StakingParams memory _stakingParams, address _serviceRegistry, address _serviceRegistryTokenUtility, address _stakingToken ) - ServiceStakingBase(_maxNumServices, _rewardsPerSecond, _minStakingDeposit, _livenessRatio, _serviceRegistry) + ServiceStakingBase(_stakingParams, _serviceRegistry) { // Initial checks if (_stakingToken == address(0) || _serviceRegistryTokenUtility == address(0)) { diff --git a/contracts/test/ReentrancyAttacker.sol b/contracts/test/ReentrancyAttacker.sol index 12769c1b..532d2cf7 100644 --- a/contracts/test/ReentrancyAttacker.sol +++ b/contracts/test/ReentrancyAttacker.sol @@ -69,7 +69,7 @@ struct AgentParams { } contract ReentrancyAttacker is ERC721TokenReceiver { - // Service Registry + // Component Registry address public immutable componentRegistry; // Service Registry address public immutable serviceRegistry; diff --git a/contracts/test/ReentrancyStakingAttacker.sol b/contracts/test/ReentrancyStakingAttacker.sol new file mode 100644 index 00000000..2cbbc942 --- /dev/null +++ b/contracts/test/ReentrancyStakingAttacker.sol @@ -0,0 +1,111 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.21; +import "../../lib/solmate/src/tokens/ERC721.sol"; + +// ServiceStaking interface +interface IServiceStaking { + /// @dev Stakes the service. + /// @param serviceId Service Id. + function stake(uint256 serviceId) external; + + /// @dev Unstakes the service. + /// @param serviceId Service Id. + function unstake(uint256 serviceId) external; + + /// @dev Checkpoint to allocate rewards up until a current time. + /// @return All staking service Ids. + /// @return All staking updated nonces. + /// @return Number of reward-eligible staking services during current checkpoint period. + /// @return Eligible service Ids. + /// @return Eligible service rewards. + /// @return success True, if the checkpoint was successful. + function checkpoint() external returns ( + uint256[] memory, + uint256[] memory, + uint256, + uint256[] memory, + uint256[] memory, + bool + ); +} + +// ServiceRegistry interface +interface IServiceRegistry { + /// @dev Approves a service for transfer. + /// @param spender Account address that will be able to transfer the token on behalf of the caller. + /// @param serviceId Service Id. + function approve(address spender, uint256 serviceId) external; +} + +contract ReentrancyStakingAttacker is ERC721TokenReceiver { + // Service Staking + address public immutable serviceStaking; + // Service Registry + address public immutable serviceRegistry; + // Attack argument + bool public attack = true; + // Nonce + uint256 internal _nonce; + // Owners + address[] public owners; + + constructor(address _serviceStaking, address _serviceRegistry) { + serviceStaking = _serviceStaking; + serviceRegistry = _serviceRegistry; + } + + /// @dev Failing receive. + receive() external payable { + revert(); + } + + function setAttack(bool status) external { + attack = status; + } + + function setOwner(address owner) external { + owners.push(owner); + } + + function inceraseNonce() external { + _nonce += 1000; + } + + /// @dev Malicious contract function call during the service token return. + function onERC721Received(address, address, uint256 serviceId, bytes memory) public virtual override returns (bytes4) { + if (attack) { + IServiceStaking(serviceStaking).unstake(serviceId); + } + return this.onERC721Received.selector; + } + + /// @dev Unstake the service. + function unstake(uint256 serviceId) external { + IServiceStaking(serviceStaking).unstake(serviceId); + } + + /// @dev Stake the service and call the checkpoint right away. + function stakeAndCheckpoint(uint256 serviceId) external { + IServiceRegistry(serviceRegistry).approve(serviceStaking, serviceId); + IServiceStaking(serviceStaking).stake(serviceId); + IServiceStaking(serviceStaking).checkpoint(); + } + + /// @dev Gets set of owners. + /// @return owners Set of Safe owners. + function getOwners() external view returns (address[] memory) { + return owners; + } + + /// @dev Gets threshold. + /// @return Threshold + function getThreshold() external view returns (uint256) { + return owners.length; + } + + /// @dev Gets the multisig nonce. + /// @return Multisig nonce. + function nonce() external view returns (uint256) { + return _nonce; + } +} \ No newline at end of file diff --git a/test/ServiceStaking.js b/test/ServiceStaking.js index 261807e5..e6b731e8 100644 --- a/test/ServiceStaking.js +++ b/test/ServiceStaking.js @@ -4,7 +4,7 @@ const { ethers } = require("hardhat"); const helpers = require("@nomicfoundation/hardhat-network-helpers"); const safeContracts = require("@gnosis.pm/safe-contracts"); -describe("ServiceStaking", function () { +describe("ServiceStakingNativeToken", function () { let componentRegistry; let agentRegistry; let serviceRegistry; @@ -13,28 +13,38 @@ describe("ServiceStaking", function () { let gnosisSafe; let gnosisSafeProxyFactory; let gnosisSafeMultisig; + let gnosisSafeSameAddressMultisig; let multiSend; let serviceStaking; let serviceStakingToken; - let reentrancyAttacker; + let attacker; let signers; let deployer; let operator; let agentInstances; - const maxNumServices = 10; - const rewardsPerSecond = "1" + "0".repeat(15); - const minStakingDeposit = 10; - const livenessRatio = "1" + "0".repeat(17); // 0.1 transaction per second (TPS) const AddressZero = ethers.constants.AddressZero; const defaultHash = "0x" + "5".repeat(64); + const bytes32Zero = "0x" + "0".repeat(64); const regDeposit = 1000; const regBond = 1000; const serviceId = 1; const agentIds = [1]; const agentParams = [[1, regBond]]; const threshold = 1; + const livenessPeriod = 10; // Ten seconds const initSupply = "5" + "0".repeat(26); const payload = "0x"; + const serviceParams = { + maxNumServices: 10, + rewardsPerSecond: "1" + "0".repeat(15), + minStakingDeposit: 10, + livenessPeriod: livenessPeriod, // Ten seconds + livenessRatio: "1" + "0".repeat(16), // 0.01 transaction per second (TPS) + numAgentInstances: 1, + agentIds: [], + threshold: 0, + configHash: bytes32Zero + }; beforeEach(async function () { signers = await ethers.getSigners(); @@ -74,23 +84,26 @@ describe("ServiceStaking", function () { gnosisSafeMultisig = await GnosisSafeMultisig.deploy(gnosisSafe.address, gnosisSafeProxyFactory.address); await gnosisSafeMultisig.deployed(); + const GnosisSafeSameAddressMultisig = await ethers.getContractFactory("GnosisSafeSameAddressMultisig"); + gnosisSafeSameAddressMultisig = await GnosisSafeSameAddressMultisig.deploy(); + await gnosisSafeSameAddressMultisig.deployed(); + const MultiSend = await ethers.getContractFactory("MultiSendCallOnly"); multiSend = await MultiSend.deploy(); await multiSend.deployed(); - const ServiceStaking = await ethers.getContractFactory("ServiceStaking"); - serviceStaking = await ServiceStaking.deploy(maxNumServices, rewardsPerSecond, minStakingDeposit, - livenessRatio, serviceRegistry.address); + const ServiceStakingNativeToken = await ethers.getContractFactory("ServiceStakingNativeToken"); + serviceStaking = await ServiceStakingNativeToken.deploy(serviceParams, serviceRegistry.address); await serviceStaking.deployed(); const ServiceStakingToken = await ethers.getContractFactory("ServiceStakingToken"); - serviceStakingToken = await ServiceStakingToken.deploy(maxNumServices, rewardsPerSecond, minStakingDeposit, - livenessRatio, serviceRegistry.address, serviceRegistryTokenUtility.address, token.address); - await serviceStaking.deployed(); + serviceStakingToken = await ServiceStakingToken.deploy(serviceParams, serviceRegistry.address, + serviceRegistryTokenUtility.address, token.address); + await serviceStakingToken.deployed(); - const ReentrancyAttacker = await ethers.getContractFactory("ReentrancyTokenAttacker"); - reentrancyAttacker = await ReentrancyAttacker.deploy(serviceRegistryTokenUtility.address); - await reentrancyAttacker.deployed(); + const Attacker = await ethers.getContractFactory("ReentrancyStakingAttacker"); + attacker = await Attacker.deploy(serviceStaking.address, serviceRegistry.address); + await attacker.deployed(); // Set the deployer to be the unit manager by default await componentRegistry.changeManager(deployer.address); @@ -103,9 +116,10 @@ describe("ServiceStaking", function () { await token.mint(deployer.address, initSupply); await token.mint(operator.address, initSupply); - // Create two services + // Create component, two agents and two services await componentRegistry.create(deployer.address, defaultHash, []); await agentRegistry.create(deployer.address, defaultHash, [1]); + await agentRegistry.create(deployer.address, defaultHash, [1]); await serviceRegistry.create(deployer.address, defaultHash, agentIds, agentParams, threshold); await serviceRegistry.create(deployer.address, defaultHash, agentIds, agentParams, threshold); @@ -114,12 +128,13 @@ describe("ServiceStaking", function () { await serviceRegistry.activateRegistration(deployer.address, serviceId + 1, {value: regDeposit}); // Register agent instances - agentInstances = [signers[2], signers[3], signers[4]]; + agentInstances = [signers[2], signers[3], signers[4], signers[5], signers[6], signers[7]]; await serviceRegistry.registerAgents(operator.address, serviceId, [agentInstances[0].address], agentIds, {value: regBond}); await serviceRegistry.registerAgents(operator.address, serviceId + 1, [agentInstances[1].address], agentIds, {value: regBond}); - // Whitelist gnosis multisig implementation + // Whitelist gnosis multisig implementations await serviceRegistry.changeMultisigPermission(gnosisSafeMultisig.address, true); + await serviceRegistry.changeMultisigPermission(gnosisSafeSameAddressMultisig.address, true); // Deploy services await serviceRegistry.deploy(deployer.address, serviceId, gnosisSafeMultisig.address, payload); @@ -128,26 +143,81 @@ describe("ServiceStaking", function () { context("Initialization", function () { it("Should not allow the zero values and addresses when deploying contracts", async function () { - const ServiceStaking = await ethers.getContractFactory("ServiceStaking"); + const ServiceStakingNativeToken = await ethers.getContractFactory("ServiceStakingNativeToken"); const ServiceStakingToken = await ethers.getContractFactory("ServiceStakingToken"); - await expect(ServiceStaking.deploy(0, 0, 0, 0, AddressZero)).to.be.revertedWithCustomError(ServiceStaking, "ZeroValue"); - await expect(ServiceStaking.deploy(maxNumServices, 0, 0, 0, AddressZero)).to.be.revertedWithCustomError(ServiceStaking, "ZeroValue"); - await expect(ServiceStaking.deploy(maxNumServices, rewardsPerSecond, 0, 0, AddressZero)).to.be.revertedWithCustomError(ServiceStaking, "ZeroValue"); - await expect(ServiceStaking.deploy(maxNumServices, rewardsPerSecond, minStakingDeposit, 0, AddressZero)).to.be.revertedWithCustomError(ServiceStaking, "ZeroValue"); - await expect(ServiceStaking.deploy(maxNumServices, rewardsPerSecond, minStakingDeposit, livenessRatio, AddressZero)).to.be.revertedWithCustomError(ServiceStaking, "ZeroAddress"); - - await expect(ServiceStakingToken.deploy(0, 0, 0, 0, AddressZero, AddressZero, AddressZero)).to.be.revertedWithCustomError(ServiceStakingToken, "ZeroValue"); - await expect(ServiceStakingToken.deploy(maxNumServices, 0, 0, 0, AddressZero, AddressZero, AddressZero)).to.be.revertedWithCustomError(ServiceStakingToken, "ZeroValue"); - await expect(ServiceStakingToken.deploy(maxNumServices, rewardsPerSecond, 0, 0, AddressZero, AddressZero, AddressZero)).to.be.revertedWithCustomError(ServiceStakingToken, "ZeroValue"); - await expect(ServiceStakingToken.deploy(maxNumServices, rewardsPerSecond, minStakingDeposit, 0, AddressZero, AddressZero, AddressZero)).to.be.revertedWithCustomError(ServiceStakingToken, "ZeroValue"); - await expect(ServiceStakingToken.deploy(maxNumServices, rewardsPerSecond, minStakingDeposit, livenessRatio, AddressZero, AddressZero, AddressZero)).to.be.revertedWithCustomError(ServiceStakingToken, "ZeroAddress"); - await expect(ServiceStakingToken.deploy(maxNumServices, rewardsPerSecond, minStakingDeposit, livenessRatio, serviceRegistry.address, AddressZero, AddressZero)).to.be.revertedWithCustomError(ServiceStakingToken, "ZeroAddress"); - await expect(ServiceStakingToken.deploy(maxNumServices, rewardsPerSecond, minStakingDeposit, livenessRatio, serviceRegistry.address, serviceRegistryTokenUtility.address, AddressZero)).to.be.revertedWithCustomError(ServiceStakingToken, "ZeroAddress"); + const defaultTestServiceParams = { + maxNumServices: 0, + rewardsPerSecond: 0, + minStakingDeposit: 0, + livenessPeriod: 0, + livenessRatio: 0, + numAgentInstances: 0, + agentIds: [], + threshold: 0, + configHash: bytes32Zero + }; + + // Service Staking Native Token + let testServiceParams = JSON.parse(JSON.stringify(defaultTestServiceParams)); + await expect(ServiceStakingNativeToken.deploy(testServiceParams, AddressZero)).to.be.revertedWithCustomError(ServiceStakingNativeToken, "ZeroValue"); + + testServiceParams.maxNumServices = 1; + await expect(ServiceStakingNativeToken.deploy(testServiceParams, AddressZero)).to.be.revertedWithCustomError(ServiceStakingNativeToken, "ZeroValue"); + + testServiceParams.rewardsPerSecond = 1; + await expect(ServiceStakingNativeToken.deploy(testServiceParams, AddressZero)).to.be.revertedWithCustomError(ServiceStakingNativeToken, "ZeroValue"); + + testServiceParams.minStakingDeposit = 1; + await expect(ServiceStakingNativeToken.deploy(testServiceParams, AddressZero)).to.be.revertedWithCustomError(ServiceStakingNativeToken, "ZeroValue"); + + testServiceParams.livenessPeriod = 1; + await expect(ServiceStakingNativeToken.deploy(testServiceParams, AddressZero)).to.be.revertedWithCustomError(ServiceStakingNativeToken, "ZeroValue"); + + testServiceParams.livenessRatio = 1; + await expect(ServiceStakingNativeToken.deploy(testServiceParams, AddressZero)).to.be.revertedWithCustomError(ServiceStakingNativeToken, "ZeroValue"); + + testServiceParams.numAgentInstances = 1; + await expect(ServiceStakingNativeToken.deploy(testServiceParams, AddressZero)).to.be.revertedWithCustomError(ServiceStakingNativeToken, "ZeroAddress"); + + testServiceParams.agentIds = [0]; + await expect(ServiceStakingNativeToken.deploy(testServiceParams, serviceRegistry.address)).to.be.revertedWithCustomError(ServiceStakingNativeToken, "WrongAgentId"); + + testServiceParams.agentIds = [1, 1]; + await expect(ServiceStakingNativeToken.deploy(testServiceParams, serviceRegistry.address)).to.be.revertedWithCustomError(ServiceStakingNativeToken, "WrongAgentId"); + + testServiceParams.agentIds = [2, 1]; + await expect(ServiceStakingNativeToken.deploy(testServiceParams, serviceRegistry.address)).to.be.revertedWithCustomError(ServiceStakingNativeToken, "WrongAgentId"); + + + // Service Staking Token + testServiceParams = JSON.parse(JSON.stringify(defaultTestServiceParams)); + await expect(ServiceStakingToken.deploy(testServiceParams, AddressZero, AddressZero, AddressZero)).to.be.revertedWithCustomError(ServiceStakingToken, "ZeroValue"); + + testServiceParams.maxNumServices = 1; + await expect(ServiceStakingToken.deploy(testServiceParams, AddressZero, AddressZero, AddressZero)).to.be.revertedWithCustomError(ServiceStakingToken, "ZeroValue"); + + testServiceParams.rewardsPerSecond = 1; + await expect(ServiceStakingToken.deploy(testServiceParams, AddressZero, AddressZero, AddressZero)).to.be.revertedWithCustomError(ServiceStakingToken, "ZeroValue"); + + testServiceParams.minStakingDeposit = 1; + await expect(ServiceStakingToken.deploy(testServiceParams, AddressZero, AddressZero, AddressZero)).to.be.revertedWithCustomError(ServiceStakingToken, "ZeroValue"); + + testServiceParams.livenessPeriod = 1; + await expect(ServiceStakingToken.deploy(testServiceParams, AddressZero, AddressZero, AddressZero)).to.be.revertedWithCustomError(ServiceStakingToken, "ZeroValue"); + + testServiceParams.livenessRatio = 1; + await expect(ServiceStakingToken.deploy(testServiceParams, AddressZero, AddressZero, AddressZero)).to.be.revertedWithCustomError(ServiceStakingToken, "ZeroValue"); + + testServiceParams.numAgentInstances = 1; + await expect(ServiceStakingToken.deploy(testServiceParams, AddressZero, AddressZero, AddressZero)).to.be.revertedWithCustomError(ServiceStakingToken, "ZeroAddress"); + + await expect(ServiceStakingToken.deploy(testServiceParams, serviceRegistry.address, AddressZero, AddressZero)).to.be.revertedWithCustomError(ServiceStakingToken, "ZeroAddress"); + await expect(ServiceStakingToken.deploy(testServiceParams, serviceRegistry.address, serviceRegistryTokenUtility.address, AddressZero)).to.be.revertedWithCustomError(ServiceStakingToken, "ZeroAddress"); }); }); - context("Staking to ServiceStaking and ServiceStakingToken", function () { + context("Staking to ServiceStakingNativeToken and ServiceStakingToken", function () { it("Should fail if there are no available rewards", async function () { await expect( serviceStaking.stake(serviceId) @@ -156,9 +226,10 @@ describe("ServiceStaking", function () { it("Should fail if the maximum number of staking services is reached", async function () { // Deploy a contract with max number of services equal to one - const ServiceStaking = await ethers.getContractFactory("ServiceStaking"); - const sStaking = await ServiceStaking.deploy(1, rewardsPerSecond, minStakingDeposit, livenessRatio, - serviceRegistry.address); + const ServiceStakingNativeToken = await ethers.getContractFactory("ServiceStakingNativeToken"); + const testServiceParams = JSON.parse(JSON.stringify(serviceParams)); + testServiceParams.maxNumServices = 1; + const sStaking = await ServiceStakingNativeToken.deploy(testServiceParams, serviceRegistry.address); await sStaking.deployed(); // Deposit to the contract @@ -192,6 +263,122 @@ describe("ServiceStaking", function () { ).to.be.revertedWithCustomError(serviceStaking, "WrongServiceState"); }); + it("Should fail when the maximum number of instances is incorrect", async function () { + // Deposit to the contract + await deployer.sendTransaction({to: serviceStaking.address, value: ethers.utils.parseEther("1")}); + + // Create a new service (serviceId == 3) + await serviceRegistry.create(deployer.address, defaultHash, [1, 2], [agentParams[0], agentParams[0]], 2); + + // Approve services + await serviceRegistry.approve(serviceStaking.address, serviceId + 2); + + await expect( + serviceStaking.stake(serviceId + 2) + ).to.be.revertedWithCustomError(serviceStaking, "WrongServiceConfiguration"); + }); + + it("Should fail when the specified config of the service does not match", async function () { + // Deploy a contract with a different service config specification + const ServiceStakingNativeToken = await ethers.getContractFactory("ServiceStakingNativeToken"); + const testServiceParams = JSON.parse(JSON.stringify(serviceParams)); + testServiceParams.configHash = "0x" + "1".repeat(64); + const sStaking = await ServiceStakingNativeToken.deploy(testServiceParams, serviceRegistry.address); + await sStaking.deployed(); + + // Deposit to the contract + await deployer.sendTransaction({to: sStaking.address, value: ethers.utils.parseEther("1")}); + + // Approve services + await serviceRegistry.approve(sStaking.address, serviceId); + + await expect( + sStaking.stake(serviceId) + ).to.be.revertedWithCustomError(sStaking, "WrongServiceConfiguration"); + }); + + it("Should fail when the specified threshold of the service does not match", async function () { + // Deploy a contract with a different service config specification + const ServiceStakingNativeToken = await ethers.getContractFactory("ServiceStakingNativeToken"); + const testServiceParams = JSON.parse(JSON.stringify(serviceParams)); + testServiceParams.threshold = 2; + const sStaking = await ServiceStakingNativeToken.deploy(testServiceParams, serviceRegistry.address); + await sStaking.deployed(); + + // Deposit to the contract + await deployer.sendTransaction({to: sStaking.address, value: ethers.utils.parseEther("1")}); + + // Approve services + await serviceRegistry.approve(sStaking.address, serviceId); + + await expect( + sStaking.stake(serviceId) + ).to.be.revertedWithCustomError(serviceStaking, "WrongServiceConfiguration"); + }); + + it("Should fail when the optional agent Ids do not match in the service", async function () { + // Deploy a service staking contract with specific agent Ids + const ServiceStakingNativeToken = await ethers.getContractFactory("ServiceStakingNativeToken"); + let testServiceParams = JSON.parse(JSON.stringify(serviceParams)); + testServiceParams.agentIds = [1]; + const sStaking = await ServiceStakingNativeToken.deploy(testServiceParams, serviceRegistry.address); + await sStaking.deployed(); + + // Deposit to the contract + await deployer.sendTransaction({to: sStaking.address, value: ethers.utils.parseEther("1")}); + + // Create a new service (serviceId == 3) + await serviceRegistry.create(deployer.address, defaultHash, [2], agentParams, threshold); + await serviceRegistry.activateRegistration(deployer.address, serviceId + 2, {value: regDeposit}); + await serviceRegistry.registerAgents(operator.address, serviceId + 2, [agentInstances[2].address], [2], {value: regBond}); + await serviceRegistry.deploy(deployer.address, serviceId + 2, gnosisSafeMultisig.address, payload); + + // Approve services + await serviceRegistry.approve(sStaking.address, serviceId + 2); + + await expect( + sStaking.stake(serviceId + 2) + ).to.be.revertedWithCustomError(sStaking, "WrongAgentId"); + + // Create a new service (serviceId == 4) + await serviceRegistry.create(deployer.address, defaultHash, [1], agentParams, threshold); + await serviceRegistry.activateRegistration(deployer.address, serviceId + 3, {value: regDeposit}); + await serviceRegistry.registerAgents(operator.address, serviceId + 3, [agentInstances[3].address], agentIds, {value: regBond}); + await serviceRegistry.deploy(deployer.address, serviceId + 3, gnosisSafeMultisig.address, payload); + + // Approve services + await serviceRegistry.approve(sStaking.address, serviceId + 3); + + // Stake the service + await sStaking.stake(serviceId + 3); + }); + + it("Should fail when the numer of agent instances matching the wrong agent Ids size", async function () { + // Deploy a service staking contract with a numer of agent instances matching the wrong agent Ids size + const ServiceStakingNativeToken = await ethers.getContractFactory("ServiceStakingNativeToken"); + let testServiceParams = JSON.parse(JSON.stringify(serviceParams)); + testServiceParams.agentIds = [1]; + testServiceParams.numAgentInstances = 2; + const sStaking = await ServiceStakingNativeToken.deploy(testServiceParams, serviceRegistry.address); + await sStaking.deployed(); + + // Deposit to the contract + await deployer.sendTransaction({to: sStaking.address, value: ethers.utils.parseEther("1")}); + + // Create a new service (serviceId == 3) + await serviceRegistry.create(deployer.address, defaultHash, [1, 2], [agentParams[0], agentParams[0]], 2); + await serviceRegistry.activateRegistration(deployer.address, serviceId + 2, {value: regDeposit}); + await serviceRegistry.registerAgents(operator.address, serviceId + 2, [agentInstances[4].address, agentInstances[5].address], [1, 2], {value: 2 * regBond}); + await serviceRegistry.deploy(deployer.address, serviceId + 2, gnosisSafeMultisig.address, payload); + + // Approve services + await serviceRegistry.approve(sStaking.address, serviceId + 2); + + await expect( + sStaking.stake(serviceId + 2) + ).to.be.revertedWithCustomError(sStaking, "WrongServiceConfiguration"); + }); + it("Should fail when the service has insufficient security deposit", async function () { // Deposit to the contract await deployer.sendTransaction({to: serviceStaking.address, value: ethers.utils.parseEther("1")}); @@ -279,7 +466,7 @@ describe("ServiceStaking", function () { ).to.be.revertedWithCustomError(serviceStakingToken, "LowerThan"); }); - it("Stake a service at ServiceStaking and try to unstake not by the service owner", async function () { + it("Stake a service at ServiceStakingNativeToken and try to unstake not by the service owner", async function () { // Deposit to the contract await deployer.sendTransaction({to: serviceStaking.address, value: ethers.utils.parseEther("1")}); @@ -348,7 +535,7 @@ describe("ServiceStaking", function () { const multisig = await ethers.getContractAt("GnosisSafe", service.multisig); // Increase the time while the service does not reach the required amount of transactions per second (TPS) - await helpers.time.increase(1000); + await helpers.time.increase(livenessPeriod); // Calculate service staking reward that must be zero const reward = await serviceStaking.calculateServiceStakingReward(serviceId); @@ -389,6 +576,9 @@ describe("ServiceStaking", function () { let signMessageData = await safeContracts.safeSignMessage(agentInstances[0], multisig, txHashData, 0); await safeContracts.executeTx(multisig, txHashData, [signMessageData], 0); + // Increase the time for the liveness period + await helpers.time.increase(livenessPeriod); + // Call the checkpoint at this time await serviceStaking.checkpoint(); @@ -398,6 +588,9 @@ describe("ServiceStaking", function () { signMessageData = await safeContracts.safeSignMessage(agentInstances[0], multisig, txHashData, 0); await safeContracts.executeTx(multisig, txHashData, [signMessageData], 0); + // Increase the time for the liveness period + await helpers.time.increase(livenessPeriod); + // Calculate service staking reward that must be greater than zero const reward = await serviceStaking.calculateServiceStakingReward(serviceId); expect(reward).to.greaterThan(0); @@ -438,7 +631,6 @@ describe("ServiceStaking", function () { // Deploy the service await serviceRegistry.deploy(deployer.address, sId, gnosisSafeMultisig.address, payload); - // Approve services await serviceRegistry.approve(serviceStakingToken.address, sId); @@ -455,6 +647,9 @@ describe("ServiceStaking", function () { let signMessageData = await safeContracts.safeSignMessage(agentInstances[2], multisig, txHashData, 0); await safeContracts.executeTx(multisig, txHashData, [signMessageData], 0); + // Increase the time for the liveness period + await helpers.time.increase(livenessPeriod); + // Call the checkpoint at this time await serviceStakingToken.checkpoint(); @@ -464,6 +659,9 @@ describe("ServiceStaking", function () { signMessageData = await safeContracts.safeSignMessage(agentInstances[2], multisig, txHashData, 0); await safeContracts.executeTx(multisig, txHashData, [signMessageData], 0); + // Increase the time for the liveness period + await helpers.time.increase(livenessPeriod); + // Calculate service staking reward that must be greater than zero const reward = await serviceStakingToken.calculateServiceStakingReward(sId); expect(reward).to.greaterThan(0); @@ -497,6 +695,9 @@ describe("ServiceStaking", function () { const service = await serviceRegistry.getService(serviceId); const multisig = await ethers.getContractAt("GnosisSafe", service.multisig); + // Increase the time for the liveness period + await helpers.time.increase(livenessPeriod); + // Construct the payload for the multisig let callData = []; let txs = []; @@ -532,7 +733,7 @@ describe("ServiceStaking", function () { const snapshot = await helpers.takeSnapshot(); // Deposit to the contract - await deployer.sendTransaction({to: serviceStaking.address, value: rewardsPerSecond}); + await deployer.sendTransaction({to: serviceStaking.address, value: serviceParams.rewardsPerSecond}); // Approve services await serviceRegistry.approve(serviceStaking.address, serviceId); @@ -550,6 +751,9 @@ describe("ServiceStaking", function () { let signMessageData = await safeContracts.safeSignMessage(agentInstances[0], multisig, txHashData, 0); await safeContracts.executeTx(multisig, txHashData, [signMessageData], 0); + // Increase the time for the liveness period + await helpers.time.increase(livenessPeriod); + // Call the checkpoint at this time await serviceStaking.checkpoint(); @@ -559,6 +763,9 @@ describe("ServiceStaking", function () { signMessageData = await safeContracts.safeSignMessage(agentInstances[0], multisig, txHashData, 0); await safeContracts.executeTx(multisig, txHashData, [signMessageData], 0); + // Increase the time for the liveness period + await helpers.time.increase(livenessPeriod); + // Calculate service staking reward that must be greater than zero const reward = await serviceStaking.calculateServiceStakingReward(serviceId); expect(reward).to.greaterThan(0); @@ -574,5 +781,253 @@ describe("ServiceStaking", function () { // Restore a previous state of blockchain snapshot.restore(); }); + + it("Stake and unstake to drain the full balance by several services", async function () { + // Take a snapshot of the current state of the blockchain + const snapshot = await helpers.takeSnapshot(); + + // Deposit to the contract + await deployer.sendTransaction({to: serviceStaking.address, value: serviceParams.rewardsPerSecond}); + + // Create and deploy one more service (serviceId == 3) + await serviceRegistry.create(deployer.address, defaultHash, agentIds, agentParams, threshold); + await serviceRegistry.activateRegistration(deployer.address, serviceId + 2, {value: regDeposit}); + await serviceRegistry.registerAgents(operator.address, serviceId + 2, [agentInstances[2].address], agentIds, {value: regBond}); + await serviceRegistry.deploy(deployer.address, serviceId + 2, gnosisSafeMultisig.address, payload); + + for (let i = 0; i < 3; i++) { + // Approve services + await serviceRegistry.approve(serviceStaking.address, serviceId + i); + + // Stake the first service + await serviceStaking.stake(serviceId + i); + + // Get the service multisig contract + const service = await serviceRegistry.getService(serviceId + i); + const multisig = await ethers.getContractAt("GnosisSafe", service.multisig); + + // Make transactions by the service multisig, except for the service Id == 3 + if (i < 2) { + const nonce = await multisig.nonce(); + const txHashData = await safeContracts.buildContractCall(multisig, "getThreshold", [], nonce, 0, 0); + const signMessageData = await safeContracts.safeSignMessage(agentInstances[i], multisig, txHashData, 0); + await safeContracts.executeTx(multisig, txHashData, [signMessageData], 0); + } + } + + // Increase the time for the liveness period + await helpers.time.increase(livenessPeriod); + + // Calculate service staking reward that must be greater than zero except for the serviceId == 3 + for (let i = 0; i < 2; i++) { + const reward = await serviceStaking.calculateServiceStakingReward(serviceId + i); + expect(reward).to.greaterThan(0); + } + + // Call the checkpoint at this time + await serviceStaking.checkpoint(); + + // Execute one more multisig tx for services except for the service Id == 3 + for (let i = 0; i < 2; i++) { + // Get the service multisig contract + const service = await serviceRegistry.getService(serviceId + i); + const multisig = await ethers.getContractAt("GnosisSafe", service.multisig); + + const nonce = await multisig.nonce(); + const txHashData = await safeContracts.buildContractCall(multisig, "getThreshold", [], nonce, 0, 0); + const signMessageData = await safeContracts.safeSignMessage(agentInstances[i], multisig, txHashData, 0); + await safeContracts.executeTx(multisig, txHashData, [signMessageData], 0); + } + + + // Increase the time for the liveness period + await helpers.time.increase(livenessPeriod); + + for (let i = 0; i < 3; i++) { + // Calculate service staking reward that must be greater than zero except for the serviceId == 3 + const reward = await serviceStaking.calculateServiceStakingReward(serviceId + i); + if (i < 2) { + expect(reward).to.greaterThan(0); + } else { + expect(reward).to.equal(0); + } + + // Get the service multisig contract + const service = await serviceRegistry.getService(serviceId + i); + const multisig = await ethers.getContractAt("GnosisSafe", service.multisig); + + // Unstake services + const balanceBefore = ethers.BigNumber.from(await ethers.provider.getBalance(multisig.address)); + await serviceStaking.unstake(serviceId + i); + const balanceAfter = ethers.BigNumber.from(await ethers.provider.getBalance(multisig.address)); + + // The balance before and after the unstake call must be different except for the serviceId == 3 + if (i < 2) { + expect(balanceAfter).to.gt(balanceBefore); + } else { + expect(reward).to.equal(0); + } + } + + // Restore a previous state of blockchain + snapshot.restore(); + }); + + it("Stake and unstake with the service activity", async function () { + // Take a snapshot of the current state of the blockchain + const snapshot = await helpers.takeSnapshot(); + + // Deposit to the contract + await deployer.sendTransaction({to: serviceStaking.address, value: ethers.utils.parseEther("1")}); + + // Approve services + await serviceRegistry.approve(serviceStaking.address, serviceId); + + // Stake the first service + await serviceStaking.stake(serviceId); + + // Take the staking timestamp + let block = await ethers.provider.getBlock("latest"); + const tsStart = block.timestamp; + + // Get the service multisig contract + const service = await serviceRegistry.getService(serviceId); + const multisig = await ethers.getContractAt("GnosisSafe", service.multisig); + + // Make transactions by the service multisig + let nonce = await multisig.nonce(); + let txHashData = await safeContracts.buildContractCall(multisig, "getThreshold", [], nonce, 0, 0); + let signMessageData = await safeContracts.safeSignMessage(agentInstances[0], multisig, txHashData, 0); + await safeContracts.executeTx(multisig, txHashData, [signMessageData], 0); + + // Increase the time for the liveness period + await helpers.time.increase(livenessPeriod); + + // Call the checkpoint at this time + await serviceStaking.checkpoint(); + + // Execute one more multisig tx + nonce = await multisig.nonce(); + txHashData = await safeContracts.buildContractCall(multisig, "getThreshold", [], nonce, 0, 0); + signMessageData = await safeContracts.safeSignMessage(agentInstances[0], multisig, txHashData, 0); + await safeContracts.executeTx(multisig, txHashData, [signMessageData], 0); + + // Increase the time for the liveness period + await helpers.time.increase(livenessPeriod); + + block = await ethers.provider.getBlock("latest"); + const tsEnd = block.timestamp; + + // Get the expected reward + const tsDiff = tsEnd - tsStart; + const expectedReward = serviceParams.rewardsPerSecond * tsDiff; + + // Nonce is just 1 as there was 1 transaction + const ratio = (10**18 * 1.0) / tsDiff; + expect(ratio).to.greaterThan(Number(serviceParams.livenessRatio)); + + // Calculate service staking reward that must match the calculated reward + const reward = await serviceStaking.calculateServiceStakingReward(serviceId); + expect(Number(reward)).to.equal(expectedReward); + + // Unstake the service + const balanceBefore = ethers.BigNumber.from(await ethers.provider.getBalance(multisig.address)); + await serviceStaking.unstake(serviceId); + const balanceAfter = ethers.BigNumber.from(await ethers.provider.getBalance(multisig.address)); + + // The balance before and after the unstake call must be different + expect(balanceAfter).to.gt(balanceBefore); + + // Restore a previous state of blockchain + snapshot.restore(); + }); + }); + + context("Reentrancy and failures", function () { + it("Stake and checkpoint in the same tx", async function () { + // Take a snapshot of the current state of the blockchain + const snapshot = await helpers.takeSnapshot(); + + // Deposit to the contract + await deployer.sendTransaction({to: serviceStaking.address, value: ethers.utils.parseEther("1")}); + + // Get the service multisig contract + const service = await serviceRegistry.getService(serviceId); + const multisig = await ethers.getContractAt("GnosisSafe", service.multisig); + + // Transfer the service to the attacker (note we need to use the transfer not to get another reentrancy call) + await serviceRegistry.transferFrom(deployer.address, attacker.address, serviceId); + + // Increase the time for the liveness period + await helpers.time.increase(livenessPeriod); + + // Stake and checkpoint + await attacker.stakeAndCheckpoint(serviceId); + + // Make sure the service have not earned any rewards + const reward = await serviceStaking.calculateServiceStakingReward(serviceId); + expect(reward).to.equal(0); + + // Try to unstake the service with the re-entrancy will fail + await expect( + attacker.unstake(serviceId) + ).to.be.reverted; + + // Unsetting the attack will allow to unstake the service + await attacker.setAttack(false); + const balanceBefore = ethers.BigNumber.from(await ethers.provider.getBalance(multisig.address)); + await attacker.unstake(serviceId); + const balanceAfter = ethers.BigNumber.from(await ethers.provider.getBalance(multisig.address)); + + // Check that the service got no reward + expect(balanceAfter).to.equal(balanceBefore); + + // Restore a previous state of blockchain + snapshot.restore(); + }); + + it.only("Failure to receive funds by the multisig", async function () { + // Take a snapshot of the current state of the blockchain + const snapshot = await helpers.takeSnapshot(); + + // Deposit to the contract + await deployer.sendTransaction({to: serviceStaking.address, value: ethers.utils.parseEther("1")}); + + // Redeploy the service with the attacker being the multisig + await serviceRegistry.terminate(deployer.address, serviceId); + await serviceRegistry.unbond(operator.address, serviceId); + + await attacker.setOwner(agentInstances[0].address); + await serviceRegistry.activateRegistration(deployer.address, serviceId, {value: regDeposit}); + await serviceRegistry.registerAgents(operator.address, serviceId, [agentInstances[0].address], agentIds, {value: regBond}); + + // Prepare the payload to redeploy with the attacker address + const data = ethers.utils.solidityPack(["address"], [attacker.address]); + await serviceRegistry.deploy(deployer.address, serviceId, gnosisSafeSameAddressMultisig.address, data); + + // Approve service + await serviceRegistry.approve(serviceStaking.address, serviceId); + + // Stake the service + await serviceStaking.stake(serviceId); + + // Increase the time for the liveness period + await helpers.time.increase(livenessPeriod); + + // Increase the nonce + await attacker.inceraseNonce(); + + // Make sure the service have not earned any rewards + const reward = await serviceStaking.calculateServiceStakingReward(serviceId); + expect(reward).to.greaterThan(0); + + // Try to unstake the service with the re-entrancy will fail + await expect( + serviceStaking.unstake(serviceId) + ).to.be.revertedWithCustomError(serviceStaking, "TransferFailed"); + + // Restore a previous state of blockchain + snapshot.restore(); + }); }); });