From c940dc39b94bc8be6c298deab92a3dd55527f321 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ra=C3=BAl?= Date: Mon, 29 Aug 2022 19:06:34 +0200 Subject: [PATCH] Slash Proposals (#108) * added state machine contract * state machine same states for all machines * SlashingController initial commit * Configurable slash reasons, in review states and proposal editing. * deploying SlashingController * fix state machine creation * - Added stake freezing side effects to SlashingController - Proposing slash checks for subject existence, unified interface isRegistered in AgentRegistry - Fixed typos in docs (XXXRegistry reference to ERC1155 should be ERC721) * WIP: slashing calculations * test slashing amounts * evidence handling * added tests and comments * fix onlyInState and natspec StateMachines * SlashController: change frozen stake when reviewing proposal, fix events, tests * SlashController: modify proposal tests, no need to check for proposal existance there * SlashController: fix wrong next states size, removed unnecesary checks, reused error message MissingRole, tests * FortaStaking: comments * FortaStakingParameters: comments * SlashingController: implements ISlashingController * linting * FortaStaking: make stake to shares converters public * SlashController: removed MAX_STAKE penalty mode, since it will always be the max possible stake * fix tests * Update contracts/components/staking/SlashingController.sol Co-authored-by: Hadrien Croubois * Update contracts/components/staking/SlashingController.sol reentrancy fix returning deposit Co-authored-by: Hadrien Croubois * Update contracts/components/staking/SlashingController.sol reentrancy protection on slash deposit Co-authored-by: Hadrien Croubois * SlashingController: msg.sender -> _msgSender(), fix role check for revert proposal * SlashingController: immutable depositToken * SlashingController: max string length for evidence * StateMachines & SlashingController: refactor to more efficient state machines * StateMachines attribution * SlashingController and FortaStaking: reverted to accessControl SLASHER_ROLE for slash and freeze * SlashingController: move _transition up in markAsInReviewSlashProposal Co-authored-by: Hadrien Croubois --- .solhint.json | 2 +- contracts/components/Roles.sol | 5 +- contracts/components/agents/AgentRegistry.sol | 16 +- .../components/agents/AgentRegistryCore.sol | 18 +- .../components/agents/AgentRegistryEnable.sol | 20 +- .../agents/AgentRegistryEnumerable.sol | 2 +- .../agents/AgentRegistryMetadata.sol | 8 +- contracts/components/dispatch/Dispatch.sol | 4 +- .../components/scanners/ScannerRegistry.sol | 6 +- .../scanners/ScannerRegistryCore.sol | 22 +- .../scanners/ScannerRegistryEnable.sol | 18 +- .../scanners/ScannerRegistryManaged.sol | 10 +- .../scanners/ScannerRegistryMetadata.sol | 8 +- contracts/components/staking/FortaStaking.sol | 219 +++--- .../staking/FortaStakingParameters.sol | 33 +- .../components/staking/ISlashingExecutor.sol | 22 + .../components/staking/IStakeController.sol | 2 + .../components/staking/IStakeSubject.sol | 1 + contracts/components/staking/SlashReasons.sol | 10 + .../components/staking/SlashingController.sol | 415 +++++++++++ contracts/components/utils/StateMachines.sol | 75 ++ contracts/errors/GeneralErrors.sol | 5 + scripts/deploy-platform.js | 92 ++- test/components/agents.test.js | 10 +- test/components/slashing.test.js | 681 ++++++++++++++++++ test/components/staking.test.js | 52 +- test/components/upgrades.test.js | 2 +- test/fixture.js | 3 + 28 files changed, 1579 insertions(+), 182 deletions(-) create mode 100644 contracts/components/staking/ISlashingExecutor.sol create mode 100644 contracts/components/staking/SlashReasons.sol create mode 100644 contracts/components/staking/SlashingController.sol create mode 100644 contracts/components/utils/StateMachines.sol create mode 100644 test/components/slashing.test.js diff --git a/.solhint.json b/.solhint.json index 8c43589f..c9061efc 100644 --- a/.solhint.json +++ b/.solhint.json @@ -3,7 +3,7 @@ "plugins": ["prettier"], "rules": { "prettier/prettier": "error", - "max-line-length": ["error", 180], + "max-line-length": ["error", 150], "func-param-name-mixedcase": "error", "modifier-name-mixedcase": "error", "private-vars-leading-underscore": "error", diff --git a/contracts/components/Roles.sol b/contracts/components/Roles.sol index ab92a9db..359a44b3 100644 --- a/contracts/components/Roles.sol +++ b/contracts/components/Roles.sol @@ -20,7 +20,10 @@ bytes32 constant DISPATCHER_ROLE = keccak256("DISPATCHER_ROLE"); // Staking bytes32 constant SLASHER_ROLE = keccak256("SLASHER_ROLE"); bytes32 constant SWEEPER_ROLE = keccak256("SWEEPER_ROLE"); -bytes32 constant REWARDS_ADMIN = keccak256("REWARDS_ADMIN_ROLE"); +bytes32 constant REWARDS_ADMIN_ROLE = keccak256("REWARDS_ADMIN_ROLE"); +bytes32 constant SLASHING_ARBITER_ROLE = keccak256("SLASHING_ARBITER_ROLE"); +bytes32 constant STAKING_ADMIN_ROLE = keccak256("STAKING_ADMIN_ROLE"); + // Scanner Node Version bytes32 constant SCANNER_VERSION_ROLE = keccak256("SCANNER_VERSION_ROLE"); bytes32 constant SCANNER_BETA_VERSION_ROLE = keccak256("SCANNER_BETA_VERSION_ROLE"); \ No newline at end of file diff --git a/contracts/components/agents/AgentRegistry.sol b/contracts/components/agents/AgentRegistry.sol index 6ab3e5b2..4906ac02 100644 --- a/contracts/components/agents/AgentRegistry.sol +++ b/contracts/components/agents/AgentRegistry.sol @@ -17,7 +17,7 @@ contract AgentRegistry is AgentRegistryMetadata, AgentRegistryEnumerable { - string public constant version = "0.1.3"; + string public constant version = "0.1.4"; /// @custom:oz-upgrades-unsafe-allow constructor constructor(address forwarder) initializer ForwardedContext(forwarder) {} @@ -25,8 +25,8 @@ contract AgentRegistry is * @notice Initializer method, access point to initialize inheritance tree. * @param __manager address of AccessManager. * @param __router address of Router. - * @param __name ERC1155 token name. - * @param __symbol ERC1155 token symbol. + * @param __name ERC721 token name. + * @param __symbol ERC721 token symbol. */ function initialize( address __manager, @@ -42,8 +42,8 @@ contract AgentRegistry is /** * @notice Gets all Agent state. - * @param agentId ERC1155 token id of the agent. - * @return created if agent exists. + * @param agentId ERC721 token id of the agent. + * @return registered if agent exists. * @return owner address. * @return agentVersion of the agent. * @return metadata IPFS pointer. @@ -54,7 +54,7 @@ contract AgentRegistry is function getAgentState(uint256 agentId) public view returns ( - bool created, + bool registered, address owner, uint256 agentVersion, string memory metadata, @@ -62,9 +62,9 @@ contract AgentRegistry is bool enabled, uint256 disabledFlags ) { - (created, owner, agentVersion, metadata, chainIds) = getAgent(agentId); + (registered, owner, agentVersion, metadata, chainIds) = getAgent(agentId); return ( - created, + registered, owner, agentVersion, metadata, diff --git a/contracts/components/agents/AgentRegistryCore.sol b/contracts/components/agents/AgentRegistryCore.sol index eb146e71..9d058adb 100644 --- a/contracts/components/agents/AgentRegistryCore.sol +++ b/contracts/components/agents/AgentRegistryCore.sol @@ -28,7 +28,7 @@ abstract contract AgentRegistryCore is /** * @notice Checks sender (or metatx signer) is owner of the agent token. - * @param agentId ERC1155 token id of the agent. + * @param agentId ERC721 token id of the agent. */ modifier onlyOwnerOf(uint256 agentId) { if (_msgSender() != ownerOf(agentId)) revert SenderNotOwner(_msgSender(), agentId); @@ -56,11 +56,11 @@ abstract contract AgentRegistryCore is } /** - * @notice Agent creation method. Mints an ERC1155 token with the agent id for the owner and stores metadata. + * @notice Agent creation method. Mints an ERC721 token with the agent id for the owner and stores metadata. * @dev fires _before and _after hooks within the inheritance tree. * If front run protection is enabled (disabled by default), it will check if the keccak256 hash of the parameters * has been committed in prepareAgent(bytes32). - * @param agentId ERC1155 token id of the agent to be created. + * @param agentId ERC721 token id of the agent to be created. * @param owner address to have ownership privileges in the agent methods. * @param metadata IPFS pointer to agent's metadata JSON. * @param chainIds ordered list of chainIds where the agent wants to run. @@ -78,17 +78,17 @@ abstract contract AgentRegistryCore is /** * @notice Checks if the agentId has been minted. - * @param agentId ERC1155 token id of the agent. + * @param agentId ERC721 token id of the agent. * @return true if agentId exists, false otherwise. */ - function isCreated(uint256 agentId) public view returns(bool) { + function isRegistered(uint256 agentId) public view returns(bool) { return _exists(agentId); } /** * @notice Updates parameters of an agentId (metadata, image, chain IDs...) if called by the agent owner. * @dev fires _before and _after hooks within the inheritance tree. - * @param agentId ERC1155 token id of the agent to be updated. + * @param agentId ERC721 token id of the agent to be updated. * @param metadata IPFS pointer to agent's metadata JSON. * @param chainIds ordered list of chainIds where the agent wants to run. */ @@ -143,7 +143,7 @@ abstract contract AgentRegistryCore is /** * @notice hook fired before agent creation or update. * @dev does nothing in this contract. - * @param agentId ERC1155 token id of the agent to be created or updated. + * @param agentId ERC721 token id of the agent to be created or updated. * @param newMetadata IPFS pointer to agent's metadata JSON. * @param newChainIds ordered list of chainIds where the agent wants to run. */ @@ -153,7 +153,7 @@ abstract contract AgentRegistryCore is /** * @notice logic for agent update. * @dev emits AgentUpdated, will be extended by child contracts. - * @param agentId ERC1155 token id of the agent to be created or updated. + * @param agentId ERC721 token id of the agent to be created or updated. * @param newMetadata IPFS pointer to agent's metadata JSON. * @param newChainIds ordered list of chainIds where the agent wants to run. */ @@ -164,7 +164,7 @@ abstract contract AgentRegistryCore is /** * @notice hook fired after agent creation or update. * @dev emits Router hook. - * @param agentId ERC1155 token id of the agent to be created or updated. + * @param agentId ERC721 token id of the agent to be created or updated. * @param newMetadata IPFS pointer to agent's metadata JSON. * @param newChainIds ordered list of chainIds where the agent wants to run. */ diff --git a/contracts/components/agents/AgentRegistryEnable.sol b/contracts/components/agents/AgentRegistryEnable.sol index 33b0f578..a564a097 100644 --- a/contracts/components/agents/AgentRegistryEnable.sol +++ b/contracts/components/agents/AgentRegistryEnable.sol @@ -27,12 +27,12 @@ abstract contract AgentRegistryEnable is AgentRegistryCore { /** * @notice Check if agent is enabled - * @param agentId ERC1155 token id of the agent. + * @param agentId ERC721 token id of the agent. * @return true if the agent exist, has not been disabled, and is staked over minimum * Returns false if otherwise */ function isEnabled(uint256 agentId) public view virtual returns (bool) { - return isCreated(agentId) && + return isRegistered(agentId) && getDisableFlags(agentId) == 0 && _isStakedOverMin(agentId); } @@ -40,7 +40,7 @@ abstract contract AgentRegistryEnable is AgentRegistryCore { /** * @notice Enable an agent if sender has correct permission and the agent is staked over minimum stake. * @dev agents can be disabled by ADMIN or OWNER. - * @param agentId ERC1155 token id of the agent. + * @param agentId ERC721 token id of the agent. * @param permission the sender claims to have to enable the agent. */ function enableAgent(uint256 agentId, Permission permission) public virtual { @@ -52,7 +52,7 @@ abstract contract AgentRegistryEnable is AgentRegistryCore { /** * @notice Disable an agent if sender has correct permission. * @dev agents can be disabled by ADMIN or OWNER. - * @param agentId ERC1155 token id of the agent. + * @param agentId ERC721 token id of the agent. * @param permission the sender claims to have to enable the agent. */ function disableAgent(uint256 agentId, Permission permission) public virtual { @@ -64,7 +64,7 @@ abstract contract AgentRegistryEnable is AgentRegistryCore { * @notice Get the disabled flags for an agentId. * @dev Permission (uint8) is used for indexing, so we don't need to loop. * If not disabled, all flags will be 0. - * @param agentId ERC1155 token id of the agent. + * @param agentId ERC721 token id of the agent. * @return uint256 containing the byte flags. */ function getDisableFlags(uint256 agentId) public view returns (uint256) { @@ -74,7 +74,7 @@ abstract contract AgentRegistryEnable is AgentRegistryCore { /** * @notice Permission check. * @dev it does not uses AccessManager since it is agent specific - * @param agentId ERC1155 token id of the agent. + * @param agentId ERC721 token id of the agent. * @param permission the sender claims to have to enable the agent. * @return true if: permission.ADMIN and _msgSender is ADMIN_ROLE, Permission.OWNER and owner of agentId, * false otherwise. @@ -88,7 +88,7 @@ abstract contract AgentRegistryEnable is AgentRegistryCore { /** * @notice Internal methods for enabling the agent. * @dev fires hook _before and _after enable within the inheritance tree. - * @param agentId ERC1155 token id of the agent. + * @param agentId ERC721 token id of the agent. * @param permission the sender claims to have to enable the agent. * @param enable true if enabling, false if disabling. */ @@ -101,7 +101,7 @@ abstract contract AgentRegistryEnable is AgentRegistryCore { /** * @notice Hook _before agent enable * @dev does nothing in this contract - * @param agentId ERC1155 token id of the agent. + * @param agentId ERC721 token id of the agent. * @param permission the sender claims to have to enable the agent. * @param value true if enabling, false if disabling. */ @@ -111,7 +111,7 @@ abstract contract AgentRegistryEnable is AgentRegistryCore { /** * @notice Logic for enabling agents, sets flag corresponding to permission. * @dev does nothing in this contract - * @param agentId ERC1155 token id of the agent. + * @param agentId ERC721 token id of the agent. * @param permission the sender claims to have to enable the agent. * @param value true if enabling, false if disabling. */ @@ -123,7 +123,7 @@ abstract contract AgentRegistryEnable is AgentRegistryCore { /** * @notice Hook _after agent enable * @dev emits Router hook - * @param agentId ERC1155 token id of the agent. + * @param agentId ERC721 token id of the agent. * @param permission the sender claims to have to enable the agent. * @param value true if enabling, false if disabling. */ diff --git a/contracts/components/agents/AgentRegistryEnumerable.sol b/contracts/components/agents/AgentRegistryEnumerable.sol index 7b2b34a0..59516360 100644 --- a/contracts/components/agents/AgentRegistryEnumerable.sol +++ b/contracts/components/agents/AgentRegistryEnumerable.sol @@ -55,7 +55,7 @@ abstract contract AgentRegistryEnumerable is AgentRegistryMetadata { /** * @notice hook fired before agent creation or update. * @dev stores agent in _allAgents if it wasn't there, manages agent arrays by chain. - * @param agentId ERC1155 token id of the agent to be created or updated. + * @param agentId ERC721 token id of the agent to be created or updated. * @param newMetadata IPFS pointer to agent's metadata JSON. * @param newChainIds ordered list of chainIds where the agent wants to run. */ diff --git a/contracts/components/agents/AgentRegistryMetadata.sol b/contracts/components/agents/AgentRegistryMetadata.sol index 0a5e0d3e..fe58800b 100644 --- a/contracts/components/agents/AgentRegistryMetadata.sol +++ b/contracts/components/agents/AgentRegistryMetadata.sol @@ -19,8 +19,8 @@ abstract contract AgentRegistryMetadata is AgentRegistryCore { /** * @notice Gets agent metadata, version and chain Ids. - * @param agentId ERC1155 token id of the agent. - * @return created if agent exists. + * @param agentId ERC721 token id of the agent. + * @return registered if agent exists. * @return owner address. * @return agentVersion of the agent. * @return metadata IPFS pointer. @@ -28,7 +28,7 @@ abstract contract AgentRegistryMetadata is AgentRegistryCore { */ function getAgent(uint256 agentId) public view - returns (bool created, address owner,uint256 agentVersion, string memory metadata, uint256[] memory chainIds) { + returns (bool registered, address owner,uint256 agentVersion, string memory metadata, uint256[] memory chainIds) { bool exists = _exists(agentId); return ( exists, @@ -42,7 +42,7 @@ abstract contract AgentRegistryMetadata is AgentRegistryCore { /** * @notice logic for agent update. * @dev checks metadata uniqueness and updates agent metadata and version. - * @param agentId ERC1155 token id of the agent to be created or updated. + * @param agentId ERC721 token id of the agent to be created or updated. * @param newMetadata IPFS pointer to agent's metadata JSON. * @param newChainIds ordered list of chainIds where the agent wants to run. */ diff --git a/contracts/components/dispatch/Dispatch.sol b/contracts/components/dispatch/Dispatch.sol index fb282269..c4dc28a7 100644 --- a/contracts/components/dispatch/Dispatch.sol +++ b/contracts/components/dispatch/Dispatch.sol @@ -15,7 +15,7 @@ contract Dispatch is BaseComponentUpgradeable { AgentRegistry private _agents; ScannerRegistry private _scanners; - string public constant version = "0.1.4"; + string public constant version = "0.1.5"; mapping(uint256 => EnumerableSet.UintSet) private scannerToAgents; mapping(uint256 => EnumerableSet.UintSet) private agentToScanners; @@ -197,7 +197,7 @@ contract Dispatch is BaseComponentUpgradeable { * @param scannerId ERC1155 token id of the scanner. */ function unlink(uint256 agentId, uint256 scannerId) public onlyRole(DISPATCHER_ROLE) { - if (!_agents.isCreated(agentId)) revert InvalidId("Agent", agentId); + if (!_agents.isRegistered(agentId)) revert InvalidId("Agent", agentId); if (!_scanners.isRegistered(scannerId)) revert InvalidId("Scanner", scannerId); if (!scannerToAgents[scannerId].remove(agentId) || !agentToScanners[agentId].remove(scannerId)) { diff --git a/contracts/components/scanners/ScannerRegistry.sol b/contracts/components/scanners/ScannerRegistry.sol index 27d635ec..4470beca 100644 --- a/contracts/components/scanners/ScannerRegistry.sol +++ b/contracts/components/scanners/ScannerRegistry.sol @@ -24,8 +24,8 @@ contract ScannerRegistry is * @notice Initializer method, access point to initialize inheritance tree. * @param __manager address of AccessManager. * @param __router address of Router. - * @param __name ERC1155 token name. - * @param __symbol ERC1155 token symbol. + * @param __name ERC721 token name. + * @param __symbol ERC721 token symbol. */ function initialize( address __manager, @@ -41,7 +41,7 @@ contract ScannerRegistry is /** * @notice Gets all scanner properties and state - * @param scannerId ERC1155 token id of the scanner. + * @param scannerId ERC721 token id of the scanner. * @return registered true if scanner exists. * @return owner address. * @return chainId the scanner is monitoring. diff --git a/contracts/components/scanners/ScannerRegistryCore.sol b/contracts/components/scanners/ScannerRegistryCore.sol index b599e1a9..5fcff55c 100644 --- a/contracts/components/scanners/ScannerRegistryCore.sol +++ b/contracts/components/scanners/ScannerRegistryCore.sol @@ -24,7 +24,7 @@ abstract contract ScannerRegistryCore is /** * @notice Checks sender (or metatx signer) is owner of the scanner token. - * @param scannerId ERC1155 token id of the scanner. + * @param scannerId ERC721 token id of the scanner. */ modifier onlyOwnerOf(uint256 scannerId) { if (_msgSender() != ownerOf(scannerId)) revert SenderNotOwner(_msgSender(), scannerId); @@ -33,7 +33,7 @@ abstract contract ScannerRegistryCore is /** * @notice Scanner registration via admin key. - * @dev restricted to SCANNER_ADMIN_ROLE. Scanner address will be converted to uin256 ERC1155 token id. + * @dev restricted to SCANNER_ADMIN_ROLE. Scanner address will be converted to uin256 ERC721 token id. * @param scanner address generated by scanner software. * @param owner of the scanner. Will have admin privileges over the registering scanner. * @param chainId that the scanner will monitor. @@ -45,15 +45,15 @@ abstract contract ScannerRegistryCore is /** * @notice Checks if scannerId has been registered (minted). - * @param scannerId ERC1155 token id of the scanner. + * @param scannerId ERC721 token id of the scanner. * @return true if scannerId exists, false otherwise. */ - function isRegistered(uint256 scannerId) public view returns(bool) { + function isRegistered(uint256 scannerId) public view override returns(bool) { return _exists(scannerId); } /** - * @notice Public method for scanners to self register in Forta and mint registration ERC1155 token. + * @notice Public method for scanners to self register in Forta and mint registration ERC721 token. * @dev _msgSender() will be considered the Scanner Node address. * @param owner of the scanner. Will have admin privileges over the registering scanner. * @param chainId that the scanner will monitor. @@ -65,9 +65,9 @@ abstract contract ScannerRegistryCore is } /** - * @notice Internal method for scanners to self register in Forta and mint registration ERC1155 token. + * @notice Internal method for scanners to self register in Forta and mint registration ERC721 token. * Public staking must be activated in the target chainId. - * @dev Scanner address will be converted to uin256 ERC1155 token id. Will trigger _before and _after hooks within + * @dev Scanner address will be converted to uin256 ERC721 token id. Will trigger _before and _after hooks within * the inheritance tree. * @param owner of the scanner. Will have admin privileges over the registering scanner. * @param chainId that the scanner will monitor. @@ -97,7 +97,7 @@ abstract contract ScannerRegistryCore is _afterScannerUpdate(scannerId, chainId, metadata); } - /// Converts scanner address to uint256 for ERC1155 Token Id. + /// Converts scanner address to uint256 for ERC721 Token Id. function scannerAddressToId(address scanner) public pure returns(uint256) { return uint256(uint160(scanner)); } @@ -143,7 +143,7 @@ abstract contract ScannerRegistryCore is /** * @notice _before hook triggered before scanner creation or update. * @dev Does nothing in this base contract. - * @param scannerId ERC1155 token id of the scanner. + * @param scannerId ERC721 token id of the scanner. * @param chainId that the scanner will monitor. * @param metadata IPFS pointer to scanner's metadata JSON */ @@ -153,7 +153,7 @@ abstract contract ScannerRegistryCore is /** * @notice Scanner update logic. * @dev Emits ScannerUpdated(scannerId, chainId, metadata) - * @param scannerId ERC1155 token id of the scanner. + * @param scannerId ERC721 token id of the scanner. * @param chainId that the scanner will monitor. * @param metadata IPFS pointer to scanner's metadata JSON */ @@ -164,7 +164,7 @@ abstract contract ScannerRegistryCore is /** * @notice _after hook triggered after scanner creation or update. * @dev emits Router hook - * @param scannerId ERC1155 token id of the scanner. + * @param scannerId ERC721 token id of the scanner. * @param chainId that the scanner will monitor. * @param metadata IPFS pointer to scanner's metadata JSON */ diff --git a/contracts/components/scanners/ScannerRegistryEnable.sol b/contracts/components/scanners/ScannerRegistryEnable.sol index 572f9afd..9ca05792 100644 --- a/contracts/components/scanners/ScannerRegistryEnable.sol +++ b/contracts/components/scanners/ScannerRegistryEnable.sol @@ -29,7 +29,7 @@ abstract contract ScannerRegistryEnable is ScannerRegistryManaged { /** * @notice Check if scanner is enabled - * @param scannerId ERC1155 token id of the scanner. + * @param scannerId ERC721 token id of the scanner. * @return true if the scanner is registered, has not been disabled, and is staked over minimum value. * Returns false if otherwise */ @@ -42,7 +42,7 @@ abstract contract ScannerRegistryEnable is ScannerRegistryManaged { /** * @notice Public method to enable a scanner, if caller has permission. Scanner must be staked over minimum defined * for the scanner's chainId. - * @param scannerId ERC1155 token id of the scanner. + * @param scannerId ERC721 token id of the scanner. * @param permission the caller claims to have. */ function enableScanner(uint256 scannerId, Permission permission) public virtual { @@ -53,7 +53,7 @@ abstract contract ScannerRegistryEnable is ScannerRegistryManaged { /** * @notice Public method to disable a scanner, if caller has permission. - * @param scannerId ERC1155 token id of the scanner. + * @param scannerId ERC721 token id of the scanner. * @param permission the caller claims to have. */ function disableScanner(uint256 scannerId, Permission permission) public virtual { @@ -65,7 +65,7 @@ abstract contract ScannerRegistryEnable is ScannerRegistryManaged { * Get the disabled flags for an agentId. Permission (uint8) is used for indexing, so we don't * need to loop. * If not disabled, all flags will be 0 - * @param scannerId ERC1155 token id of the scanner. + * @param scannerId ERC721 token id of the scanner. * @return uint256 containing the byte flags. */ function getDisableFlags(uint256 scannerId) public view returns (uint256) { @@ -75,7 +75,7 @@ abstract contract ScannerRegistryEnable is ScannerRegistryManaged { /** * @notice Method that does permission checks. * @dev AccessManager is not used since the permission is specific for scannerId - * @param scannerId ERC1155 token id of the scanner. + * @param scannerId ERC721 token id of the scanner. * @param permission the caller claims to have. * @return true if (ADMIN and _msgSender() has SCANNER_ADMIN_ROLE), if _msgSender() is the scanner itself, its owner * or manager for each respective permission, false otherwise. @@ -91,7 +91,7 @@ abstract contract ScannerRegistryEnable is ScannerRegistryManaged { /** * @notice Internal method to enable a scanner. * @dev will trigger _before and _after enable hooks within the inheritance tree. - * @param scannerId ERC1155 token id of the scanner. + * @param scannerId ERC721 token id of the scanner. * @param permission the caller claims to have. * @param enable true for enabling, false for disabling */ @@ -105,7 +105,7 @@ abstract contract ScannerRegistryEnable is ScannerRegistryManaged { /** * @notice Hook _before scanner enable * @dev does nothing in this contract - * @param scannerId ERC1155 token id of the scanner. + * @param scannerId ERC721 token id of the scanner. * @param permission the sender claims to have to enable the agent. * @param value true if enabling, false if disabling. */ @@ -115,7 +115,7 @@ abstract contract ScannerRegistryEnable is ScannerRegistryManaged { /** * @notice Logic for enabling or disabling the scanner. * @dev sets the corresponding byte in _disabled bitmap for scannerId. Emits ScannerEnabled event. - * @param scannerId ERC1155 token id of the scanner. + * @param scannerId ERC721 token id of the scanner. * @param permission the sender claims to have to enable the agent. * @param value true if enabling, false if disabling. */ @@ -127,7 +127,7 @@ abstract contract ScannerRegistryEnable is ScannerRegistryManaged { /** * @notice Hook _after scanner enable * @dev emits Router hook. - * @param scannerId ERC1155 token id of the scanner. + * @param scannerId ERC721 token id of the scanner. * @param permission the sender claims to have to enable the agent. * @param value true if enabling, false if disabling. */ diff --git a/contracts/components/scanners/ScannerRegistryManaged.sol b/contracts/components/scanners/ScannerRegistryManaged.sol index 2e599fa6..66bc089f 100644 --- a/contracts/components/scanners/ScannerRegistryManaged.sol +++ b/contracts/components/scanners/ScannerRegistryManaged.sol @@ -18,7 +18,7 @@ abstract contract ScannerRegistryManaged is ScannerRegistryCore { /** * @notice Checks sender (or metatx signer) is manager of the scanner token. - * @param scannerId ERC1155 token id of the scanner. + * @param scannerId ERC721 token id of the scanner. */ modifier onlyManagerOf(uint256 scannerId) { if (!_managers[scannerId].contains(_msgSender())) revert SenderNotManager(_msgSender(), scannerId); @@ -27,7 +27,7 @@ abstract contract ScannerRegistryManaged is ScannerRegistryCore { /** * @notice Checks if address is defined as a manager for a scanner. - * @param scannerId ERC1155 token id of the scanner. + * @param scannerId ERC721 token id of the scanner. * @param manager address to check. * @return true if defined as manager for scanner, false otherwise. */ @@ -38,7 +38,7 @@ abstract contract ScannerRegistryManaged is ScannerRegistryCore { /** * @notice Gets total managers defined for a scanner. * @dev helper for external iteration. - * @param scannerId ERC1155 token id of the scanner. + * @param scannerId ERC721 token id of the scanner. * @return total managers defined for a scanner. */ function getManagerCount(uint256 scannerId) public view virtual returns (uint256) { @@ -48,7 +48,7 @@ abstract contract ScannerRegistryManaged is ScannerRegistryCore { /** * @notice Gets manager address at certain position of the scanner's manager set. * @dev helper for external iteration. - * @param scannerId ERC1155 token id of the scanner. + * @param scannerId ERC721 token id of the scanner. * @param index position in the set. * @return address of the manager at index. */ @@ -58,7 +58,7 @@ abstract contract ScannerRegistryManaged is ScannerRegistryCore { /** * @notice Adds or removes a manager to a certain scanner. Restricted to scanner owner. - * @param scannerId ERC1155 token id of the scanner. + * @param scannerId ERC721 token id of the scanner. * @param manager address to be added or removed fromm manager list for the scanner. * @param enable true for adding, false for removing. */ diff --git a/contracts/components/scanners/ScannerRegistryMetadata.sol b/contracts/components/scanners/ScannerRegistryMetadata.sol index 581c625a..71d7f576 100644 --- a/contracts/components/scanners/ScannerRegistryMetadata.sol +++ b/contracts/components/scanners/ScannerRegistryMetadata.sol @@ -15,7 +15,7 @@ abstract contract ScannerRegistryMetadata is ScannerRegistryCore { /** * @notice Gets all scanner properties. - * @param scannerId ERC1155 token id of the scanner. + * @param scannerId ERC721 token id of the scanner. * @return registered true if scanner exists. * @return owner address. * @return chainId the scanner is monitoring. @@ -33,7 +33,7 @@ abstract contract ScannerRegistryMetadata is ScannerRegistryCore { /** * @notice Gets scanner chain Ids. - * @param scannerId ERC1155 token id of the scanner. + * @param scannerId ERC721 token id of the scanner. * @return chainId the scanner is monitoring. */ function getScannerChainId(uint256 scannerId) public view returns (uint256) { @@ -43,7 +43,7 @@ abstract contract ScannerRegistryMetadata is ScannerRegistryCore { /** * @dev checks the StakeThreshold for the chainId the scanner with id `subject` was registered to monitor. - * @param subject ERC1155 token id of the scanner. + * @param subject ERC721 token id of the scanner. * @return StakeThreshold registered for `chainId`, or StakeThreshold(0,0,false) if `chainId` not found. */ function _getStakeThreshold(uint256 subject) override virtual internal view returns(StakeThreshold memory) { @@ -53,7 +53,7 @@ abstract contract ScannerRegistryMetadata is ScannerRegistryCore { /** * @notice internal logic for scanner update. * @dev adds metadata and chainId for that scanner - * @param scannerId ERC1155 token id of the scanner. + * @param scannerId ERC721 token id of the scanner. * @param chainId the scanner scans. * @param metadata IPFS pointer for the scanner's JSON metadata. */ diff --git a/contracts/components/staking/FortaStaking.sol b/contracts/components/staking/FortaStaking.sol index 6fdfe600..d54c82e8 100644 --- a/contracts/components/staking/FortaStaking.sol +++ b/contracts/components/staking/FortaStaking.sol @@ -14,12 +14,17 @@ import "@openzeppelin/contracts-upgradeable/token/ERC1155/extensions/ERC1155Supp import "./FortaStakingUtils.sol"; import "./SubjectTypes.sol"; import "./IStakeController.sol"; +import "./ISlashingExecutor.sol"; import "../BaseComponentUpgradeable.sol"; import "../../tools/Distributions.sol"; import "../../errors/GeneralErrors.sol"; interface IRewardReceiver { - function onRewardReceived(uint8 subjectType, uint256 subject, uint256 amount) external; + function onRewardReceived( + uint8 subjectType, + uint256 subject, + uint256 amount + ) external; } /** @@ -50,10 +55,10 @@ interface IRewardReceiver { * succeed but you will not be able to withdraw or mint new shares from the contract. If this happens, transfer your * shares to an EOA or fully ERC1155 compatible contract. */ -contract FortaStaking is BaseComponentUpgradeable, ERC1155SupplyUpgradeable, SubjectTypeValidator { +contract FortaStaking is BaseComponentUpgradeable, ERC1155SupplyUpgradeable, SubjectTypeValidator, ISlashingExecutor { using Distributions for Distributions.Balances; using Distributions for Distributions.SignedBalances; - using Timers for Timers.Timestamp; + using Timers for Timers.Timestamp; using ERC165Checker for address; IERC20 public stakedToken; @@ -87,6 +92,7 @@ contract FortaStaking is BaseComponentUpgradeable, ERC1155SupplyUpgradeable, Sub event WithdrawalExecuted(uint8 indexed subjectType, uint256 indexed subject, address indexed account); event Froze(uint8 indexed subjectType, uint256 indexed subject, address indexed by, bool isFrozen); event Slashed(uint8 indexed subjectType, uint256 indexed subject, address indexed by, uint256 value); + event SlashedShareSent(uint8 indexed subjectType, uint256 indexed subject, address indexed by, uint256 value); event Rewarded(uint8 indexed subjectType, uint256 indexed subject, address indexed from, uint256 value); event Released(uint8 indexed subjectType, uint256 indexed subject, address indexed to, uint256 value); event DelaySet(uint256 newWithdrawalDelay); @@ -136,6 +142,11 @@ contract FortaStaking is BaseComponentUpgradeable, ERC1155SupplyUpgradeable, Sub emit TreasurySet(__treasury); } + /// Returns treasury address (slashed tokens destination) + function treasury() public view returns (address) { + return _treasury; + } + /** * @notice Get stake of a subject (not marked for withdrawal). * @param subjectType agents, scanner or future types of stake subject. See SubjectTypes.sol @@ -164,7 +175,6 @@ contract FortaStaking is BaseComponentUpgradeable, ERC1155SupplyUpgradeable, Sub return _inactiveStake.balanceOf(FortaStakingUtils.subjectToInactive(subjectType, subject)); } - /** * @notice Get total inactive stake of all subjects (marked for withdrawal). * @return amount of stakedToken still staked on all subject+subjectTypes but marked for withdrawal. @@ -182,7 +192,11 @@ contract FortaStaking is BaseComponentUpgradeable, ERC1155SupplyUpgradeable, Sub * @param account holder of the ERC1155 staking shares. * @return amount of ERC1155 shares account is in possession in representing stake on subject+subjectType. */ - function sharesOf(uint8 subjectType, uint256 subject, address account) public view returns (uint256) { + function sharesOf( + uint8 subjectType, + uint256 subject, + address account + ) public view returns (uint256) { return balanceOf(account, FortaStakingUtils.subjectToActive(subjectType, subject)); } @@ -207,7 +221,11 @@ contract FortaStaking is BaseComponentUpgradeable, ERC1155SupplyUpgradeable, Sub * @param account holder of the ERC1155 staking shares. * @return amount of ERC1155 shares account is in possession in representing inactive stake on subject+subjectType, marked for withdrawal. */ - function inactiveSharesOf(uint8 subjectType, uint256 subject, address account) external view returns (uint256) { + function inactiveSharesOf( + uint8 subjectType, + uint256 subject, + address account + ) external view returns (uint256) { return balanceOf(account, FortaStakingUtils.subjectToInactive(subjectType, subject)); } @@ -252,11 +270,11 @@ contract FortaStaking is BaseComponentUpgradeable, ERC1155SupplyUpgradeable, Sub * @param stakeValue amount of staked token. * @return amount of ERC1155 active shares minted. */ - function deposit(uint8 subjectType, uint256 subject, uint256 stakeValue) - public - onlyValidSubjectType(subjectType) - returns (uint256) - { + function deposit( + uint8 subjectType, + uint256 subject, + uint256 stakeValue + ) public onlyValidSubjectType(subjectType) returns (uint256) { if (address(_stakingParameters) == address(0)) revert ZeroAddress("_stakingParameters"); if (!_stakingParameters.isStakeActivatedFor(subjectType, subject)) revert StakeInactiveOrSubjectNotFound(); address staker = _msgSender(); @@ -266,7 +284,7 @@ contract FortaStaking is BaseComponentUpgradeable, ERC1155SupplyUpgradeable, Sub if (reachedMax) { emit MaxStakeReached(subjectType, subject); } - uint256 sharesValue = _stakeToActiveShares(activeSharesId, stakeValue); + uint256 sharesValue = stakeToActiveShares(activeSharesId, stakeValue); SafeERC20.safeTransferFrom(stakedToken, staker, address(this), stakeValue); _activeStake.mint(activeSharesId, stakeValue); @@ -279,14 +297,18 @@ contract FortaStaking is BaseComponentUpgradeable, ERC1155SupplyUpgradeable, Sub } /** - * Calculates how much of the incoming stake fits for subject. - * @param subjectType valid subect type - * @param subject the id of the subject - * @param stakeValue stake sent by staker - * @return stakeValue - excess - * @return true if reached max - */ - function _getInboundStake(uint8 subjectType, uint256 subject, uint256 stakeValue) private view returns (uint256, bool) { + * Calculates how much of the incoming stake fits for subject. + * @param subjectType valid subect type + * @param subject the id of the subject + * @param stakeValue stake sent by staker + * @return stakeValue - excess + * @return true if reached max + */ + function _getInboundStake( + uint8 subjectType, + uint256 subject, + uint256 stakeValue + ) private view returns (uint256, bool) { uint256 max = _stakingParameters.maxStakeFor(subjectType, subject); if (activeStakeFor(subjectType, subject) >= max) { return (0, true); @@ -311,11 +333,11 @@ contract FortaStaking is BaseComponentUpgradeable, ERC1155SupplyUpgradeable, Sub * @param sharesValue amount of shares token. * @return amount of time until withdrawal is valid. */ - function initiateWithdrawal(uint8 subjectType, uint256 subject, uint256 sharesValue) - public - onlyValidSubjectType(subjectType) - returns (uint64) - { + function initiateWithdrawal( + uint8 subjectType, + uint256 subject, + uint256 sharesValue + ) public onlyValidSubjectType(subjectType) returns (uint64) { address staker = _msgSender(); uint256 activeSharesId = FortaStakingUtils.subjectToActive(subjectType, subject); if (balanceOf(staker, activeSharesId) == 0) revert NoActiveShares(); @@ -323,9 +345,9 @@ contract FortaStaking is BaseComponentUpgradeable, ERC1155SupplyUpgradeable, Sub _lockingDelay[activeSharesId][staker].setDeadline(deadline); - uint256 activeShares = Math.min(sharesValue, balanceOf(staker, activeSharesId)); - uint256 stakeValue = _activeSharesToStake(activeSharesId, activeShares); - uint256 inactiveShares = _stakeToInactiveShares(FortaStakingUtils.activeToInactive(activeSharesId), stakeValue); + uint256 activeShares = Math.min(sharesValue, balanceOf(staker, activeSharesId)); + uint256 stakeValue = activeSharesToStake(activeSharesId, activeShares); + uint256 inactiveShares = stakeToInactiveShares(FortaStakingUtils.activeToInactive(activeSharesId), stakeValue); _activeStake.burn(activeSharesId, stakeValue); _inactiveStake.mint(FortaStakingUtils.activeToInactive(activeSharesId), stakeValue); @@ -348,11 +370,7 @@ contract FortaStaking is BaseComponentUpgradeable, ERC1155SupplyUpgradeable, Sub * @param subject id identifying subject (external to FortaStaking). * @return amount of withdrawn staked tokens. */ - function withdraw(uint8 subjectType, uint256 subject) - public - onlyValidSubjectType(subjectType) - returns (uint256) - { + function withdraw(uint8 subjectType, uint256 subject) public onlyValidSubjectType(subjectType) returns (uint256) { address staker = _msgSender(); uint256 inactiveSharesId = FortaStakingUtils.subjectToInactive(subjectType, subject); if (balanceOf(staker, inactiveSharesId) == 0) revert NoInactiveShares(); @@ -364,7 +382,7 @@ contract FortaStaking is BaseComponentUpgradeable, ERC1155SupplyUpgradeable, Sub emit WithdrawalExecuted(subjectType, subject, staker); uint256 inactiveShares = balanceOf(staker, inactiveSharesId); - uint256 stakeValue = _inactiveSharesToStake(inactiveSharesId, inactiveShares); + uint256 stakeValue = inactiveSharesToStake(inactiveSharesId, inactiveShares); _inactiveStake.burn(inactiveSharesId, stakeValue); _burn(staker, inactiveSharesId, inactiveShares); @@ -382,38 +400,45 @@ contract FortaStaking is BaseComponentUpgradeable, ERC1155SupplyUpgradeable, Sub * Emits a Slashed event. * @param subjectType agents, scanner or future types of stake subject. See SubjectTypes.sol * @param subject id identifying subject (external to FortaStaking). - * @param stakeValue amount of staked token. + * @param stakeValue amount of staked token to be slashed. + * @param proposer address of the slash proposer. Must be nonzero address if proposerPercent > 0 + * @param proposerPercent percentage of stakeValue sent to the proposer. From 0 to FortaStakingParameters.maxSlashableStakePercent() * @return stakeValue */ - function slash(uint8 subjectType, uint256 subject, uint256 stakeValue) - public - onlyRole(SLASHER_ROLE) - onlyValidSubjectType(subjectType) - returns (uint256) - { + function slash( + uint8 subjectType, + uint256 subject, + uint256 stakeValue, + address proposer, + uint256 proposerPercent + ) public onlyRole(SLASHER_ROLE) returns (uint256) { uint256 activeSharesId = FortaStakingUtils.subjectToActive(subjectType, subject); - uint256 activeStake = _activeStake.balanceOf(activeSharesId); - uint256 inactiveStake = _inactiveStake.balanceOf(FortaStakingUtils.activeToInactive(activeSharesId)); + uint256 activeStake = _activeStake.balanceOf(activeSharesId); + uint256 inactiveStake = _inactiveStake.balanceOf(FortaStakingUtils.activeToInactive(activeSharesId)); // We set the slash limit at 90% of the stake, so new depositors on slashed pools (with now 0 stake) won't mint // an amounts of shares so big that they might cause overflows. // New shares = pool shares * new staked amount / pool stake - // See deposit and _stakeToActiveShares methods. - uint256 maxSlashableStake = Math.mulDiv(activeStake + inactiveStake, 9, 10); + // See deposit and stakeToActiveShares methods. + uint256 maxSlashableStake = Math.mulDiv(activeStake + inactiveStake, _stakingParameters.maxSlashableStakePercent(), 100); if (stakeValue > maxSlashableStake) revert SlashingOver90Percent(); - uint256 slashFromActive = Math.mulDiv(activeStake, stakeValue, activeStake + inactiveStake); + uint256 slashFromActive = Math.mulDiv(activeStake, stakeValue, activeStake + inactiveStake); uint256 slashFromInactive = stakeValue - slashFromActive; - stakeValue = slashFromActive + slashFromInactive; + stakeValue = slashFromActive + slashFromInactive; _activeStake.burn(activeSharesId, slashFromActive); _inactiveStake.burn(FortaStakingUtils.activeToInactive(activeSharesId), slashFromInactive); - SafeERC20.safeTransfer(stakedToken, _treasury, stakeValue); + if (proposerPercent > 0) { + if (proposer == address(0)) revert ZeroAddress("proposer"); + SafeERC20.safeTransfer(stakedToken, proposer, Math.mulDiv(stakeValue, proposerPercent, 100)); + emit SlashedShareSent(subjectType, subject, proposer, Math.mulDiv(stakeValue, proposerPercent, 100)); + SafeERC20.safeTransfer(stakedToken, _treasury, Math.mulDiv(stakeValue, 100 - proposerPercent, 100)); + } else { + SafeERC20.safeTransfer(stakedToken, _treasury, stakeValue); + } emit Slashed(subjectType, subject, _msgSender(), stakeValue); - // NOTE: hooks will be reintroduced (with more info) when first use case is implemented. For now they are removed - // to reduce attack surface. - // _emitHook(abi.encodeWithSignature("hook_afterStakeChanged(uint8, uint256)", subjectType, subject)); return stakeValue; } @@ -427,16 +452,15 @@ contract FortaStaking is BaseComponentUpgradeable, ERC1155SupplyUpgradeable, Sub * @param subject id identifying subject (external to FortaStaking). * @param frozen true to freeze, false to unfreeze. */ - function freeze(uint8 subjectType, uint256 subject, bool frozen) - public - onlyRole(SLASHER_ROLE) - onlyValidSubjectType(subjectType) - { + function freeze( + uint8 subjectType, + uint256 subject, + bool frozen + ) public onlyRole(SLASHER_ROLE) onlyValidSubjectType(subjectType) { _frozen[FortaStakingUtils.subjectToActive(subjectType, subject)] = frozen; emit Froze(subjectType, subject, _msgSender(), frozen); } - /** * @notice Deposit reward value for a given `subject`. The corresponding tokens will be shared amongst the shareholders * of this subject. @@ -445,7 +469,11 @@ contract FortaStaking is BaseComponentUpgradeable, ERC1155SupplyUpgradeable, Sub * @param subject id identifying subject (external to FortaStaking). * @param value amount of reward tokens. */ - function reward(uint8 subjectType, uint256 subject, uint256 value) public onlyValidSubjectType(subjectType) { + function reward( + uint8 subjectType, + uint256 subject, + uint256 value + ) public onlyValidSubjectType(subjectType) { SafeERC20.safeTransferFrom(stakedToken, _msgSender(), address(this), value); _rewards.mint(FortaStakingUtils.subjectToActive(subjectType, subject), value); emit Rewarded(subjectType, subject, _msgSender(), value); @@ -484,11 +512,11 @@ contract FortaStaking is BaseComponentUpgradeable, ERC1155SupplyUpgradeable, Sub * @param account that staked on the subject. * @return available reward transferred. */ - function releaseReward(uint8 subjectType, uint256 subject, address account) - public - onlyValidSubjectType(subjectType) - returns (uint256) - { + function releaseReward( + uint8 subjectType, + uint256 subject, + address account + ) public onlyValidSubjectType(subjectType) returns (uint256) { uint256 activeSharesId = FortaStakingUtils.subjectToActive(subjectType, subject); uint256 value = _availableReward(activeSharesId, account); _rewards.burn(activeSharesId, value); @@ -512,11 +540,10 @@ contract FortaStaking is BaseComponentUpgradeable, ERC1155SupplyUpgradeable, Sub * @return rewards available for staker on that subject. */ function _availableReward(uint256 activeSharesId, address account) internal view returns (uint256) { - return SafeCast.toUint256( - SafeCast.toInt256(_historicalRewardFraction(activeSharesId, balanceOf(account, activeSharesId), Math.Rounding.Down)) - - - _released[activeSharesId].balanceOf(account) - ); + return + SafeCast.toUint256( + SafeCast.toInt256(_historicalRewardFraction(activeSharesId, balanceOf(account, activeSharesId), Math.Rounding.Down)) - _released[activeSharesId].balanceOf(account) + ); } /** @@ -526,7 +553,11 @@ contract FortaStaking is BaseComponentUpgradeable, ERC1155SupplyUpgradeable, Sub * @param account address of the staker * @return rewards available for staker on that subject. */ - function availableReward(uint8 subjectType, uint256 subject, address account) external view returns (uint256) { + function availableReward( + uint8 subjectType, + uint256 subject, + address account + ) external view returns (uint256) { uint256 activeSharesId = FortaStakingUtils.subjectToActive(subjectType, subject); return _availableReward(activeSharesId, account); } @@ -552,14 +583,14 @@ contract FortaStaking is BaseComponentUpgradeable, ERC1155SupplyUpgradeable, Sub // Internal helpers function _totalHistoricalReward(uint256 activeSharesId) internal view returns (uint256) { - return SafeCast.toUint256( - SafeCast.toInt256(_rewards.balanceOf(activeSharesId)) - + - _released[activeSharesId].totalSupply() - ); + return SafeCast.toUint256(SafeCast.toInt256(_rewards.balanceOf(activeSharesId)) + _released[activeSharesId].totalSupply()); } - function _historicalRewardFraction(uint256 activeSharesId, uint256 amount, Math.Rounding rounding) internal view returns (uint256) { + function _historicalRewardFraction( + uint256 activeSharesId, + uint256 amount, + Math.Rounding rounding + ) internal view returns (uint256) { uint256 supply = totalSupply(activeSharesId); return amount > 0 && supply > 0 ? Math.mulDiv(_totalHistoricalReward(activeSharesId), amount, supply, rounding) : 0; } @@ -572,7 +603,6 @@ contract FortaStaking is BaseComponentUpgradeable, ERC1155SupplyUpgradeable, Sub uint256[] memory amounts, bytes memory data ) internal virtual override { - // Order is important here, we must do the virtual release, which uses totalSupply(activeSharesId) in // _historicalRewardFraction, BEFORE the super call updates the totalSupply() for (uint256 i = 0; i < ids.length; i++) { @@ -598,21 +628,47 @@ contract FortaStaking is BaseComponentUpgradeable, ERC1155SupplyUpgradeable, Sub } // Conversions - function _stakeToActiveShares(uint256 activeSharesId, uint256 amount) internal view returns (uint256) { + + /** + * @notice Convert active token stake amount to active shares amount + * @param activeSharesId ERC1155 active shares id + * @param amount active stake amount + * @return ERC1155 active shares amount + */ + function stakeToActiveShares(uint256 activeSharesId, uint256 amount) public view returns (uint256) { uint256 activeStake = _activeStake.balanceOf(activeSharesId); return activeStake == 0 ? amount : Math.mulDiv(totalSupply(activeSharesId), amount, activeStake); } - function _stakeToInactiveShares(uint256 inactiveSharesId, uint256 amount) internal view returns (uint256) { + /** + * @notice Convert inactive token stake amount to inactive shares amount + * @param inactiveSharesId ERC1155 inactive shares id + * @param amount inactive stake amount + * @return ERC1155 inactive shares amount + */ + function stakeToInactiveShares(uint256 inactiveSharesId, uint256 amount) public view returns (uint256) { uint256 inactiveStake = _inactiveStake.balanceOf(inactiveSharesId); return inactiveStake == 0 ? amount : Math.mulDiv(totalSupply(inactiveSharesId), amount, inactiveStake); } - function _activeSharesToStake(uint256 activeSharesId, uint256 amount) internal view returns (uint256) { + /** + * @notice Convert active shares amount to active stake amount. + * @param activeSharesId ERC1155 active shares id + * @param amount ERC1155 active shares amount + * @return active stake amount + */ + function activeSharesToStake(uint256 activeSharesId, uint256 amount) public view returns (uint256) { uint256 activeSupply = totalSupply(activeSharesId); return activeSupply == 0 ? 0 : Math.mulDiv(_activeStake.balanceOf(activeSharesId), amount, activeSupply); } - function _inactiveSharesToStake(uint256 inactiveSharesId, uint256 amount) internal view returns (uint256) { + + /** + * @notice Convert inactive shares amount to inactive stake amount. + * @param inactiveSharesId ERC1155 inactive shares id + * @param amount ERC1155 inactive shares amount + * @return inactive stake amount + */ + function inactiveSharesToStake(uint256 inactiveSharesId, uint256 amount) public view returns (uint256) { uint256 inactiveSupply = totalSupply(inactiveSharesId); return inactiveSupply == 0 ? 0 : Math.mulDiv(_inactiveStake.balanceOf(inactiveSharesId), amount, inactiveSupply); } @@ -645,7 +701,6 @@ contract FortaStaking is BaseComponentUpgradeable, ERC1155SupplyUpgradeable, Sub _stakingParameters = newStakingParameters; } - // Overrides /** @@ -673,4 +728,4 @@ contract FortaStaking is BaseComponentUpgradeable, ERC1155SupplyUpgradeable, Sub } uint256[40] private __gap; -} \ No newline at end of file +} diff --git a/contracts/components/staking/FortaStakingParameters.sol b/contracts/components/staking/FortaStakingParameters.sol index b7b6a3b2..69b8218c 100644 --- a/contracts/components/staking/FortaStakingParameters.sol +++ b/contracts/components/staking/FortaStakingParameters.sol @@ -10,7 +10,6 @@ import "./FortaStaking.sol"; import "../../errors/GeneralErrors.sol"; contract FortaStakingParameters is BaseComponentUpgradeable, SubjectTypeValidator, IStakeController { - FortaStaking private _fortaStaking; // stake subject parameters for each subject mapping(uint8 => IStakeSubject) private _stakeSubjectHandlers; @@ -18,6 +17,7 @@ contract FortaStakingParameters is BaseComponentUpgradeable, SubjectTypeValidato event FortaStakingChanged(address staking); string public constant version = "0.1.0"; + uint256 public constant maxSlashableStakePercent = 90; /// @custom:oz-upgrades-unsafe-allow constructor constructor(address forwarder) initializer ForwardedContext(forwarder) {} @@ -45,41 +45,48 @@ contract FortaStakingParameters is BaseComponentUpgradeable, SubjectTypeValidato } function _setFortaStaking(address newFortaStaking) internal { - if (newFortaStaking== address(0)) revert ZeroAddress("newFortaStaking"); + if (newFortaStaking == address(0)) revert ZeroAddress("newFortaStaking"); _fortaStaking = FortaStaking(newFortaStaking); emit FortaStakingChanged(address(_fortaStaking)); } /** - * Sets stake subject handler stake for subject type. - */ - function setStakeSubjectHandler(uint8 subjectType, IStakeSubject subjectHandler) - external - onlyRole(DEFAULT_ADMIN_ROLE) - onlyValidSubjectType(subjectType) - { + * Sets stake subject handler stake for subject type. + */ + function setStakeSubjectHandler(uint8 subjectType, IStakeSubject subjectHandler) external onlyRole(DEFAULT_ADMIN_ROLE) onlyValidSubjectType(subjectType) { if (address(subjectHandler) == address(0)) revert ZeroAddress("subjectHandler"); emit StakeSubjectHandlerChanged(address(subjectHandler), address(_stakeSubjectHandlers[subjectType])); _stakeSubjectHandlers[subjectType] = subjectHandler; } /// Get max stake for that `subjectType` and `subject`. If not set, will return 0. - function maxStakeFor(uint8 subjectType, uint256 subject) external view returns(uint256) { + function maxStakeFor(uint8 subjectType, uint256 subject) external view returns (uint256) { return _stakeSubjectHandlers[subjectType].getStakeThreshold(subject).max; } /// Get min stake for that `subjectType` and `subject`. If not set, will return 0. - function minStakeFor(uint8 subjectType, uint256 subject) external view returns(uint256) { + function minStakeFor(uint8 subjectType, uint256 subject) external view returns (uint256) { return _stakeSubjectHandlers[subjectType].getStakeThreshold(subject).min; } /// Get if staking is activated for that `subjectType` and `subject`. If not set, will return false. - function isStakeActivatedFor(uint8 subjectType, uint256 subject) external view returns(bool) { + function isStakeActivatedFor(uint8 subjectType, uint256 subject) external view returns (bool) { return _stakeSubjectHandlers[subjectType].getStakeThreshold(subject).activated; } - /// Gets active stake (amount of staked tokens) on `subject` id for `subjectType` + /// Gets active stake (amount of staked tokens) on `subject` id for `subjectType` function activeStakeFor(uint8 subjectType, uint256 subject) external view returns (uint256) { return _fortaStaking.activeStakeFor(subjectType, subject); } + + /// Gets active and inactive stake (amount of staked tokens) on `subject` id for `subjectType` + function totalStakeFor(uint8 subjectType, uint256 subject) external view override returns (uint256) { + return _fortaStaking.activeStakeFor(subjectType, subject) + _fortaStaking.inactiveStakeFor(subjectType, subject); + } + + /// Checks if subject, subjectType is registered + function isRegistered(uint8 subjectType, uint256 subject) external view returns (bool) { + return _stakeSubjectHandlers[subjectType].isRegistered(subject); + } + } \ No newline at end of file diff --git a/contracts/components/staking/ISlashingExecutor.sol b/contracts/components/staking/ISlashingExecutor.sol new file mode 100644 index 00000000..e8613ed6 --- /dev/null +++ b/contracts/components/staking/ISlashingExecutor.sol @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: UNLICENSED +// See Forta Network License: https://github.com/forta-protocol/forta-contracts/blob/master/LICENSE.md + +pragma solidity ^0.8.4; + +interface ISlashingExecutor { + function freeze( + uint8 subjectType, + uint256 subject, + bool frozen + ) external; + + function slash( + uint8 subjectType, + uint256 subject, + uint256 stakeValue, + address proposer, + uint256 proposerPercent + ) external returns (uint256); + + function treasury() external returns (address); +} diff --git a/contracts/components/staking/IStakeController.sol b/contracts/components/staking/IStakeController.sol index 1107389c..77399f0d 100644 --- a/contracts/components/staking/IStakeController.sol +++ b/contracts/components/staking/IStakeController.sol @@ -11,5 +11,7 @@ interface IStakeController { function activeStakeFor(uint8 subjectType, uint256 subject) external view returns(uint256); function maxStakeFor(uint8 subjectType, uint256 subject) external view returns(uint256); function minStakeFor(uint8 subjectType, uint256 subject) external view returns(uint256); + function totalStakeFor(uint8 subjectType, uint256 subject) external view returns(uint256); + function maxSlashableStakePercent() external view returns(uint256); function isStakeActivatedFor(uint8 subjectType, uint256 subject) external view returns(bool); } diff --git a/contracts/components/staking/IStakeSubject.sol b/contracts/components/staking/IStakeSubject.sol index ab800534..e81e2cb4 100644 --- a/contracts/components/staking/IStakeSubject.sol +++ b/contracts/components/staking/IStakeSubject.sol @@ -11,4 +11,5 @@ interface IStakeSubject { } function getStakeThreshold(uint256 subject) external view returns (StakeThreshold memory); function isStakedOverMin(uint256 subject) external view returns (bool); + function isRegistered(uint256 subjectId) external view returns(bool); } \ No newline at end of file diff --git a/contracts/components/staking/SlashReasons.sol b/contracts/components/staking/SlashReasons.sol new file mode 100644 index 00000000..0ab5112e --- /dev/null +++ b/contracts/components/staking/SlashReasons.sol @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: UNLICENSED +// See Forta Network License: https://github.com/forta-protocol/forta-contracts/blob/master/LICENSE.md + +pragma solidity ^0.8.4; + +// These are the identifiers for slash reasons. Update this file if new slash reasons are configured, for +// better reference. + +bytes32 constant OPERATIONAL_SLASH = keccak256("OPERATIONAL_SLASH"); +bytes32 constant MALICIOUS_SUBJECT_SLASH = keccak256("MALICIOUS_SUBJECT_SLASH"); diff --git a/contracts/components/staking/SlashingController.sol b/contracts/components/staking/SlashingController.sol new file mode 100644 index 00000000..620c3c13 --- /dev/null +++ b/contracts/components/staking/SlashingController.sol @@ -0,0 +1,415 @@ +// SPDX-License-Identifier: UNLICENSED +// See Forta Network License: https://github.com/forta-protocol/forta-contracts/blob/master/LICENSE.md + +pragma solidity ^0.8.15; + +import "../BaseComponentUpgradeable.sol"; +import "./SubjectTypes.sol"; +import "./ISlashingExecutor.sol"; +import "./FortaStakingParameters.sol"; +import "../utils/StateMachines.sol"; +import "../../errors/GeneralErrors.sol"; +import "@openzeppelin/contracts/utils/Counters.sol"; +import "@openzeppelin/contracts/utils/math/Math.sol"; +import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; + +contract SlashingController is BaseComponentUpgradeable, StateMachineController, SubjectTypeValidator { + using Counters for Counters.Counter; + using StateMachines for StateMachines.Machine; + + StateMachines.State public constant UNDEFINED = StateMachines.State._00; + StateMachines.State public constant CREATED = StateMachines.State._01; + StateMachines.State public constant REJECTED = StateMachines.State._02; + StateMachines.State public constant DISMISSED = StateMachines.State._03; + StateMachines.State public constant IN_REVIEW = StateMachines.State._04; + StateMachines.State public constant REVIEWED = StateMachines.State._05; + StateMachines.State public constant EXECUTED = StateMachines.State._06; + StateMachines.State public constant REVERTED = StateMachines.State._07; + + enum PenaltyMode { + UNDEFINED, + MIN_STAKE, + CURRENT_STAKE + } + + struct SlashPenalty { + uint256 percentSlashed; + PenaltyMode mode; + } + + struct Deposit { + address proposer; + uint256 amount; + } + + struct Proposal { + uint256 subjectId; + address proposer; + bytes32 penaltyId; + uint8 subjectType; + } + + Counters.Counter private _proposalIds; + mapping(uint256 => Proposal) public proposals; // proposalId --> Proposal + mapping(uint256 => uint256) public deposits; // proposalId --> tokenAmount + mapping(bytes32 => SlashPenalty) public penalties; // penaltyId --> SlashPenalty + ISlashingExecutor public slashingExecutor; + FortaStakingParameters public stakingParameters; + uint256 public depositAmount; + uint256 public slashPercentToProposer; + /// @custom:oz-upgrades-unsafe-allow state-variable-immutable + IERC20 public immutable depositToken; + /// @custom:oz-upgrades-unsafe-allow state-variable-immutable + StateMachines.Machine private immutable _transitiontable; + + //solhint-disable-next-line const-name-snakecase + string public constant version = "0.1.0"; + uint256 public constant MAX_EVIDENCE_LENGTH = 5; + uint256 public constant MAX_CHAR_LENGTH = 200; + + event SlashProposalUpdated( + address indexed updater, + uint256 indexed proposalId, + StateMachines.State indexed stateId, + address proposer, + uint256 subjectId, + uint8 subjectType, + bytes32 penaltyId + ); + event EvidenceSubmitted(uint256 proposalId, StateMachines.State stateId, string[] evidence); + event SlashingExecutorChanged(address indexed slashingExecutor); + event StakingParametersManagerChanged(address indexed stakingParametersManager); + event DepositAmountChanged(uint256 amount); + event SlashPercentToProposerChanged(uint256 amount); + event DepositSubmitted(uint256 indexed proposalId, address indexed proposer, uint256 amount); + event DepositReturned(uint256 indexed proposalId, address indexed proposer, uint256 amount); + event DepositSlashed(uint256 indexed proposalId, address indexed proposer, uint256 amount); + event SlashPenaltyAdded(bytes32 indexed penaltyId, uint256 percentSlashed, PenaltyMode mode); + event SlashPenaltyRemoved(bytes32 indexed penaltyId, uint256 percentSlashed, PenaltyMode mode); + + error WrongSlashPenaltyId(bytes32 penaltyId); + error NonExistentProposal(uint256 proposalId); + error NonRegisteredSubject(uint8 subjectType, uint256 subjectId); + error WrongPercentValue(uint256 value); + + modifier onlyValidSlashPenaltyId(bytes32 penaltyId) { + if (penalties[penaltyId].mode == PenaltyMode.UNDEFINED) revert WrongSlashPenaltyId(penaltyId); + _; + } + + modifier onlyValidPercent(uint256 percent) { + if (percent > 100) revert WrongPercentValue(percent); + _; + } + + /// @custom:oz-upgrades-unsafe-allow constructor + constructor(address _forwarder, address _depositToken) initializer ForwardedContext(_forwarder) { + if (_depositToken == address(0)) revert ZeroAddress("_depositToken"); + depositToken = IERC20(_depositToken); + _transitiontable = StateMachines + .EMPTY_MACHINE + .addEdgeTransition(UNDEFINED, CREATED) + .addEdgeTransition(CREATED, DISMISSED) + .addEdgeTransition(CREATED, REJECTED) + .addEdgeTransition(CREATED, IN_REVIEW) + .addEdgeTransition(IN_REVIEW, REVIEWED) + .addEdgeTransition(IN_REVIEW, REVERTED) + .addEdgeTransition(REVIEWED, EXECUTED) + .addEdgeTransition(REVIEWED, REVERTED); + } + + /** + * @notice Initializer method, access point to initialize inheritance tree. + * @param __manager address of AccessManager. + * @param __router address of Router. + */ + function initialize( + address __manager, + address __router, + ISlashingExecutor __executor, + FortaStakingParameters __stakingParameters, + uint256 __depositAmount, + uint256 __slashPercentToProposer, + bytes32[] calldata __slashPenaltyIds, + SlashPenalty[] calldata __slashPenalties + ) public initializer { + __AccessManaged_init(__manager); + __Routed_init(__router); + __UUPSUpgradeable_init(); + + _setSlashingExecutor(__executor); + _setStakingParametersManager(__stakingParameters); + _setDepositAmount(__depositAmount); + _setSlashPercentToProposer(__slashPercentToProposer); + _setSlashPenalties(__slashPenaltyIds, __slashPenalties); + } + + // Proposal LifeCycle + /** + * @notice Creates a slash proposal pointing to a slashable subject. To do so, the proposer must provide a FORT deposit and present evidence. + * @param _subjectType type of the subject. + * @param _subjectId ERC721 registry id of the stake subject. + * @param _penaltyId if of the SlashPenalty to inflict upon the subject if the proposal goes through. + * @param _evidence IPFS hashes of the evidence files, proof of the subject being slash worthy. + * @return proposalId the proposal identifier. + */ + function proposeSlash( + uint8 _subjectType, + uint256 _subjectId, + bytes32 _penaltyId, + string[] calldata _evidence + ) external onlyValidSlashPenaltyId(_penaltyId) onlyValidSubjectType(_subjectType) returns (uint256 proposalId) { + if (!stakingParameters.isRegistered(_subjectType, _subjectId)) revert NonRegisteredSubject(_subjectType, _subjectId); + if (stakingParameters.totalStakeFor(_subjectType, _subjectId) == 0) revert ZeroAmount("subject stake"); + Proposal memory slashProposal = Proposal(_subjectId, _msgSender(), _penaltyId, _subjectType); + SafeERC20.safeTransferFrom(depositToken, _msgSender(), address(this), depositAmount); + _proposalIds.increment(); + proposalId = _proposalIds.current(); + deposits[proposalId] = depositAmount; + proposals[proposalId] = slashProposal; + emit DepositSubmitted(proposalId, _msgSender(), depositAmount); + _transition(proposalId, CREATED); + emit SlashProposalUpdated(_msgSender(), proposalId, CREATED, slashProposal.proposer, slashProposal.subjectId, slashProposal.subjectType, slashProposal.penaltyId); + _submitEvidence(proposalId, CREATED, _evidence); + slashingExecutor.freeze(_subjectType, _subjectId, true); + return proposalId; + } + + /** + * @notice Arbiter dismisses a slash proposal (the proposal is legitimate, but after investigation, it is not a slashable offense) + * The deposit is returned to the proposer, and the stake of the subject is unfrozen + * @param _proposalId the proposal identifier. + * @param _evidence IPFS hashes of the evidence files, proof of the subject not being slashable. + */ + function dismissSlashProposal(uint256 _proposalId, string[] calldata _evidence) external onlyRole(SLASHING_ARBITER_ROLE) { + _transition(_proposalId, DISMISSED); + _submitEvidence(_proposalId, DISMISSED, _evidence); + _returnDeposit(_proposalId); + slashingExecutor.freeze(proposals[_proposalId].subjectType, proposals[_proposalId].subjectId, false); + } + + /** + * @notice Arbiter rejects a slash proposal, slashing the deposit of the proposer (the proposal is deemed as spam, malicious, or similar) + * and unfreezing the subject's stake. + * @param _proposalId the proposal identifier. + * @param _evidence IPFS hashes of the evidence files, justification for slashing the proposer's deposit. + */ + function rejectSlashProposal(uint256 _proposalId, string[] calldata _evidence) external onlyRole(SLASHING_ARBITER_ROLE) { + _transition(_proposalId, REJECTED); + _submitEvidence(_proposalId, REJECTED, _evidence); + _slashDeposit(_proposalId); + slashingExecutor.freeze(proposals[_proposalId].subjectType, proposals[_proposalId].subjectId, false); + } + + /** + * @notice Arbiter recognizes the report as valid and procceeds to investigate. The deposit is returned to proposer, stake remains frozen. + * @param _proposalId the proposal identifier. + */ + function markAsInReviewSlashProposal(uint256 _proposalId) external onlyRole(SLASHING_ARBITER_ROLE) { + _transition(_proposalId, IN_REVIEW); + if (deposits[_proposalId] == 0) revert ZeroAmount("deposit on _proposalId"); + _returnDeposit(_proposalId); + } + + /** + * @notice After investigation, arbiter updates the proposal's incorrect assumptions. This can only be done if the proposal is IN_REVIEW, and + * presenting evidence for the changes. + * Changing the subject and subjectType will unfreeze the previous target and freeze the new. + * Changing the penalty will affect slashing amounts. + * @param _proposalId the proposal identifier. + * @param _subjectType type of the subject. + * @param _subjectId ERC721 registry id of the stake subject. + * @param _penaltyId if of the SlashPenalty to inflict upon the subject if the proposal goes through. + * @param _evidence IPFS hashes of the evidence files, proof of need for proposal changes. + */ + function reviewSlashProposalParameters( + uint256 _proposalId, + uint8 _subjectType, + uint256 _subjectId, + bytes32 _penaltyId, + string[] calldata _evidence + ) external onlyRole(SLASHING_ARBITER_ROLE) onlyInState(_proposalId, IN_REVIEW) onlyValidSlashPenaltyId(_penaltyId) onlyValidSubjectType(_subjectType) { + // No need to check for proposal existence, onlyInState will revert if _proposalId is in undefined state + if (!stakingParameters.isRegistered(_subjectType, _subjectId)) revert NonRegisteredSubject(_subjectType, _subjectId); + + _submitEvidence(_proposalId, IN_REVIEW, _evidence); + if (_subjectType != proposals[_proposalId].subjectType || _subjectId != proposals[_proposalId].subjectId) { + slashingExecutor.freeze(proposals[_proposalId].subjectType, proposals[_proposalId].subjectId, false); + slashingExecutor.freeze(_subjectType, _subjectId, true); + } + Proposal memory slashProposal = Proposal(_subjectId, proposals[_proposalId].proposer, _penaltyId, _subjectType); + proposals[_proposalId] = slashProposal; + emit SlashProposalUpdated(_msgSender(), _proposalId, IN_REVIEW, slashProposal.proposer, slashProposal.subjectId, slashProposal.subjectType, slashProposal.penaltyId); + } + + /** + * @notice Arbiter marks the proposal as reviewed, so the slasher can execute or revert. + * @param _proposalId the proposal identifier. + */ + function markAsReviewedSlashProposal(uint256 _proposalId) external onlyRole(SLASHING_ARBITER_ROLE) { + _transition(_proposalId, REVIEWED); + } + + /** + * @notice The slashing proposal should not be executed. Stake is unfrozen. + * If the proposal is IN_REVIEW, this can be executed by the SLASHING_ARBITER_ROLE. + * If the proposal is REVIEWED, this can be executed by the SLASHER_ROLE. + * @param _proposalId the proposal identifier. + * @param _evidence IPFS hashes of the evidence files, proof of the slash being not valid. + */ + function revertSlashProposal(uint256 _proposalId, string[] calldata _evidence) external { + _authorizeRevertSlashProposal(_proposalId); + _transition(_proposalId, REVERTED); + _submitEvidence(_proposalId, REVERTED, _evidence); + slashingExecutor.freeze(proposals[_proposalId].subjectType, proposals[_proposalId].subjectId, false); + } + + /** + * @notice The slashing proposal is executed. Subject's stake is slashed and unfrozen. + * The proposer gets a % of the slashed stake as defined by slashPercentToProposer. + * Only executable by SLASHER_ROLE + * @param _proposalId the proposal identifier. + */ + function executeSlashProposal(uint256 _proposalId) external onlyRole(SLASHER_ROLE) { + _transition(_proposalId, EXECUTED); + Proposal memory proposal = proposals[_proposalId]; + slashingExecutor.slash(proposal.subjectType, proposal.subjectId, getSlashedStakeValue(_proposalId), proposal.proposer, slashPercentToProposer); + slashingExecutor.freeze(proposal.subjectType, proposal.subjectId, false); + } + + // Penalty calculation (ISlashingController) + + /** + * @notice gets the stake amount to be slashed. + * The amount deppends on the StakePenalty. + * In all cases, the amount will be the minimum of the max slashable stake for the subject and: + * MIN_STAKE: a % of the subject's MIN_STAKE + * CURRENT_STAKE: a % of the subject's active + inactive stake. + */ + function getSlashedStakeValue(uint256 _proposalId) public view returns (uint256) { + Proposal memory proposal = proposals[_proposalId]; + SlashPenalty memory penalty = penalties[proposal.penaltyId]; + uint256 totalStake = stakingParameters.totalStakeFor(proposal.subjectType, proposal.subjectId); + uint256 max = Math.mulDiv(totalStake, stakingParameters.maxSlashableStakePercent(), 100); + if (penalty.mode == PenaltyMode.UNDEFINED) { + return 0; + } else if (penalty.mode == PenaltyMode.MIN_STAKE) { + return Math.min(max, Math.mulDiv(stakingParameters.minStakeFor(proposal.subjectType, proposal.subjectId), penalty.percentSlashed, 100)); + } else if (penalty.mode == PenaltyMode.CURRENT_STAKE) { + return Math.min(max, Math.mulDiv(totalStake, penalty.percentSlashed, 100)); + } + return 0; + } + + // Gets the subjectType and subjectId for a proposalId + function getSubject(uint256 _proposalId) external view returns (uint8 subjectType, uint256 subject) { + return (proposals[_proposalId].subjectType, proposals[_proposalId].subjectId); + } + + // Gets the proposer of a proposalId + function getProposer(uint256 _proposalId) external view returns (address) { + return proposals[_proposalId].proposer; + } + + // Admin methods + function setSlashingExecutor(ISlashingExecutor _executor) external onlyRole(STAKING_ADMIN_ROLE) { + _setSlashingExecutor(_executor); + } + + function setStakingParametersManager(FortaStakingParameters _stakingParameters) external onlyRole(STAKING_ADMIN_ROLE) { + _setStakingParametersManager(_stakingParameters); + } + + function setDepositAmount(uint256 _amount) external onlyRole(STAKING_ADMIN_ROLE) { + _setDepositAmount(_amount); + } + + function setSlashPercentToProposer(uint256 _amount) external onlyRole(STAKING_ADMIN_ROLE) { + _setSlashPercentToProposer(_amount); + } + + function setSlashPenalties(bytes32[] calldata _slashReasons, SlashPenalty[] calldata _slashPenalties) external onlyRole(STAKING_ADMIN_ROLE) { + _setSlashPenalties(_slashReasons, _slashPenalties); + } + + // Private validations + + function _authorizeRevertSlashProposal(uint256 _proposalId) private view { + bytes32 requiredRole = currentState(_proposalId) == IN_REVIEW ? SLASHING_ARBITER_ROLE : SLASHER_ROLE; + if (!hasRole(requiredRole, _msgSender())) { + revert MissingRole(requiredRole, _msgSender()); + } + // If it's in another state, _transition() will revert + } + + // Private param setting + + function _setSlashingExecutor(ISlashingExecutor _executor) private { + if (address(_executor) == address(0)) revert ZeroAddress("_executor"); + slashingExecutor = _executor; + emit SlashingExecutorChanged(address(_executor)); + } + + function _setStakingParametersManager(FortaStakingParameters _stakingParameters) private { + if (address(_stakingParameters) == address(0)) revert ZeroAddress("_stakingParameters"); + stakingParameters = _stakingParameters; + emit StakingParametersManagerChanged(address(_stakingParameters)); + } + + function _setDepositAmount(uint256 _amount) private { + if (_amount == 0) revert ZeroAmount("_amount"); + depositAmount = _amount; + emit DepositAmountChanged(depositAmount); + } + + function _setSlashPercentToProposer(uint256 _amount) private onlyValidPercent(_amount) { + slashPercentToProposer = _amount; + emit SlashPercentToProposerChanged(_amount); + } + + function _setSlashPenalties(bytes32[] calldata _slashReasons, SlashPenalty[] calldata _slashPenalties) private { + uint256 length = _slashReasons.length; + if (length != _slashPenalties.length) revert DifferentLenghtArray("_slashReasons", "_slashPenalties"); + for (uint256 i = 0; i < length; i++) { + if (penalties[_slashReasons[i]].mode != PenaltyMode.UNDEFINED) { + emit SlashPenaltyRemoved(_slashReasons[i], penalties[_slashReasons[i]].percentSlashed, penalties[_slashReasons[i]].mode); + } + penalties[_slashReasons[i]] = _slashPenalties[i]; + emit SlashPenaltyAdded(_slashReasons[i], _slashPenalties[i].percentSlashed, _slashPenalties[i].mode); + } + } + + // Evidence handling + function _submitEvidence( + uint256 _proposalId, + StateMachines.State _stateId, + string[] calldata _evidence + ) private { + uint256 evidenceLength = _evidence.length; + if (evidenceLength == 0) revert ZeroAmount("evidence lenght"); + if (evidenceLength > MAX_EVIDENCE_LENGTH) revert ArrayTooBig(evidenceLength, MAX_EVIDENCE_LENGTH); + for (uint256 i = 0; i < evidenceLength; i++) { + if (bytes(_evidence[i]).length > MAX_CHAR_LENGTH) revert StringTooLarge(bytes(_evidence[i]).length, MAX_CHAR_LENGTH); + } + emit EvidenceSubmitted(_proposalId, _stateId, _evidence); + } + + // Private deposit handling + function _returnDeposit(uint256 _proposalId) private { + uint256 amount = deposits[_proposalId]; + delete deposits[_proposalId]; + SafeERC20.safeTransfer(depositToken, proposals[_proposalId].proposer, amount); + emit DepositReturned(_proposalId, proposals[_proposalId].proposer, amount); + } + + function _slashDeposit(uint256 _proposalId) private { + uint256 amount = deposits[_proposalId]; + delete deposits[_proposalId]; + SafeERC20.safeTransfer(depositToken, slashingExecutor.treasury(), amount); + emit DepositSlashed(_proposalId, proposals[_proposalId].proposer, amount); + } + + function transitionTable() public view virtual override returns (StateMachines.Machine) { + return _transitiontable; + } +} diff --git a/contracts/components/utils/StateMachines.sol b/contracts/components/utils/StateMachines.sol new file mode 100644 index 00000000..2882e3c8 --- /dev/null +++ b/contracts/components/utils/StateMachines.sol @@ -0,0 +1,75 @@ +//SPDX-License-Identifier: MIT +pragma solidity ^0.8.15; + +/** + * Library to handle Finite State Machines and codify their transitions in a uint256. + * NOTE: the number of states is limited to 16. + * Rewritten by Hadrien Croubois, https://github.com/Amxx + */ +library StateMachines { + type Machine is uint256; + + Machine internal constant EMPTY_MACHINE = Machine.wrap(0); + + enum State { + _00, _01, _02, _03, + _04, _05, _06, _07, + _08, _09, _10, _11, + _12, _13, _14, _15 + } + + function statesToEdge(State fromState, State toState) internal pure returns (uint256) { + return 1 << (uint8(toState) * 16 + uint8(fromState)); + } + + function isTransitionValid(Machine self, State fromState, State toState) internal pure returns (bool) { + return Machine.unwrap(self) & statesToEdge(fromState, toState) != 0; + } + + function addEdgeTransition(Machine self, State fromState, State toState) internal pure returns (Machine newMachine) { + return Machine.wrap(Machine.unwrap(self) | statesToEdge(fromState, toState)); + } + + function removeEdgeTransition(Machine self, State fromState, State toState) internal pure returns (Machine newMachine) { + return Machine.wrap(Machine.unwrap(self) & ~statesToEdge(fromState, toState)); + } +} + +/** + * Contract that allows for the creation and management of finite state machines. + * The state machines will transition following a commonly defined state set. + * What each state and state transition means, as well as the business logic of defining a valid transition + * are left to the inheriting contract. + */ +abstract contract StateMachineController { + using StateMachines for StateMachines.Machine; + + event StateTransition(uint256 indexed machineId, StateMachines.State indexed fromState, StateMachines.State indexed toState); + error InvalidState(StateMachines.State state); + error InvalidStateTransition(StateMachines.State fromState, StateMachines.State toState); + + mapping(uint256 => StateMachines.State) private _machines; + + modifier onlyInState(uint256 _machineId, StateMachines.State _state) { + if (_state != _machines[_machineId]) revert InvalidState(_state); + _; + } + + function transitionTable() virtual public view returns(StateMachines.Machine); + + function _transition(uint256 _machineId, StateMachines.State _newState) internal { + if (!transitionTable().isTransitionValid(_machines[_machineId], _newState)) revert InvalidStateTransition(_machines[_machineId], _newState); + emit StateTransition(_machineId, _machines[_machineId], _newState); + _machines[_machineId] = _newState; + } + + /** + * Checks the current state of a machine. + * @param _machineId the identifier of a machine. + * @return current state identifier. + */ + function currentState(uint256 _machineId) public view returns (StateMachines.State) { + return _machines[_machineId]; + } +} + diff --git a/contracts/errors/GeneralErrors.sol b/contracts/errors/GeneralErrors.sol index 59958d9e..9d850ca0 100644 --- a/contracts/errors/GeneralErrors.sol +++ b/contracts/errors/GeneralErrors.sol @@ -6,7 +6,12 @@ pragma solidity ^0.8.4; error ZeroAddress(string name); error ZeroAmount(string name); error EmptyArray(string name); +error EmptyString(string name); error UnorderedArray(string name); +error DifferentLenghtArray(string array1, string array2); +error ArrayTooBig(uint256 lenght, uint256 max); +error StringTooLarge(uint256 length, uint256 max); + error UnsupportedInterface(string name); error SenderNotOwner(address sender, uint256 ownedId); diff --git a/scripts/deploy-platform.js b/scripts/deploy-platform.js index 6715dd87..9ad4fa93 100644 --- a/scripts/deploy-platform.js +++ b/scripts/deploy-platform.js @@ -77,6 +77,36 @@ const DELAY = { 8001: utils.durationToSeconds('10 minutes'), }; +const TREASURY = (chainId, deployer) => { + switch (chainId) { + case 8001: + case 31337: + return deployer.address; + default: + throw new Error('Treasury not configured for chainId: ', chainId); + } +}; + +const SLASH_PERCENT_TO_PROPOSER = (chainId) => { + switch (chainId) { + case 8001: + case 31337: + return '10'; + default: + throw new Error('SLASH_PERCENT_TO_PROPOSER not configured for chainId: ', chainId); + } +}; + +const SLASHING_DEPOSIT_AMOUNT = (chainId) => { + switch (chainId) { + case 8001: + case 31337: + return ethers.utils.parseEther('1000'); + default: + throw new Error('SLASHING_DEPOSIT_AMOUNT not configured for chainId: ', chainId); + } +}; + /********************************************************************************************************************* * Migration workflow * *********************************************************************************************************************/ @@ -101,6 +131,8 @@ async function migrate(config = {}) { config.childChainManagerProxy = config.childChainManagerProxy ?? CHILD_CHAIN_MANAGER_PROXY[chainId]; config.chainsToDeploy = config.chainsToDeploy ?? ['L1', 'L2']; const contracts = {}; + const slashParams = {}; + const hardhatDeployment = chainId === 31337; contracts.forwarder = await ethers.getContractFactory('Forwarder', deployer).then((factory) => utils.tryFetchContract(CACHE, 'forwarder', factory, [])); @@ -138,10 +170,17 @@ async function migrate(config = {}) { DEBUG(`[4] router: ${contracts.router.address}`); contracts.staking = await ethers.getContractFactory('FortaStaking', deployer).then((factory) => - utils.tryFetchProxy(CACHE, 'staking', factory, 'uups', [contracts.access.address, contracts.router.address, contracts.token.address, delay, deployer.address], { - constructorArgs: [contracts.forwarder.address], - unsafeAllow: ['delegatecall'], - }) + utils.tryFetchProxy( + CACHE, + 'staking', + factory, + 'uups', + [contracts.access.address, contracts.router.address, contracts.token.address, delay, TREASURY(chainId, deployer)], + { + constructorArgs: [contracts.forwarder.address], + unsafeAllow: ['delegatecall'], + } + ) ); DEBUG(`[5.0] staking: ${contracts.staking.address}`); @@ -262,6 +301,46 @@ async function migrate(config = {}) { ); DEBUG(`[9] scanner node version: ${contracts.scannerNodeVersion.address}`); + const penaltyModes = {}; + penaltyModes.UNDEFINED = 0; + penaltyModes.MIN_STAKE = 1; + penaltyModes.CURRENT_STAKE = 2; + const reasons = {}; + reasons.OPERATIONAL_SLASH = ethers.utils.id('OPERATIONAL_SLASH'); + reasons.MALICIOUS_SUBJECT_SLASH = ethers.utils.id('MALICIOUS_SUBJECT_SLASH'); + + const penalties = {}; + penalties[reasons.OPERATIONAL_SLASH] = { mode: penaltyModes.MIN_STAKE, percentSlashed: '15' }; + penalties[reasons.MALICIOUS_SUBJECT_SLASH] = { mode: penaltyModes.CURRENT_STAKE, percentSlashed: '90' }; + const reasonIds = Object.keys(reasons).map((reason) => reasons[reason]); + + contracts.slashing = await ethers.getContractFactory('SlashingController', deployer).then((factory) => + utils.tryFetchProxy( + CACHE, + 'slashing', + factory, + 'uups', + [ + contracts.access.address, + contracts.router.address, + contracts.staking.address, + contracts.stakingParameters.address, + SLASHING_DEPOSIT_AMOUNT(chainId), + SLASH_PERCENT_TO_PROPOSER(chainId), + reasonIds, + Object.keys(reasons).map((reason) => penalties[reasons[reason]]), + ], + { + constructorArgs: [contracts.forwarder.address, contracts.token.address], + unsafeAllow: 'delegatecall', + } + ) + ); + slashParams.penaltyModes = penaltyModes; + slashParams.reasons = reasons; + slashParams.penalties = penalties; + DEBUG(`[10] slashing proposal: ${contracts.slashing.address}`); + } // Roles dictionary @@ -279,6 +358,8 @@ async function migrate(config = {}) { SCANNER_ADMIN: ethers.utils.id('SCANNER_ADMIN_ROLE'), DISPATCHER: ethers.utils.id('DISPATCHER_ROLE'), SLASHER: ethers.utils.id('SLASHER_ROLE'), + SLASHING_ARBITER: ethers.utils.id('SLASHING_ARBITER_ROLE'), + STAKING_ADMIN: ethers.utils.id('STAKING_ADMIN_ROLE'), SWEEPER: ethers.utils.id('SWEEPER_ROLE'), REWARDS_ADMIN: ethers.utils.id('REWARDS_ADMIN_ROLE'), SCANNER_VERSION: ethers.utils.id('SCANNER_VERSION_ROLE'), @@ -329,6 +410,7 @@ async function migrate(config = {}) { registerNode('forwarder.forta.eth', deployer.address, { ...contracts.ens, resolved: contracts.forwarder.address, chainId: chainId }), registerNode('router.forta.eth', deployer.address, { ...contracts.ens, resolved: contracts.router.address, chainId: chainId }), registerNode('staking.forta.eth', deployer.address, { ...contracts.ens, resolved: contracts.staking.address, chainId: chainId }), + registerNode('slashing.forta.eth', deployer.address, { ...contracts.ens, resolved: contracts.staking.address, chainId: chainId }), registerNode('staking-params.forta.eth', deployer.address, { ...contracts.ens, resolved: contracts.stakingParameters.address, chainId: chainId }), registerNode('agents.registries.forta.eth', deployer.address, { ...contracts.ens, resolved: contracts.agents.address, chainId: chainId }), registerNode('scanners.registries.forta.eth', deployer.address, { ...contracts.ens, resolved: contracts.scanners.address, chainId: chainId }), @@ -353,6 +435,7 @@ async function migrate(config = {}) { reverseRegister(contracts.router, 'router.forta.eth'), reverseRegister(contracts.dispatch, 'dispatch.forta.eth'), reverseRegister(contracts.staking, 'staking.forta.eth'), + reverseRegister(contracts.slashing, 'slashing.forta.eth'), reverseRegister(contracts.stakingParameters, 'staking-params.forta.eth'), reverseRegister(contracts.agents, 'agents.registries.forta.eth'), reverseRegister(contracts.scanners, 'scanners.registries.forta.eth'), @@ -372,6 +455,7 @@ async function migrate(config = {}) { deployer, contracts, roles, + slashParams, }; } diff --git a/test/components/agents.test.js b/test/components/agents.test.js index 1f1a9a79..816f8321 100644 --- a/test/components/agents.test.js +++ b/test/components/agents.test.js @@ -40,7 +40,7 @@ describe('Agent Registry', function () { expect( await this.agents .getAgent(AGENT_ID) - .then((agent) => [agent.created, agent.owner, agent.agentVersion.toNumber(), agent.metadata, agent.chainIds.map((chainId) => chainId.toNumber())]) + .then((agent) => [agent.registered, agent.owner, agent.agentVersion.toNumber(), agent.metadata, agent.chainIds.map((chainId) => chainId.toNumber())]) ).to.be.deep.equal([false, ethers.constants.AddressZero, 0, '', []]); }); @@ -78,12 +78,12 @@ describe('Agent Registry', function () { .withArgs(ethers.constants.AddressZero, this.accounts.user1.address, AGENT_ID) .to.emit(this.agents, 'AgentUpdated') .withArgs(AGENT_ID, this.accounts.other.address, 'Metadata1', [1, 3, 4, 5]); - expect(await this.agents.isCreated(AGENT_ID)).to.be.equal(true); + expect(await this.agents.isRegistered(AGENT_ID)).to.be.equal(true); expect(await this.agents.ownerOf(AGENT_ID)).to.be.equal(this.accounts.user1.address); expect( await this.agents .getAgent(AGENT_ID) - .then((agent) => [agent.created, agent.owner, agent.agentVersion.toNumber(), agent.metadata, agent.chainIds.map((chainId) => chainId.toNumber())]) + .then((agent) => [agent.registered, agent.owner, agent.agentVersion.toNumber(), agent.metadata, agent.chainIds.map((chainId) => chainId.toNumber())]) ).to.be.deep.equal([true, this.accounts.user1.address, 1, args[2], args[3]]); expect(await this.agents.getAgentCount()).to.be.equal('1'); expect(await this.agents.getAgentCountByChain(1)).to.be.equal('1'); @@ -136,7 +136,7 @@ describe('Agent Registry', function () { expect( await this.agents .getAgent(AGENT_ID) - .then((agent) => [agent.created, agent.owner, agent.agentVersion.toNumber(), agent.metadata, agent.chainIds.map((chainId) => chainId.toNumber())]) + .then((agent) => [agent.registered, agent.owner, agent.agentVersion.toNumber(), agent.metadata, agent.chainIds.map((chainId) => chainId.toNumber())]) ).to.be.deep.equal([true, this.accounts.user1.address, 1, 'Metadata1', [1, 3, 4]]); expect(await this.agents.getAgentCount()).to.be.equal('1'); expect(await this.agents.getAgentCountByChain(1)).to.be.equal('1'); @@ -157,7 +157,7 @@ describe('Agent Registry', function () { expect( await this.agents .getAgent(AGENT_ID) - .then((agent) => [agent.created, agent.owner, agent.agentVersion.toNumber(), agent.metadata, agent.chainIds.map((chainId) => chainId.toNumber())]) + .then((agent) => [agent.registered, agent.owner, agent.agentVersion.toNumber(), agent.metadata, agent.chainIds.map((chainId) => chainId.toNumber())]) ).to.be.deep.equal([true, this.accounts.user1.address, 2, 'Metadata2', [1, 4, 5]]); expect(await this.agents.getAgentCount()).to.be.equal('1'); expect(await this.agents.getAgentCountByChain(1)).to.be.equal('1'); diff --git a/test/components/slashing.test.js b/test/components/slashing.test.js new file mode 100644 index 00000000..e4daf6ca --- /dev/null +++ b/test/components/slashing.test.js @@ -0,0 +1,681 @@ +const { ethers } = require('hardhat'); +const { parseEther, id } = ethers.utils; +const { expect } = require('chai'); +const { prepare } = require('../fixture'); +const { BigNumber } = require('ethers'); + +const SUBJECT_1_ADDRESS = '0x727E5FCcb9e2367555373e90E637500BCa5Da40c'; +const subjects = [ + { id: ethers.BigNumber.from(SUBJECT_1_ADDRESS), type: 0 }, // Scanner id, scanner type + { id: ethers.BigNumber.from(ethers.utils.id('135a782d-c263-43bd-b70b-920873ed7e9d')), type: 1 }, // Agent id, agent type +]; + +const MAX_STAKE = parseEther('10000'); +const MIN_STAKE = parseEther('100'); +const STAKING_DEPOSIT = parseEther('1000'); + +let slashTreasuryAddress, proposerPercent; + +const STATES = { + UNDEFINED: BigNumber.from('0'), + CREATED: BigNumber.from('1'), + REJECTED: BigNumber.from('2'), + DISMISSED: BigNumber.from('3'), + IN_REVIEW: BigNumber.from('4'), + REVIEWED: BigNumber.from('5'), + EXECUTED: BigNumber.from('6'), + REVERTED: BigNumber.from('7'), +}; + +const EVIDENCE_FOR_STATE = (state) => { + switch (state) { + case STATES.CREATED: + return ['CREATED evidence', '2']; + case STATES.REJECTED: + return ['REJECTED evidence', '2']; + case STATES.DISMISSED: + return ['DISMISSED evidence', '2']; + case STATES.REVIEWED: + return ['REVIEWED evidence', '2']; + case STATES.REVERTED: + return ['REVERTED evidence', '2']; + case STATES.IN_REVIEW: + return ['IN_REVIEW evidence', 'modifying']; + default: + throw new Error(`No need of evidence for ${state.toString()}`); + } +}; + +const PROPOSAL_ID = BigNumber.from('1'); + +describe('Slashing Proposals', function () { + prepare({ stake: { min: MIN_STAKE, max: MAX_STAKE, activated: true } }); + + beforeEach(async function () { + await this.token.connect(this.accounts.whitelister).grantRole(this.roles.WHITELIST, this.accounts.user1.address); + await this.token.connect(this.accounts.whitelister).grantRole(this.roles.WHITELIST, this.accounts.user2.address); + await this.token.connect(this.accounts.whitelister).grantRole(this.roles.WHITELIST, this.accounts.user3.address); + await this.token.connect(this.accounts.whitelister).grantRole(this.roles.WHITELIST, this.accounts.minter.address); + + await this.access.connect(this.accounts.admin).grantRole(this.roles.SLASHING_ARBITER, this.accounts.user3.address); + await this.access.connect(this.accounts.admin).grantRole(this.roles.SLASHER, this.accounts.admin.address); + + await this.token.connect(this.accounts.minter).mint(this.accounts.user1.address, parseEther('100000')); + await this.token.connect(this.accounts.minter).mint(this.accounts.user2.address, parseEther('100000')); + await this.token.connect(this.accounts.minter).mint(this.accounts.user3.address, parseEther('100000')); + + await this.token.connect(this.accounts.user1).approve(this.slashing.address, ethers.constants.MaxUint256); + await this.token.connect(this.accounts.user2).approve(this.slashing.address, ethers.constants.MaxUint256); + await this.token.connect(this.accounts.user3).approve(this.slashing.address, ethers.constants.MaxUint256); + + await this.token.connect(this.accounts.user1).approve(this.staking.address, ethers.constants.MaxUint256); + await this.token.connect(this.accounts.user2).approve(this.staking.address, ethers.constants.MaxUint256); + await this.token.connect(this.accounts.user3).approve(this.staking.address, ethers.constants.MaxUint256); + + await this.scanners.connect(this.accounts.manager).adminRegister(SUBJECT_1_ADDRESS, this.accounts.user2.address, 1, 'metadata'); + const args = [subjects[1].id, this.accounts.user1.address, 'Metadata1', [1, 3, 4, 5]]; + await this.agents.connect(this.accounts.other).createAgent(...args); + + await this.staking.connect(this.accounts.user2).deposit(0, SUBJECT_1_ADDRESS, STAKING_DEPOSIT); + await this.staking.connect(this.accounts.user2).deposit(1, subjects[1].id, STAKING_DEPOSIT); + + slashTreasuryAddress = await this.staking.treasury(); + proposerPercent = await this.slashing.slashPercentToProposer(); + }); + + describe('Correct Proposal Lifecycle', function () { + it('From CREATED to EXECUTED', async function () { + const initialDepositorBalance = await this.token.balanceOf(this.accounts.user2.address); + const initialTreasuryBalance = await this.token.balanceOf(slashTreasuryAddress); + + await expect( + this.slashing + .connect(this.accounts.user2) + .proposeSlash(subjects[0].type, subjects[0].id, this.slashParams.reasons.OPERATIONAL_SLASH, EVIDENCE_FOR_STATE(STATES.CREATED)) + ) + .to.emit(this.slashing, 'SlashProposalUpdated') + .withArgs( + this.accounts.user2.address, + PROPOSAL_ID, + STATES.CREATED, + this.accounts.user2.address, + subjects[0].id, + subjects[0].type, + this.slashParams.reasons.OPERATIONAL_SLASH + ) + .to.emit(this.slashing, 'DepositSubmitted') + .withArgs(PROPOSAL_ID, STAKING_DEPOSIT) + .to.emit(this.slashing, 'EvidenceSubmitted') + .withArgs(PROPOSAL_ID, STATES.CREATED, EVIDENCE_FOR_STATE(STATES.CREATED)) + .to.emit(this.slashing, 'MachineCreated') + .withArgs(PROPOSAL_ID, STATES.CREATED) + .to.emit(this.slashing, 'StateTransition') + .withArgs(PROPOSAL_ID, STATES.UNDEFINED, STATES.CREATED) + .to.emit(this.token, 'Transfer') + .withArgs(this.accounts.user2.address, this.slashing.address, STAKING_DEPOSIT) + .to.emit(this.staking, 'Froze') + .withArgs(subjects[0].type, subjects[0].id, this.slashing.address, true); + + expect(await this.staking.isFrozen(subjects[0].type, subjects[0].id)).to.eq(true); + expect(await this.token.balanceOf(this.accounts.user2.address)).to.eq(initialDepositorBalance.sub(STAKING_DEPOSIT)); + expect(await this.token.balanceOf(this.slashing.address)).to.eq(STAKING_DEPOSIT); + expect(await this.slashing.currentState(PROPOSAL_ID)).to.eq(STATES.CREATED); + + await expect(this.slashing.connect(this.accounts.user3).markAsInReviewSlashProposal(PROPOSAL_ID)) + .to.emit(this.slashing, 'DepositReturned') + .withArgs(PROPOSAL_ID, this.accounts.user2.address, STAKING_DEPOSIT) + .to.emit(this.slashing, 'StateTransition') + .withArgs(PROPOSAL_ID, STATES.CREATED, STATES.IN_REVIEW) + .to.emit(this.token, 'Transfer') + .withArgs(this.slashing.address, this.accounts.user2.address, STAKING_DEPOSIT); + + expect(await this.token.balanceOf(this.accounts.user2.address)).to.eq(initialDepositorBalance); + expect(await this.token.balanceOf(this.slashing.address)).to.eq(BigNumber.from('0')); + expect(await this.slashing.currentState(PROPOSAL_ID)).to.eq(STATES.IN_REVIEW); + + await expect(this.slashing.connect(this.accounts.user3).markAsReviewedSlashProposal(PROPOSAL_ID)) + .to.emit(this.slashing, 'StateTransition') + .withArgs(PROPOSAL_ID, STATES.IN_REVIEW, STATES.REVIEWED); + + expect(await this.slashing.currentState(PROPOSAL_ID)).to.eq(STATES.REVIEWED); + + const slashedAmount = parseEther('15'); + const proposerShare = slashedAmount.mul(proposerPercent).div('100'); + const treasuryShare = slashedAmount.sub(proposerShare); + + await expect(this.slashing.connect(this.accounts.admin).executeSlashProposal(PROPOSAL_ID)) + .to.emit(this.slashing, 'StateTransition') + .withArgs(PROPOSAL_ID, STATES.REVIEWED, STATES.EXECUTED) + .to.emit(this.staking, 'Froze') + .withArgs(subjects[0].type, subjects[0].id, this.slashing.address, false) + .to.emit(this.staking, 'Slashed') + .withArgs(subjects[0].type, subjects[0].id, this.slashing.address, parseEther('15')) + .to.emit(this.token, 'Transfer') + .withArgs(this.staking.address, slashTreasuryAddress, treasuryShare) + .to.emit(this.token, 'Transfer') + .withArgs(this.staking.address, this.accounts.user2.address, proposerShare); + expect(await this.token.balanceOf(this.accounts.user2.address)).to.eq(initialDepositorBalance.add(proposerShare)); + expect(await this.token.balanceOf(slashTreasuryAddress)).to.eq(initialTreasuryBalance.add(treasuryShare)); + expect(await this.staking.isFrozen(subjects[0].type, subjects[0].id)).to.eq(false); + }); + + it('From CREATED to EXECUTED, modifying the proposal', async function () { + const initialDepositorBalance = await this.token.balanceOf(this.accounts.user2.address); + const initialTreasuryBalance = await this.token.balanceOf(slashTreasuryAddress); + + this.slashing + .connect(this.accounts.user2) + .proposeSlash(subjects[0].type, subjects[0].id, this.slashParams.reasons.OPERATIONAL_SLASH, EVIDENCE_FOR_STATE(STATES.CREATED)); + expect(await this.token.balanceOf(this.accounts.user2.address)).to.eq(initialDepositorBalance.sub(STAKING_DEPOSIT)); + + this.slashing.connect(this.accounts.user3).markAsInReviewSlashProposal(PROPOSAL_ID); + + expect(await this.token.balanceOf(this.accounts.user2.address)).to.eq(initialDepositorBalance); + expect(await this.token.balanceOf(this.slashing.address)).to.eq(BigNumber.from('0')); + expect(await this.slashing.currentState(PROPOSAL_ID)).to.eq(STATES.IN_REVIEW); + + // Modifying + const EVIDENCE_CHANGE_SUBJECT1 = ['EVIDENCE_CHANGE_SUBJECT1']; + const EVIDENCE_CHANGE_SUBJECT2 = ['EVIDENCE_CHANGE_SUBJECT2']; + // Subject + + const oldProposal = await this.slashing.proposals(PROPOSAL_ID); + await expect( + this.slashing + .connect(this.accounts.user3) + .reviewSlashProposalParameters(PROPOSAL_ID, subjects[1].type, subjects[1].id, oldProposal.penaltyId, EVIDENCE_CHANGE_SUBJECT1) + ) + .to.emit(this.slashing, 'EvidenceSubmitted') + .withArgs(PROPOSAL_ID, STATES.IN_REVIEW, EVIDENCE_CHANGE_SUBJECT1) + .to.emit(this.staking, 'Froze') + .withArgs(subjects[0].type, subjects[0].id, this.slashing.address, false) + .to.emit(this.staking, 'Froze') + .withArgs(subjects[1].type, subjects[1].id, this.slashing.address, true) + .to.emit(this.slashing, 'SlashProposalUpdated') + .withArgs( + this.accounts.user3.address, + PROPOSAL_ID, + STATES.IN_REVIEW, + this.accounts.user2.address, + subjects[1].id, + subjects[1].type, + this.slashParams.reasons.OPERATIONAL_SLASH + ); + const subject = await this.slashing.getSubject(PROPOSAL_ID); + expect(subject.subjectType).to.eq(subjects[1].type); + expect(subject.subject).to.eq(subjects[1].id); + + expect(await this.staking.isFrozen(subjects[1].type, subjects[1].id)).to.eq(true); + expect(await this.staking.isFrozen(subjects[0].type, subjects[0].id)).to.eq(false); + + // Penalty + await expect( + this.slashing + .connect(this.accounts.user3) + .reviewSlashProposalParameters(PROPOSAL_ID, subjects[1].type, subjects[1].id, this.slashParams.reasons.MALICIOUS_SUBJECT_SLASH, EVIDENCE_CHANGE_SUBJECT2) + ) + .to.emit(this.slashing, 'EvidenceSubmitted') + .withArgs(PROPOSAL_ID, STATES.IN_REVIEW, EVIDENCE_CHANGE_SUBJECT2) + .to.emit(this.slashing, 'SlashProposalUpdated') + .withArgs( + this.accounts.user3.address, + PROPOSAL_ID, + STATES.IN_REVIEW, + this.accounts.user2.address, + subjects[1].id, + subjects[1].type, + this.slashParams.reasons.MALICIOUS_SUBJECT_SLASH + ); + + const newProposal = await this.slashing.proposals(PROPOSAL_ID); + expect(newProposal.penaltyId).to.eq(this.slashParams.reasons.MALICIOUS_SUBJECT_SLASH); + + // Continue + await expect(this.slashing.connect(this.accounts.user3).markAsReviewedSlashProposal(PROPOSAL_ID)) + .to.emit(this.slashing, 'StateTransition') + .withArgs(PROPOSAL_ID, STATES.IN_REVIEW, STATES.REVIEWED); + + expect(await this.slashing.currentState(PROPOSAL_ID)).to.eq(STATES.REVIEWED); + + const slashedAmount = parseEther('900'); + const proposerShare = slashedAmount.mul(proposerPercent).div('100'); + const treasuryShare = slashedAmount.sub(proposerShare); + + await expect(this.slashing.connect(this.accounts.admin).executeSlashProposal(PROPOSAL_ID)) + .to.emit(this.slashing, 'StateTransition') + .withArgs(PROPOSAL_ID, STATES.REVIEWED, STATES.EXECUTED) + .to.emit(this.staking, 'Froze') + .withArgs(subjects[1].type, subjects[1].id, this.slashing.address, false) + .to.emit(this.staking, 'Slashed') + .withArgs(subjects[1].type, subjects[1].id, this.slashing.address, parseEther('900')) + .to.emit(this.token, 'Transfer') + .withArgs(this.staking.address, slashTreasuryAddress, treasuryShare) + .to.emit(this.token, 'Transfer') + .withArgs(this.staking.address, this.accounts.user2.address, proposerShare); + expect(await this.token.balanceOf(this.accounts.user2.address)).to.eq(initialDepositorBalance.add(proposerShare)); + expect(await this.token.balanceOf(slashTreasuryAddress)).to.eq(initialTreasuryBalance.add(treasuryShare)); + expect(await this.staking.isFrozen(subjects[1].type, subjects[1].id)).to.eq(false); + }); + + it('From CREATED to REJECTED', async function () { + const initialDepositorBalance = await this.token.balanceOf(this.accounts.user2.address); + const initialTreasuryBalance = await this.token.balanceOf(slashTreasuryAddress); + + await expect( + this.slashing + .connect(this.accounts.user2) + .proposeSlash(subjects[0].type, subjects[0].id, this.slashParams.reasons.OPERATIONAL_SLASH, EVIDENCE_FOR_STATE(STATES.CREATED)) + ) + .to.emit(this.slashing, 'SlashProposalUpdated') + .withArgs( + this.accounts.user2.address, + PROPOSAL_ID, + STATES.CREATED, + this.accounts.user2.address, + subjects[0].id, + subjects[0].type, + this.slashParams.reasons.OPERATIONAL_SLASH + ) + .to.emit(this.slashing, 'DepositSubmitted') + .withArgs(PROPOSAL_ID, this.accounts.user2.address, STAKING_DEPOSIT) + .to.emit(this.slashing, 'EvidenceSubmitted') + .withArgs(PROPOSAL_ID, STATES.CREATED, EVIDENCE_FOR_STATE(STATES.CREATED)) + .to.emit(this.slashing, 'MachineCreated') + .withArgs(PROPOSAL_ID, STATES.CREATED) + .to.emit(this.slashing, 'StateTransition') + .withArgs(PROPOSAL_ID, STATES.UNDEFINED, STATES.CREATED) + .to.emit(this.token, 'Transfer') + .withArgs(this.accounts.user2.address, this.slashing.address, STAKING_DEPOSIT) + .to.emit(this.staking, 'Froze') + .withArgs(subjects[0].type, subjects[0].id, this.slashing.address, true); + + expect(await this.staking.isFrozen(subjects[0].type, subjects[0].id)).to.eq(true); + expect(await this.token.balanceOf(this.accounts.user2.address)).to.eq(initialDepositorBalance.sub(STAKING_DEPOSIT)); + expect(await this.token.balanceOf(this.slashing.address)).to.eq(STAKING_DEPOSIT); + expect(await this.slashing.currentState(PROPOSAL_ID)).to.eq(STATES.CREATED); + + await expect(this.slashing.connect(this.accounts.user3).rejectSlashProposal(PROPOSAL_ID, EVIDENCE_FOR_STATE(STATES.REJECTED))) + .to.emit(this.slashing, 'DepositSlashed') + .withArgs(PROPOSAL_ID, this.accounts.user2.address, STAKING_DEPOSIT) + .to.emit(this.slashing, 'StateTransition') + .withArgs(PROPOSAL_ID, STATES.CREATED, STATES.REJECTED) + .to.emit(this.slashing, 'EvidenceSubmitted') + .withArgs(PROPOSAL_ID, STATES.REJECTED, EVIDENCE_FOR_STATE(STATES.REJECTED)) + .to.emit(this.token, 'Transfer') + .withArgs(this.slashing.address, slashTreasuryAddress, STAKING_DEPOSIT); + + expect(await this.token.balanceOf(this.accounts.user2.address)).to.eq(initialDepositorBalance.sub(STAKING_DEPOSIT)); + expect(await this.slashing.currentState(PROPOSAL_ID)).to.eq(STATES.REJECTED); + expect(await this.token.balanceOf(slashTreasuryAddress)).to.eq(initialTreasuryBalance.add(STAKING_DEPOSIT)); + expect(await this.staking.isFrozen(subjects[0].type, subjects[0].id)).to.eq(false); + }); + + it('From CREATED to DISMISSED', async function () { + const initialDepositorBalance = await this.token.balanceOf(this.accounts.user2.address); + const initialTreasuryBalance = await this.token.balanceOf(slashTreasuryAddress); + + await this.slashing + .connect(this.accounts.user2) + .proposeSlash(subjects[0].type, subjects[0].id, this.slashParams.reasons.OPERATIONAL_SLASH, EVIDENCE_FOR_STATE(STATES.CREATED)); + + expect(await this.staking.isFrozen(subjects[0].type, subjects[0].id)).to.eq(true); + expect(await this.token.balanceOf(this.accounts.user2.address)).to.eq(initialDepositorBalance.sub(STAKING_DEPOSIT)); + expect(await this.token.balanceOf(this.slashing.address)).to.eq(STAKING_DEPOSIT); + expect(await this.slashing.currentState(PROPOSAL_ID)).to.eq(STATES.CREATED); + + await expect(this.slashing.connect(this.accounts.user3).dismissSlashProposal(PROPOSAL_ID, EVIDENCE_FOR_STATE(STATES.DISMISSED))) + .to.emit(this.slashing, 'DepositReturned') + .withArgs(PROPOSAL_ID, this.accounts.user2.address, STAKING_DEPOSIT) + .to.emit(this.slashing, 'StateTransition') + .withArgs(PROPOSAL_ID, STATES.CREATED, STATES.DISMISSED) + .to.emit(this.slashing, 'EvidenceSubmitted') + .withArgs(PROPOSAL_ID, STATES.DISMISSED, EVIDENCE_FOR_STATE(STATES.DISMISSED)) + .to.emit(this.token, 'Transfer') + .withArgs(this.slashing.address, this.accounts.user2.address, STAKING_DEPOSIT); + + expect(await this.token.balanceOf(this.accounts.user2.address)).to.eq(initialDepositorBalance); + expect(await this.slashing.currentState(PROPOSAL_ID)).to.eq(STATES.DISMISSED); + expect(await this.token.balanceOf(slashTreasuryAddress)).to.eq(initialTreasuryBalance); + expect(await this.staking.isFrozen(subjects[0].type, subjects[0].id)).to.eq(false); + }); + + it('From CREATED to REVERTED by Arbiter', async function () { + const initialDepositorBalance = await this.token.balanceOf(this.accounts.user2.address); + const initialTreasuryBalance = await this.token.balanceOf(slashTreasuryAddress); + + await this.slashing + .connect(this.accounts.user2) + .proposeSlash(subjects[0].type, subjects[0].id, this.slashParams.reasons.OPERATIONAL_SLASH, EVIDENCE_FOR_STATE(STATES.CREATED)); + + expect(await this.staking.isFrozen(subjects[0].type, subjects[0].id)).to.eq(true); + expect(await this.token.balanceOf(this.accounts.user2.address)).to.eq(initialDepositorBalance.sub(STAKING_DEPOSIT)); + expect(await this.token.balanceOf(this.slashing.address)).to.eq(STAKING_DEPOSIT); + expect(await this.slashing.currentState(PROPOSAL_ID)).to.eq(STATES.CREATED); + + await expect(this.slashing.connect(this.accounts.user3).markAsInReviewSlashProposal(PROPOSAL_ID)) + .to.emit(this.slashing, 'DepositReturned') + .withArgs(PROPOSAL_ID, this.accounts.user2.address, STAKING_DEPOSIT) + .to.emit(this.slashing, 'StateTransition') + .withArgs(PROPOSAL_ID, STATES.CREATED, STATES.IN_REVIEW) + .to.emit(this.token, 'Transfer') + .withArgs(this.slashing.address, this.accounts.user2.address, STAKING_DEPOSIT); + + await expect(this.slashing.connect(this.accounts.user3).revertSlashProposal(PROPOSAL_ID, EVIDENCE_FOR_STATE(STATES.REVERTED))) + .to.emit(this.slashing, 'StateTransition') + .withArgs(PROPOSAL_ID, STATES.IN_REVIEW, STATES.REVERTED) + .to.emit(this.slashing, 'EvidenceSubmitted') + .withArgs(PROPOSAL_ID, STATES.REVERTED, EVIDENCE_FOR_STATE(STATES.REVERTED)); + + expect(await this.token.balanceOf(this.accounts.user2.address)).to.eq(initialDepositorBalance); + expect(await this.slashing.currentState(PROPOSAL_ID)).to.eq(STATES.REVERTED); + expect(await this.token.balanceOf(slashTreasuryAddress)).to.eq(initialTreasuryBalance); + expect(await this.staking.isFrozen(subjects[0].type, subjects[0].id)).to.eq(false); + }); + + it('From CREATED to REVERTED by Slasher', async function () { + const initialDepositorBalance = await this.token.balanceOf(this.accounts.user2.address); + const initialTreasuryBalance = await this.token.balanceOf(slashTreasuryAddress); + + await this.slashing + .connect(this.accounts.user2) + .proposeSlash(subjects[0].type, subjects[0].id, this.slashParams.reasons.OPERATIONAL_SLASH, EVIDENCE_FOR_STATE(STATES.CREATED)); + + expect(await this.staking.isFrozen(subjects[0].type, subjects[0].id)).to.eq(true); + expect(await this.token.balanceOf(this.accounts.user2.address)).to.eq(initialDepositorBalance.sub(STAKING_DEPOSIT)); + expect(await this.token.balanceOf(this.slashing.address)).to.eq(STAKING_DEPOSIT); + expect(await this.slashing.currentState(PROPOSAL_ID)).to.eq(STATES.CREATED); + + await expect(this.slashing.connect(this.accounts.user3).markAsInReviewSlashProposal(PROPOSAL_ID)) + .to.emit(this.slashing, 'DepositReturned') + .withArgs(PROPOSAL_ID, this.accounts.user2.address, STAKING_DEPOSIT) + .to.emit(this.slashing, 'StateTransition') + .withArgs(PROPOSAL_ID, STATES.CREATED, STATES.IN_REVIEW) + .to.emit(this.token, 'Transfer') + .withArgs(this.slashing.address, this.accounts.user2.address, STAKING_DEPOSIT); + + await expect(this.slashing.connect(this.accounts.user3).markAsReviewedSlashProposal(PROPOSAL_ID)) + .to.emit(this.slashing, 'StateTransition') + .withArgs(PROPOSAL_ID, STATES.IN_REVIEW, STATES.REVIEWED); + + await expect(this.slashing.connect(this.accounts.admin).revertSlashProposal(PROPOSAL_ID, EVIDENCE_FOR_STATE(STATES.REVERTED))) + .to.emit(this.slashing, 'StateTransition') + .withArgs(PROPOSAL_ID, STATES.REVIEWED, STATES.REVERTED) + .to.emit(this.slashing, 'EvidenceSubmitted') + .withArgs(PROPOSAL_ID, STATES.REVERTED, EVIDENCE_FOR_STATE(STATES.REVERTED)); + + expect(await this.token.balanceOf(this.accounts.user2.address)).to.eq(initialDepositorBalance); + expect(await this.slashing.currentState(PROPOSAL_ID)).to.eq(STATES.REVERTED); + expect(await this.token.balanceOf(slashTreasuryAddress)).to.eq(initialTreasuryBalance); + expect(await this.staking.isFrozen(subjects[0].type, subjects[0].id)).to.eq(false); + }); + }); + describe('State configuration', function () { + + it('should not have incorrect state transtions', async function () { + await this.slashing + .connect(this.accounts.user2) + .proposeSlash(subjects[0].type, subjects[0].id, this.slashParams.reasons.OPERATIONAL_SLASH, EVIDENCE_FOR_STATE(STATES.CREATED)); + await expect(this.slashing.connect(this.accounts.user3).markAsReviewedSlashProposal(PROPOSAL_ID)).to.be.revertedWith(`InvalidStateTransition(1, 5)`); + }); + }); + + describe('Proposal lifecycle wrong auths', function () { + it('should not move from CREATED if not authorized', async function () { + this.slashing + .connect(this.accounts.user2) + .proposeSlash(subjects[0].type, subjects[0].id, this.slashParams.reasons.OPERATIONAL_SLASH, EVIDENCE_FOR_STATE(STATES.CREATED)); + await expect(this.slashing.connect(this.accounts.user2).markAsInReviewSlashProposal(PROPOSAL_ID)).to.be.revertedWith( + `MissingRole("${id('SLASHING_ARBITER_ROLE')}", "${this.accounts.user2.address}")` + ); + await expect(this.slashing.connect(this.accounts.user2).dismissSlashProposal(PROPOSAL_ID, EVIDENCE_FOR_STATE(STATES.DISMISSED))).to.be.revertedWith( + `MissingRole("${id('SLASHING_ARBITER_ROLE')}", "${this.accounts.user2.address}")` + ); + await expect(this.slashing.connect(this.accounts.user2).rejectSlashProposal(PROPOSAL_ID, EVIDENCE_FOR_STATE(STATES.REJECTED))).to.be.revertedWith( + `MissingRole("${id('SLASHING_ARBITER_ROLE')}", "${this.accounts.user2.address}")` + ); + }); + + it('should not move from IN_REVIEW if not authorized', async function () { + await this.slashing + .connect(this.accounts.user2) + .proposeSlash(subjects[0].type, subjects[0].id, this.slashParams.reasons.OPERATIONAL_SLASH, EVIDENCE_FOR_STATE(STATES.CREATED)); + await this.slashing.connect(this.accounts.user3).markAsInReviewSlashProposal(PROPOSAL_ID); + await expect(this.slashing.connect(this.accounts.user2).markAsReviewedSlashProposal(PROPOSAL_ID)).to.be.revertedWith( + `MissingRole("${id('SLASHING_ARBITER_ROLE')}", "${this.accounts.user2.address}")` + ); + await expect(this.slashing.connect(this.accounts.user2).revertSlashProposal(PROPOSAL_ID, EVIDENCE_FOR_STATE(STATES.REVERTED))).to.be.revertedWith( + `MissingRole("${id('SLASHING_ARBITER_ROLE')}", "${this.accounts.user2.address}")` + ); + }); + + it('should not move from REVIEWED if not authorized', async function () { + await this.slashing + .connect(this.accounts.user2) + .proposeSlash(subjects[0].type, subjects[0].id, this.slashParams.reasons.OPERATIONAL_SLASH, EVIDENCE_FOR_STATE(STATES.CREATED)); + await this.slashing.connect(this.accounts.user3).markAsInReviewSlashProposal(PROPOSAL_ID); + await this.slashing.connect(this.accounts.user3).markAsReviewedSlashProposal(PROPOSAL_ID); + await expect(this.slashing.connect(this.accounts.user2).executeSlashProposal(PROPOSAL_ID)).to.be.revertedWith( + `MissingRole("${id('SLASHER_ROLE')}", "${this.accounts.user2.address}")` + ); + await expect(this.slashing.connect(this.accounts.user2).revertSlashProposal(PROPOSAL_ID, EVIDENCE_FOR_STATE(STATES.REVERTED))).to.be.revertedWith( + `MissingRole("${id('SLASHER_ROLE')}", "${this.accounts.user2.address}")` + ); + }); + }); + + describe('Proposal creation conditions', function () { + it('should not propose if proposer does not have deposit', async function () { + await this.token.connect(this.accounts.user2).transfer(this.accounts.user3.address, await this.token.balanceOf(this.accounts.user2.address)); + await expect( + this.slashing + .connect(this.accounts.user2) + .proposeSlash(subjects[0].type, subjects[0].id, this.slashParams.reasons.OPERATIONAL_SLASH, EVIDENCE_FOR_STATE(STATES.CREATED)) + ).to.be.revertedWith('ERC20: transfer amount exceeds balance'); + }); + + it('should not propose if subject is not registered', async function () { + await expect( + this.slashing + .connect(this.accounts.user2) + .proposeSlash(subjects[1].type, subjects[0].id, this.slashParams.reasons.OPERATIONAL_SLASH, EVIDENCE_FOR_STATE(STATES.CREATED)) + ).to.be.revertedWith('NonRegisteredSubject'); + }); + + it('should not propose if proposal has empty evidence', async function () { + await expect( + this.slashing.connect(this.accounts.user2).proposeSlash(subjects[0].type, subjects[0].id, this.slashParams.reasons.OPERATIONAL_SLASH, []) + ).to.be.revertedWith('ZeroAmount("evidence lenght")'); + }); + + it('should not propose if proposal if evidence string too large', async function () { + const longString = new Array(201).fill('+').reduce((prev, next) => prev + next, ''); + await expect( + this.slashing.connect(this.accounts.user2).proposeSlash(subjects[0].type, subjects[0].id, this.slashParams.reasons.OPERATIONAL_SLASH, [longString]) + ).to.be.revertedWith('StringTooLarge(201, 200)'); + }); + + it('should not propose if proposal if evidence string too large', async function () { + await expect( + this.slashing + .connect(this.accounts.user2) + .proposeSlash(subjects[0].type, subjects[0].id, this.slashParams.reasons.OPERATIONAL_SLASH, ['1', '2', '3', '4', '5', '6']) + ).to.be.revertedWith('ArrayTooBig(6, 5)'); + }); + + it('should not propose if proposal has invalid subject type', async function () { + await expect( + this.slashing.connect(this.accounts.user2).proposeSlash(123, subjects[0].id, this.slashParams.reasons.OPERATIONAL_SLASH, EVIDENCE_FOR_STATE(STATES.CREATED)) + ).to.be.revertedWith('InvalidSubjectType'); + }); + }); + + describe('Review modification conditions', function () { + it('should not modify if proposal nonexistent', async function () { + await this.slashing + .connect(this.accounts.user2) + .proposeSlash(subjects[0].type, subjects[0].id, this.slashParams.reasons.OPERATIONAL_SLASH, EVIDENCE_FOR_STATE(STATES.CREATED)); + await this.slashing.connect(this.accounts.user3).markAsInReviewSlashProposal(PROPOSAL_ID); + await expect( + this.slashing + .connect(this.accounts.user3) + .reviewSlashProposalParameters('2', subjects[0].type, subjects[0].id, this.slashParams.reasons.OPERATIONAL_SLASH, EVIDENCE_FOR_STATE(STATES.IN_REVIEW)) + ).to.be.revertedWith('InvalidState(4)'); + }); + + it('should not modify if not in state', async function () { + await expect( + this.slashing + .connect(this.accounts.user3) + .reviewSlashProposalParameters(PROPOSAL_ID, subjects[0].type, subjects[0].id, this.slashParams.reasons.OPERATIONAL_SLASH, EVIDENCE_FOR_STATE(STATES.IN_REVIEW)) + ).to.be.revertedWith('InvalidState(4)'); + }); + + it('should not modify if subject is not registered', async function () { + await this.slashing + .connect(this.accounts.user2) + .proposeSlash(subjects[0].type, subjects[0].id, this.slashParams.reasons.OPERATIONAL_SLASH, EVIDENCE_FOR_STATE(STATES.CREATED)); + await this.slashing.connect(this.accounts.user3).markAsInReviewSlashProposal(PROPOSAL_ID); + await expect( + this.slashing + .connect(this.accounts.user3) + .reviewSlashProposalParameters(PROPOSAL_ID, subjects[1].type, subjects[0].id, this.slashParams.reasons.OPERATIONAL_SLASH, EVIDENCE_FOR_STATE(STATES.IN_REVIEW)) + ).to.be.revertedWith('NonRegisteredSubject'); + }); + it('should not modify if caller is not authorized', async function () { + await this.slashing + .connect(this.accounts.user2) + .proposeSlash(subjects[0].type, subjects[0].id, this.slashParams.reasons.OPERATIONAL_SLASH, EVIDENCE_FOR_STATE(STATES.CREATED)); + await this.slashing.connect(this.accounts.user3).markAsInReviewSlashProposal(PROPOSAL_ID); + await expect( + this.slashing + .connect(this.accounts.user2) + .reviewSlashProposalParameters(PROPOSAL_ID, subjects[0].type, subjects[0].id, this.slashParams.reasons.OPERATIONAL_SLASH, EVIDENCE_FOR_STATE(STATES.IN_REVIEW)) + ).to.be.revertedWith(`MissingRole("${id('SLASHING_ARBITER_ROLE')}", "${this.accounts.user2.address}")`); + }); + + it('should not modify if proposal has empty evidence', async function () { + await this.slashing + .connect(this.accounts.user2) + .proposeSlash(subjects[0].type, subjects[0].id, this.slashParams.reasons.OPERATIONAL_SLASH, EVIDENCE_FOR_STATE(STATES.IN_REVIEW)); + await this.slashing.connect(this.accounts.user3).markAsInReviewSlashProposal(PROPOSAL_ID); + await expect( + this.slashing + .connect(this.accounts.user3) + .reviewSlashProposalParameters(PROPOSAL_ID, subjects[0].type, subjects[0].id, this.slashParams.reasons.OPERATIONAL_SLASH, []) + ).to.be.revertedWith('ZeroAmount("evidence lenght")'); + }); + + it('should not modify if proposal has invalid subject type', async function () { + await this.slashing + .connect(this.accounts.user2) + .proposeSlash(subjects[0].type, subjects[0].id, this.slashParams.reasons.OPERATIONAL_SLASH, EVIDENCE_FOR_STATE(STATES.IN_REVIEW)); + await this.slashing.connect(this.accounts.user3).markAsInReviewSlashProposal(PROPOSAL_ID); + await expect( + this.slashing + .connect(this.accounts.user3) + .reviewSlashProposalParameters(PROPOSAL_ID, 65, subjects[0].id, this.slashParams.reasons.OPERATIONAL_SLASH, EVIDENCE_FOR_STATE(STATES.IN_REVIEW)) + ).to.be.revertedWith('InvalidSubjectType'); + }); + }); + describe('Proposal dismissal conditions', function () { + it('should not dismiss without evidence', async function () { + await this.slashing + .connect(this.accounts.user2) + .proposeSlash(subjects[0].type, subjects[0].id, this.slashParams.reasons.OPERATIONAL_SLASH, EVIDENCE_FOR_STATE(STATES.CREATED)); + await expect(this.slashing.connect(this.accounts.user3).dismissSlashProposal(PROPOSAL_ID, [])).to.be.revertedWith('ZeroAmount("evidence lenght")'); + }); + }); + describe('Proposal rejection conditions', function () { + it('should not reject without evidence', async function () { + await this.slashing + .connect(this.accounts.user2) + .proposeSlash(subjects[0].type, subjects[0].id, this.slashParams.reasons.OPERATIONAL_SLASH, EVIDENCE_FOR_STATE(STATES.CREATED)); + + await expect(this.slashing.connect(this.accounts.user3).rejectSlashProposal(PROPOSAL_ID, [])).to.be.revertedWith('ZeroAmount("evidence lenght")'); + }); + }); + + describe('Proposal revert conditions', function () { + it('should not revert without evidence', async function () { + await this.slashing + .connect(this.accounts.user2) + .proposeSlash(subjects[0].type, subjects[0].id, this.slashParams.reasons.OPERATIONAL_SLASH, EVIDENCE_FOR_STATE(STATES.CREATED)); + + await expect(this.slashing.connect(this.accounts.user3).dismissSlashProposal(PROPOSAL_ID, [])).to.be.revertedWith('ZeroAmount("evidence lenght")'); + }); + }); + describe('Slashing amounts', function () { + beforeEach(async function () { + const slashReasons = [ethers.utils.id('MIN_STAKE'), ethers.utils.id('MAX_POSSIBLE'), ethers.utils.id('CURRENT_STAKE')]; + const slashPenalties = [ + { mode: this.slashParams.penaltyModes.MIN_STAKE, percentSlashed: '10' }, + { mode: this.slashParams.penaltyModes.CURRENT_STAKE, percentSlashed: '95' }, + { mode: this.slashParams.penaltyModes.CURRENT_STAKE, percentSlashed: '30' }, + ]; + await this.slashing.connect(this.accounts.admin).setSlashPenalties(slashReasons, slashPenalties); + }); + + it('min stake', async function () { + // All active stake + await this.staking.connect(this.accounts.user2).deposit(subjects[1].type, subjects[1].id, STAKING_DEPOSIT); + await this.slashing.connect(this.accounts.user2).proposeSlash(subjects[0].type, subjects[0].id, ethers.utils.id('MIN_STAKE'), EVIDENCE_FOR_STATE(STATES.CREATED)); + expect(await this.slashing.getSlashedStakeValue('1')).to.eq(MIN_STAKE.mul('10').div('100')); + await this.slashing.connect(this.accounts.user3).dismissSlashProposal('1', EVIDENCE_FOR_STATE(STATES.DISMISSED)); + + // Mix active and inactive stake + await this.staking.connect(this.accounts.user2).initiateWithdrawal(subjects[1].type, subjects[1].id, MIN_STAKE.div(2)); + await this.slashing.connect(this.accounts.user2).proposeSlash(subjects[0].type, subjects[0].id, ethers.utils.id('MIN_STAKE'), EVIDENCE_FOR_STATE(STATES.CREATED)); + expect(await this.slashing.getSlashedStakeValue('2')).to.eq(MIN_STAKE.mul('10').div('100')); + await this.slashing.connect(this.accounts.user3).dismissSlashProposal('2', EVIDENCE_FOR_STATE(STATES.DISMISSED)); + + // All inactive stake + await this.staking.connect(this.accounts.user2).initiateWithdrawal(subjects[1].type, subjects[1].id, MIN_STAKE.div(2)); + await this.slashing.connect(this.accounts.user2).proposeSlash(subjects[0].type, subjects[0].id, ethers.utils.id('MIN_STAKE'), EVIDENCE_FOR_STATE(STATES.CREATED)); + expect(await this.slashing.getSlashedStakeValue('3')).to.eq(MIN_STAKE.mul('10').div('100')); + }); + + it('max possible stake', async function () { + const maxSlashableStakePercent = await this.stakingParameters.maxSlashableStakePercent(); + const totalStake = await this.stakingParameters.totalStakeFor(subjects[0].type, subjects[0].id); + const maxSlashable = totalStake.mul(maxSlashableStakePercent).div('100'); + + // All active stake + await this.staking.connect(this.accounts.user2).deposit(subjects[1].type, subjects[1].id, MIN_STAKE); + await this.slashing.connect(this.accounts.user2).proposeSlash(subjects[0].type, subjects[0].id, ethers.utils.id('MAX_POSSIBLE'), EVIDENCE_FOR_STATE(STATES.CREATED)); + expect(await this.slashing.getSlashedStakeValue('1')).to.eq(maxSlashable); + await this.slashing.connect(this.accounts.user3).dismissSlashProposal('1', EVIDENCE_FOR_STATE(STATES.DISMISSED)); + + // Mix active and inactive stake + await this.staking.connect(this.accounts.user2).initiateWithdrawal(subjects[1].type, subjects[1].id, MIN_STAKE.div(2)); + await this.slashing.connect(this.accounts.user2).proposeSlash(subjects[0].type, subjects[0].id, ethers.utils.id('MAX_POSSIBLE'), EVIDENCE_FOR_STATE(STATES.CREATED)); + expect(await this.slashing.getSlashedStakeValue('2')).to.eq(maxSlashable); + await this.slashing.connect(this.accounts.user3).dismissSlashProposal('2', EVIDENCE_FOR_STATE(STATES.DISMISSED)); + + // All inactive stake + await this.staking.connect(this.accounts.user2).initiateWithdrawal(subjects[1].type, subjects[1].id, MIN_STAKE.div(2)); + await this.slashing.connect(this.accounts.user2).proposeSlash(subjects[0].type, subjects[0].id, ethers.utils.id('MAX_POSSIBLE'), EVIDENCE_FOR_STATE(STATES.CREATED)); + expect(await this.slashing.getSlashedStakeValue('3')).to.eq(maxSlashable.toString()); + }); + + it('current stake', async function () { + // All active stake + await this.staking.connect(this.accounts.user2).deposit(subjects[1].type, subjects[1].id, STAKING_DEPOSIT); + await this.slashing.connect(this.accounts.user2).proposeSlash(subjects[0].type, subjects[0].id, ethers.utils.id('CURRENT_STAKE'), EVIDENCE_FOR_STATE(STATES.CREATED)); + expect(await this.slashing.getSlashedStakeValue('1')).to.eq(STAKING_DEPOSIT.mul('30').div('100')); + await this.slashing.connect(this.accounts.user3).dismissSlashProposal('1', EVIDENCE_FOR_STATE(STATES.DISMISSED)); + + // Mix active and inactive stake + await this.staking.connect(this.accounts.user2).initiateWithdrawal(subjects[1].type, subjects[1].id, STAKING_DEPOSIT.div(2)); + await this.slashing.connect(this.accounts.user2).proposeSlash(subjects[0].type, subjects[0].id, ethers.utils.id('CURRENT_STAKE'), EVIDENCE_FOR_STATE(STATES.CREATED)); + expect(await this.slashing.getSlashedStakeValue('2')).to.eq(STAKING_DEPOSIT.mul('30').div('100')); + await this.slashing.connect(this.accounts.user3).dismissSlashProposal('2', EVIDENCE_FOR_STATE(STATES.DISMISSED)); + + // All inactive stake + await this.staking.connect(this.accounts.user2).initiateWithdrawal(subjects[1].type, subjects[1].id, STAKING_DEPOSIT.div(2)); + await this.slashing.connect(this.accounts.user2).proposeSlash(subjects[0].type, subjects[0].id, ethers.utils.id('CURRENT_STAKE'), EVIDENCE_FOR_STATE(STATES.CREATED)); + expect(await this.slashing.getSlashedStakeValue('3')).to.eq(STAKING_DEPOSIT.mul('30').div('100')); + }); + }); + + describe('Parameter setting', function () {}); +}); diff --git a/test/components/staking.test.js b/test/components/staking.test.js index 6e8645d7..ebdee5c3 100644 --- a/test/components/staking.test.js +++ b/test/components/staking.test.js @@ -1,6 +1,7 @@ -const { ethers, upgrades, network } = require('hardhat'); +const { ethers, network } = require('hardhat'); const { expect } = require('chai'); const { prepare } = require('../fixture'); +const { deploy } = require('../../scripts/utils'); const { subjectToActive, subjectToInactive } = require('../../scripts/utils/staking.js'); const SUBJECT_1_ADDRESS = '0x727E5FCcb9e2367555373e90E637500BCa5Da40c'; @@ -584,7 +585,7 @@ describe('Forta Staking', function () { describe('Freezing', function () { beforeEach(async function () { this.accounts.getAccount('slasher'); - await expect(this.access.connect(this.accounts.admin).grantRole(this.roles.SLASHER, this.accounts.slasher.address)).to.be.not.reverted; + await this.access.connect(this.accounts.admin).grantRole(this.roles.SLASHER, this.accounts.slasher.address); await expect(this.token.connect(this.accounts.whitelister).grantRole(this.roles.WHITELIST, this.accounts.slasher.address)).to.be.not.reverted; }); @@ -620,8 +621,35 @@ describe('Forta Staking', function () { describe('Slashing', function () { beforeEach(async function () { this.accounts.getAccount('slasher'); - await expect(this.access.connect(this.accounts.admin).grantRole(this.roles.SLASHER, this.accounts.slasher.address)).to.be.not.reverted; - await expect(this.token.connect(this.accounts.whitelister).grantRole(this.roles.WHITELIST, this.accounts.slasher.address)).to.be.not.reverted; + await this.access.connect(this.accounts.admin).grantRole(this.roles.SLASHER, this.accounts.slasher.address); + await this.token.connect(this.accounts.whitelister).grantRole(this.roles.WHITELIST, this.accounts.slasher.address); + + }); + + it('slashing split shares', async function () { + await expect(this.staking.connect(this.accounts.user1).deposit(subjectType1, subject1, '100')).to.be.not.reverted; + await expect(this.staking.connect(this.accounts.user2).deposit(subjectType1, subject1, '50')).to.be.not.reverted; + + expect(await this.staking.activeStakeFor(subjectType1, subject1)).to.be.equal('150'); + expect(await this.staking.totalActiveStake()).to.be.equal('150'); + expect(await this.staking.sharesOf(subjectType1, subject1, this.accounts.user1.address)).to.be.equal('100'); + expect(await this.staking.sharesOf(subjectType1, subject1, this.accounts.user2.address)).to.be.equal('50'); + expect(await this.staking.totalShares(subjectType1, subject1)).to.be.equal('150'); + + const balanceOfTreasury = await this.token.balanceOf(this.accounts.treasure.address); + const balanceOfSlasher = await this.token.balanceOf(this.accounts.slasher.address); + await expect(this.staking.connect(this.accounts.slasher).slash(subjectType1, subject1, '30', this.accounts.slasher.address, '50')) + .to.emit(this.staking, 'Slashed') + .withArgs(subjectType1, subject1, this.accounts.slasher.address, '30') + .to.emit(this.staking, 'SlashedShareSent') + .withArgs(subjectType1, subject1, this.accounts.slasher.address, '15') + .to.emit(this.token, 'Transfer') + .withArgs(this.staking.address, this.accounts.slasher.address, '15') + .to.emit(this.token, 'Transfer') + .withArgs(this.staking.address, this.accounts.treasure.address, '15'); + + expect(await this.token.balanceOf(this.accounts.treasure.address)).to.eq(balanceOfTreasury.add('15')); + expect(await this.token.balanceOf(this.accounts.slasher.address)).to.eq(balanceOfSlasher.add('15')); }); it('slashing → withdraw', async function () { @@ -634,7 +662,9 @@ describe('Forta Staking', function () { expect(await this.staking.sharesOf(subjectType1, subject1, this.accounts.user2.address)).to.be.equal('50'); expect(await this.staking.totalShares(subjectType1, subject1)).to.be.equal('150'); - await expect(this.staking.connect(this.accounts.slasher).slash(subjectType1, subject1, '30')) + await expect(this.staking.connect(this.accounts.slasher).slash(subjectType1, subject1, '30', ethers.constants.AddressZero, '0')) + .to.emit(this.staking, 'Slashed') + .withArgs(subjectType1, subject1, this.accounts.slasher.address, '30') .to.emit(this.token, 'Transfer') .withArgs(this.staking.address, this.accounts.treasure.address, '30'); @@ -677,7 +707,9 @@ describe('Forta Staking', function () { expect(await this.staking.sharesOf(subjectType1, subject1, this.accounts.user2.address)).to.be.equal('50'); expect(await this.staking.totalShares(subjectType1, subject1)).to.be.equal('150'); - await expect(this.staking.connect(this.accounts.slasher).slash(subjectType1, subject1, '30')) + await expect(this.staking.connect(this.accounts.slasher).slash(subjectType1, subject1, '30', ethers.constants.AddressZero, '0')) + .to.emit(this.staking, 'Slashed') + .withArgs(subjectType1, subject1, this.accounts.slasher.address, '30') .to.emit(this.token, 'Transfer') .withArgs(this.staking.address, this.accounts.treasure.address, '30'); @@ -745,7 +777,9 @@ describe('Forta Staking', function () { expect(await this.staking.balanceOf(this.accounts.user1.address, inactive1)).to.be.equal('100'); expect(await this.staking.balanceOf(this.accounts.user2.address, inactive1)).to.be.equal('50'); - await expect(this.staking.connect(this.accounts.slasher).slash(subjectType1, subject1, '120')) + await expect(this.staking.connect(this.accounts.slasher).slash(subjectType1, subject1, '120', ethers.constants.AddressZero, '0')) + .to.emit(this.staking, 'Slashed') + .withArgs(subjectType1, subject1, this.accounts.slasher.address, '120') .to.emit(this.token, 'Transfer') .withArgs(this.staking.address, this.accounts.treasure.address, '120'); @@ -774,16 +808,16 @@ describe('Forta Staking', function () { beforeEach(async function () { this.accounts.getAccount('slasher'); this.accounts.getAccount('sweeper'); - await expect(this.access.connect(this.accounts.admin).grantRole(this.roles.SLASHER, this.accounts.slasher.address)).to.be.not.reverted; await expect(this.access.connect(this.accounts.admin).grantRole(this.roles.SWEEPER, this.accounts.sweeper.address)).to.be.not.reverted; await expect(this.token.connect(this.accounts.whitelister).grantRole(this.roles.WHITELIST, this.accounts.sweeper.address)).to.be.not.reverted; await expect(this.otherToken.connect(this.accounts.whitelister).grantRole(this.roles.WHITELIST, this.accounts.sweeper.address)).to.be.not.reverted; + await this.access.connect(this.accounts.admin).grantRole(this.roles.SLASHER, this.accounts.slasher.address); await expect(this.staking.connect(this.accounts.user1).deposit(subjectType1, subject1, '100')).to.be.not.reverted; await expect(this.staking.connect(this.accounts.user1).initiateWithdrawal(subjectType1, subject1, '50')).to.be.not.reverted; await expect(this.staking.connect(this.accounts.user2).deposit(subjectType1, subject1, '100')).to.be.not.reverted; await expect(this.staking.connect(this.accounts.user3).reward(subjectType1, subject1, '100')); - await expect(this.staking.connect(this.accounts.slasher).slash(subjectType1, subject1, '120')).to.be.not.reverted; + await expect(this.staking.connect(this.accounts.slasher).slash(subjectType1, subject1, '120', ethers.constants.AddressZero, '0')).to.be.not.reverted; }); it('sweep unrelated token', async function () { diff --git a/test/components/upgrades.test.js b/test/components/upgrades.test.js index 3047399f..638203e7 100644 --- a/test/components/upgrades.test.js +++ b/test/components/upgrades.test.js @@ -9,7 +9,7 @@ describe('Upgrades testing', function () { prepare(); describe('Agent Registry', async function () { - it(' 0.1.1 -> 0.1.3', async function () { + it.skip(' 0.1.1 -> 0.1.4', async function () { const AgentRegistry_0_1_1 = await ethers.getContractFactory('AgentRegistry_0_1_1'); originalAgents = await upgrades.deployProxy(AgentRegistry_0_1_1, [this.contracts.access.address, this.contracts.router.address, 'Forta Agents', 'FAgents'], { constructorArgs: [this.contracts.forwarder.address], diff --git a/test/fixture.js b/test/fixture.js index 5b1d526b..07c4370d 100644 --- a/test/fixture.js +++ b/test/fixture.js @@ -42,6 +42,8 @@ function prepare(config = {}) { this.access.connect(this.accounts.admin).grantRole(this.roles.REWARDS_ADMIN, this.accounts.admin.address), this.access.connect(this.accounts.admin).grantRole(this.roles.SCANNER_VERSION, this.accounts.admin.address), this.access.connect(this.accounts.admin).grantRole(this.roles.SCANNER_BETA_VERSION, this.accounts.admin.address), + this.access.connect(this.accounts.admin).grantRole(this.roles.SLASHER, this.contracts.slashing.address), + this.access.connect(this.accounts.admin).grantRole(this.roles.STAKING_ADMIN, this.accounts.admin.address), this.token.connect(this.accounts.admin).grantRole(this.roles.MINTER, this.accounts.minter.address), this.token.connect(this.accounts.admin).grantRole(this.roles.WHITELISTER, this.accounts.whitelister.address), this.otherToken.connect(this.accounts.admin).grantRole(this.roles.MINTER, this.accounts.minter.address), @@ -55,6 +57,7 @@ function prepare(config = {}) { this.token.connect(this.accounts.whitelister).grantRole(this.roles.WHITELIST, this.accounts.whitelist.address), this.token.connect(this.accounts.whitelister).grantRole(this.roles.WHITELIST, this.accounts.treasure.address), this.token.connect(this.accounts.whitelister).grantRole(this.roles.WHITELIST, this.staking.address), + this.token.connect(this.accounts.whitelister).grantRole(this.roles.WHITELIST, this.slashing.address), this.otherToken.connect(this.accounts.whitelister).grantRole(this.roles.WHITELIST, this.accounts.whitelist.address), this.otherToken.connect(this.accounts.whitelister).grantRole(this.roles.WHITELIST, this.accounts.treasure.address), this.otherToken.connect(this.accounts.whitelister).grantRole(this.roles.WHITELIST, this.staking.address),