diff --git a/contracts/contract/minipool/RocketMinipoolDelegate.sol b/contracts/contract/minipool/RocketMinipoolDelegate.sol index 82341f5ce..098947836 100644 --- a/contracts/contract/minipool/RocketMinipoolDelegate.sol +++ b/contracts/contract/minipool/RocketMinipoolDelegate.sol @@ -147,7 +147,8 @@ contract RocketMinipoolDelegate is RocketMinipoolStorageLayout, RocketMinipoolIn // Only accepts calls from the RocketDepositPool contract function userDeposit() override external payable onlyLatestContract("rocketDepositPool", msg.sender) onlyInitialised { // Check current status & user deposit status - require(status >= MinipoolStatus.Initialised && status <= MinipoolStatus.Staking, "The user deposit can only be assigned while initialised, in prelaunch, or staking"); + require(status == MinipoolStatus.Initialised || status == MinipoolStatus.Prelaunch || status == MinipoolStatus.Staking || status == MinipoolStatus.RequestedWithdrawable, + "The user deposit can only be assigned while initialised, in prelaunch, staking, or requestedWithdrawable"); require(userDepositAssignedTime == 0, "The user deposit has already been assigned"); // Progress initialised minipool to prelaunch if (status == MinipoolStatus.Initialised) { setStatus(MinipoolStatus.Prelaunch); } @@ -256,7 +257,7 @@ contract RocketMinipoolDelegate is RocketMinipoolStorageLayout, RocketMinipoolIn // Get contracts RocketMinipoolManagerInterface rocketMinipoolManager = RocketMinipoolManagerInterface(getContractAddress("rocketMinipoolManager")); // Check current status - require(status == MinipoolStatus.Staking, "The minipool can only become withdrawable while staking"); + require(status == MinipoolStatus.Staking || status = RequestedWithdrawable, "The minipool can only become withdrawable while staking or requested withdrawable"); // Progress to withdrawable setStatus(MinipoolStatus.Withdrawable); // Remove minipool from queue @@ -269,6 +270,15 @@ contract RocketMinipoolDelegate is RocketMinipoolStorageLayout, RocketMinipoolIn rocketMinipoolManager.decrementNodeStakingMinipoolCount(nodeAddress); } + // Only needs to be called if exiting owner is arbitraging + // Should be called before the validator is fully exited (including going through exit queue) + // Once this is called, distributeBalance() will lock out non-owners for 14 days + // This allows owner to bundle distributeBalance() call with a rETH burn to arbitrage + function requestWithdrawable() override external onlyInitialised onlyMinipoolOwnerOrWithdrawalAddress(msg.sender) { + require(status == MinipoolStatus.Staking); + setStatus(MinipoolStatus.RequestWithdrawable); + } + // Distributes the contract's balance and finalises the pool function distributeBalanceAndFinalise() override external onlyInitialised onlyMinipoolOwnerOrWithdrawalAddress(msg.sender) { // Can only call if withdrawable and can only be called once @@ -282,22 +292,39 @@ contract RocketMinipoolDelegate is RocketMinipoolStorageLayout, RocketMinipoolIn } // Distributes the contract's balance - // When called during staking status, requires 16 ether in the pool - // When called by non-owner with less than 16 ether, requires 14 days to have passed since being made withdrawable + // Can be called by: + // - Owner; at any time once minipool is withdrawable (withdrawable state set by oDAO) + // - Owner; when minipool state has been requestedWithdrawable for more than 2 days (no reliance on oDAO) + // - Non-owner; when minipool state is staking and balance is > 16 ETH (no reliance on owner or oDAO) + // - Non-owner; when minipool state has been requestedWithdrawable for at least 14 days and balance is > 16 ETH (no reliance on owner or oDAO) + // - Non-owner; when minipool state has been withdrawable for 14 days and balance is between > 4 ETH (no reliance on owner) + // The first two can be used for arbitrage by an owner (along with a bundled rETH burn) + // + // There is currently no solution for the following scenarios: + // - Balance below 4 ETH + // - Balance between 4 and 16 ETH _and_ oDAO does not update state to Withdrawable function distributeBalance() override external onlyInitialised { - // Must be called while staking or withdrawable - require(status == MinipoolStatus.Staking || status == MinipoolStatus.Withdrawable, "Minipool must be staking or withdrawable"); // Get withdrawal amount, we must also account for a possible node refund balance on the contract from users staking 32 ETH that have received a 16 ETH refund after the protocol bought out 16 ETH uint256 totalBalance = address(this).balance.sub(nodeRefundBalance); // Get node withdrawal address address nodeWithdrawalAddress = rocketStorage.getNodeWithdrawalAddress(nodeAddress); - // If it's not the owner calling - if (msg.sender != nodeAddress && msg.sender != nodeWithdrawalAddress) { - // And the pool is in staking status - if (status == MinipoolStatus.Staking) { + if (msg.sender == nodeAddress || msg.sender == nodeWithdrawalAddress) { + if (status == MinipoolStatus.Withdrawable) { + // Owner and state is withdrawable - no further requirements + } else if (status == MinipoolStatus.RequestedWithdrawable) { + require(block.timestamp > statusTime.add(2 days), "After requesting withdrawable, owner must wait either 2 days OR for the state to get updated by oDAO"); + } else { + require(false, "Minipool state must be Withdrawable or RequestedWithdrawable"); + } + } else { + if (status == MinipoolStatus.Staking || status == MinipoolStatus.RequestedWithdrawable) { // Then balance must be greater than 16 ETH require(totalBalance >= 16 ether, "Balance must be greater than 16 ETH"); + if (status == MinipoolStatus.RequestedWithdrawable) { + require(block.timestamp > statusTime.add(14 days), "Only owner can distribute balance for 2 weeks after requestWithdrawable is called"); + } } else { + require(status == MinipoolStatus.Withdrawable) // Then enough time must have elapsed require(block.timestamp > statusTime.add(14 days), "Non-owner must wait 14 days after withdrawal to distribute balance"); // And balance must be greater than 4 ETH diff --git a/contracts/contract/minipool/RocketMinipoolManager.sol b/contracts/contract/minipool/RocketMinipoolManager.sol index 1224438d1..98ae479d3 100644 --- a/contracts/contract/minipool/RocketMinipoolManager.sol +++ b/contracts/contract/minipool/RocketMinipoolManager.sol @@ -59,9 +59,9 @@ contract RocketMinipoolManager is RocketBase, RocketMinipoolManagerInterface { } // Get the number of minipools in each status. - // Returns the counts for Initialised, Prelaunch, Staking, Withdrawable, and Dissolved in that order. + // Returns the counts for Initialised, Prelaunch, Staking, RequestedWithdrwawable, Withdrawable, and Dissolved in that order. function getMinipoolCountPerStatus(uint256 offset, uint256 limit) override external view - returns (uint256 initialisedCount, uint256 prelaunchCount, uint256 stakingCount, uint256 withdrawableCount, uint256 dissolvedCount) { + returns (uint256 initialisedCount, uint256 prelaunchCount, uint256 stakingCount, uint256 requestedWithdrawableCount, uint256 withdrawableCount, uint256 dissolvedCount) { // Get contracts AddressSetStorageInterface addressSetStorage = AddressSetStorageInterface(getContractAddress("addressSetStorage")); // Precompute minipool key @@ -83,6 +83,8 @@ contract RocketMinipoolManager is RocketBase, RocketMinipoolManagerInterface { } else if (status == MinipoolStatus.Staking) { stakingCount++; + } else if (status == MinipoolStatus.RequestedWithdrawable) { + requestedWithdrawableCount++; } else if (status == MinipoolStatus.Withdrawable) { withdrawableCount++; diff --git a/contracts/contract/minipool/RocketMinipoolStatus.sol b/contracts/contract/minipool/RocketMinipoolStatus.sol index 778ab5ee8..f54d365ac 100644 --- a/contracts/contract/minipool/RocketMinipoolStatus.sol +++ b/contracts/contract/minipool/RocketMinipoolStatus.sol @@ -41,7 +41,8 @@ contract RocketMinipoolStatus is RocketBase, RocketMinipoolStatusInterface { require(rocketDAOProtocolSettingsMinipool.getSubmitWithdrawableEnabled(), "Submitting withdrawable status is currently disabled"); // Check minipool status RocketMinipoolInterface minipool = RocketMinipoolInterface(_minipoolAddress); - require(minipool.getStatus() == MinipoolStatus.Staking, "Minipool can only be set as withdrawable while staking"); + MinipoolStatus status = minipool.getStatus() + require(status == MinipoolStatus.Staking || status == MinipoolStatus.RequestedWithdrawable, "Minipool can only be set as withdrawable while staking or requested withdrawable"); // Get submission keys bytes32 nodeSubmissionKey = keccak256(abi.encodePacked("minipool.withdrawable.submitted.node", msg.sender, _minipoolAddress)); bytes32 submissionCountKey = keccak256(abi.encodePacked("minipool.withdrawable.submitted.count", _minipoolAddress)); @@ -71,7 +72,8 @@ contract RocketMinipoolStatus is RocketBase, RocketMinipoolStatusInterface { require(rocketDAOProtocolSettingsMinipool.getSubmitWithdrawableEnabled(), "Submitting withdrawable status is currently disabled"); // Check minipool status RocketMinipoolInterface minipool = RocketMinipoolInterface(_minipoolAddress); - require(minipool.getStatus() == MinipoolStatus.Staking, "Minipool can only be set as withdrawable while staking"); + MinipoolStatus status = minipool.getStatus() + require(status == MinipoolStatus.Staking || status == MinipoolStatus.RequestedWithdrawable, "Minipool can only be set as withdrawable while staking or requested withdrawable"); // Get submission keys bytes32 submissionCountKey = keccak256(abi.encodePacked("minipool.withdrawable.submitted.count", _minipoolAddress)); // Get submission count diff --git a/contracts/types/MinipoolStatus.sol b/contracts/types/MinipoolStatus.sol index 7f489d8d5..01bf12d2c 100644 --- a/contracts/types/MinipoolStatus.sol +++ b/contracts/types/MinipoolStatus.sol @@ -5,9 +5,10 @@ pragma solidity 0.7.6; // Represents a minipool's status within the network enum MinipoolStatus { - Initialised, // The minipool has been initialised and is awaiting a deposit of user ETH - Prelaunch, // The minipool has enough ETH to begin staking and is awaiting launch by the node operator - Staking, // The minipool is currently staking - Withdrawable, // The minipool has become withdrawable on the beacon chain and can be withdrawn from by the node operator - Dissolved // The minipool has been dissolved and its user deposited ETH has been returned to the deposit pool + Initialised, // The minipool has been initialised and is awaiting a deposit of user ETH + Prelaunch, // The minipool has enough ETH to begin staking and is awaiting launch by the node operator + Staking, // The minipool is currently staking + RequestedWithdrawable, // The node operator has requested withdrawable state; functionally equivalent to staking, except when arbing + Withdrawable, // The minipool has become withdrawable on the beacon chain and can be withdrawn from by the node operator + Dissolved // The minipool has been dissolved and its user deposited ETH has been returned to the deposit pool }