diff --git a/packages/horizon/contracts/interfaces/internal/IHorizonStakingMain.sol b/packages/horizon/contracts/interfaces/internal/IHorizonStakingMain.sol index 210146bf4..5ef9928b1 100644 --- a/packages/horizon/contracts/interfaces/internal/IHorizonStakingMain.sol +++ b/packages/horizon/contracts/interfaces/internal/IHorizonStakingMain.sol @@ -254,8 +254,15 @@ interface IHorizonStakingMain { * @param tokens The amount of tokens being released * @param shares The amount of shares being released * @param thawingUntil The timestamp until the stake has thawed + * @param valid Whether the thaw request was valid at the time of fulfillment */ - event ThawRequestFulfilled(bytes32 indexed thawRequestId, uint256 tokens, uint256 shares, uint64 thawingUntil); + event ThawRequestFulfilled( + bytes32 indexed thawRequestId, + uint256 tokens, + uint256 shares, + uint64 thawingUntil, + bool valid + ); /** * @notice Emitted when a series of thaw requests are fulfilled. @@ -742,6 +749,8 @@ interface IHorizonStakingMain { * Tokens can be automatically re-delegated to another provision by setting `newServiceProvider`. * @dev The parameter `nThawRequests` can be set to a non zero value to fulfill a specific number of thaw * requests in the event that fulfilling all of them results in a gas limit error. + * @dev If the delegation pool was completely slashed before withdrawing, calling this function will fulfill + * the thaw requests with an amount equal to zero. * * Requirements: * - Must have previously initiated a thaw request using {undelegate}. diff --git a/packages/horizon/contracts/interfaces/internal/IHorizonStakingTypes.sol b/packages/horizon/contracts/interfaces/internal/IHorizonStakingTypes.sol index 42b5588ef..0dfc6c774 100644 --- a/packages/horizon/contracts/interfaces/internal/IHorizonStakingTypes.sol +++ b/packages/horizon/contracts/interfaces/internal/IHorizonStakingTypes.sol @@ -32,6 +32,8 @@ interface IHorizonStakingTypes { uint32 maxVerifierCutPending; // Pending value for `thawingPeriod`. Verifier needs to accept it before it becomes active. uint64 thawingPeriodPending; + // Value of the current thawing nonce. Thaw requests with older nonces are invalid. + uint256 thawingNonce; } /** @@ -75,6 +77,8 @@ interface IHorizonStakingTypes { uint256 tokensThawing; // Shares representing the thawing tokens uint256 sharesThawing; + // Value of the current thawing nonce. Thaw requests with older nonces are invalid. + uint256 thawingNonce; } /** @@ -101,6 +105,8 @@ interface IHorizonStakingTypes { uint256 tokensThawing; // Shares representing the thawing tokens uint256 sharesThawing; + // Value of the current thawing nonce. Thaw requests with older nonces are invalid. + uint256 thawingNonce; } /** @@ -137,5 +143,7 @@ interface IHorizonStakingTypes { uint64 thawingUntil; // Id of the next thaw request in the linked list bytes32 next; + // Used to invalidate unfulfilled thaw requests + uint256 thawingNonce; } } diff --git a/packages/horizon/contracts/staking/HorizonStaking.sol b/packages/horizon/contracts/staking/HorizonStaking.sol index a00cda8af..b8a376480 100644 --- a/packages/horizon/contracts/staking/HorizonStaking.sol +++ b/packages/horizon/contracts/staking/HorizonStaking.sol @@ -411,6 +411,14 @@ contract HorizonStaking is HorizonStakingBase, IHorizonStakingMain { (FIXED_POINT_PRECISION); prov.tokens = prov.tokens - providerTokensSlashed; + // If the slashing leaves the thawing shares with no thawing tokens, cancel pending thawings by: + // - deleting all thawing shares + // - incrementing the nonce to invalidate pending thaw requests + if (prov.sharesThawing != 0 && prov.tokensThawing == 0) { + prov.sharesThawing = 0; + prov.thawingNonce++; + } + // Service provider accounting _serviceProviders[serviceProvider].tokensProvisioned = _serviceProviders[serviceProvider].tokensProvisioned - @@ -438,6 +446,16 @@ contract HorizonStaking is HorizonStakingBase, IHorizonStakingMain { (pool.tokensThawing * (FIXED_POINT_PRECISION - delegationFractionSlashed)) / FIXED_POINT_PRECISION; + // If the slashing leaves the thawing shares with no thawing tokens, cancel pending thawings by: + // - deleting all thawing shares + // - incrementing the nonce to invalidate pending thaw requests + // Note that thawing shares are completely lost, delegators won't get back the corresponding + // delegation pool shares. + if (pool.sharesThawing != 0 && pool.tokensThawing == 0) { + pool.sharesThawing = 0; + pool.thawingNonce++; + } + emit DelegationSlashed(serviceProvider, verifier, tokensToSlash); } else { emit DelegationSlashingSkipped(serviceProvider, verifier, tokensToSlash); @@ -644,7 +662,8 @@ contract HorizonStaking is HorizonStakingBase, IHorizonStakingMain { thawingPeriod: _thawingPeriod, createdAt: uint64(block.timestamp), maxVerifierCutPending: _maxVerifierCut, - thawingPeriodPending: _thawingPeriod + thawingPeriodPending: _thawingPeriod, + thawingNonce: 0 }); ServiceProviderInternal storage sp = _serviceProviders[_serviceProvider]; @@ -672,6 +691,9 @@ contract HorizonStaking is HorizonStakingBase, IHorizonStakingMain { /** * @notice See {IHorizonStakingMain-thaw}. + * @dev We use a thawing pool to keep track of tokens thawing for multiple thaw requests. + * If due to slashing the thawing pool loses all of its tokens, the pool is reset and all pending thaw + * requests are invalidated. */ function _thaw(address _serviceProvider, address _verifier, uint256 _tokens) private returns (bytes32) { require(_tokens != 0, HorizonStakingInvalidZeroTokens()); @@ -679,18 +701,24 @@ contract HorizonStaking is HorizonStakingBase, IHorizonStakingMain { require(tokensAvailable >= _tokens, HorizonStakingInsufficientTokens(tokensAvailable, _tokens)); Provision storage prov = _provisions[_serviceProvider][_verifier]; - uint256 thawingShares = prov.sharesThawing == 0 ? _tokens : (prov.sharesThawing * _tokens) / prov.tokensThawing; + + // Calculate shares to issue + // Thawing pool is reset/initialized when the pool is empty: prov.tokensThawing == 0 + uint256 thawingShares = prov.tokensThawing == 0 + ? _tokens + : ((prov.sharesThawing * _tokens) / prov.tokensThawing); uint64 thawingUntil = uint64(block.timestamp + uint256(prov.thawingPeriod)); - prov.tokensThawing = prov.tokensThawing + _tokens; prov.sharesThawing = prov.sharesThawing + thawingShares; + prov.tokensThawing = prov.tokensThawing + _tokens; bytes32 thawRequestId = _createThawRequest( _serviceProvider, _verifier, _serviceProvider, thawingShares, - thawingUntil + thawingUntil, + prov.thawingNonce ); emit ProvisionThawed(_serviceProvider, _verifier, _tokens); return thawRequestId; @@ -715,7 +743,8 @@ contract HorizonStaking is HorizonStakingBase, IHorizonStakingMain { _serviceProvider, tokensThawing, sharesThawing, - _nThawRequests + _nThawRequests, + prov.thawingNonce ); prov.tokens = prov.tokens - tokensThawed_; @@ -741,15 +770,19 @@ contract HorizonStaking is HorizonStakingBase, IHorizonStakingMain { DelegationPoolInternal storage pool = _getDelegationPool(_serviceProvider, _verifier); DelegationInternal storage delegation = pool.delegators[msg.sender]; + // An invalid delegation pool has shares but no tokens require( - pool.tokens != 0 || (pool.shares == 0 && pool.sharesThawing == 0), + pool.tokens != 0 || pool.shares == 0, HorizonStakingInvalidDelegationPoolState(_serviceProvider, _verifier) ); // Calculate shares to issue - uint256 shares = (pool.tokens == 0 || pool.tokens == pool.tokensThawing) - ? _tokens - : ((_tokens * pool.shares) / (pool.tokens - pool.tokensThawing)); + // Delegation pool is reset/initialized in any of the following cases: + // - pool.tokens == 0 and pool.shares == 0, pool is completely empty. Note that we don't test shares == 0 because + // the invalid delegation pool check already ensures shares are 0 if tokens are 0 + // - pool.tokens == pool.tokensThawing, the entire pool is thawing + bool initializePool = pool.tokens == 0 || pool.tokens == pool.tokensThawing; + uint256 shares = initializePool ? _tokens : ((_tokens * pool.shares) / (pool.tokens - pool.tokensThawing)); require(shares != 0 && shares >= _minSharesOut, HorizonStakingSlippageProtection(shares, _minSharesOut)); pool.tokens = pool.tokens + _tokens; @@ -764,6 +797,12 @@ contract HorizonStaking is HorizonStakingBase, IHorizonStakingMain { * @notice See {IHorizonStakingMain-undelegate}. * @dev To allow delegation to be slashable even while thawing without breaking accounting * the delegation pool shares are burned and replaced with thawing pool shares. + * @dev Note that due to slashing the delegation pool can enter an invalid state if all it's tokens are slashed. + * An invalid pool can only be recovered by adding back tokens into the pool with {IHorizonStakingMain-addToDelegationPool}. + * Any time the delegation pool is invalidated, the thawing pool is also reset and any pending undelegate requests get + * invalidated. + * Note that delegation that is caught thawing when the pool is invalidated will be completely lost! However delegation shares + * that were not thawing will be preserved. */ function _undelegate( address _serviceProvider, @@ -775,15 +814,21 @@ contract HorizonStaking is HorizonStakingBase, IHorizonStakingMain { DelegationPoolInternal storage pool = _getDelegationPool(_serviceProvider, _verifier); DelegationInternal storage delegation = pool.delegators[msg.sender]; require(delegation.shares >= _shares, HorizonStakingInsufficientShares(delegation.shares, _shares)); + + // An invalid delegation pool has shares but no tokens (previous require check ensures shares > 0) require(pool.tokens != 0, HorizonStakingInvalidDelegationPoolState(_serviceProvider, _verifier)); + // Calculate thawing shares to issue - convert delegation pool shares to thawing pool shares + // delegation pool shares -> delegation pool tokens -> thawing pool shares + // Thawing pool is reset/initialized when the pool is empty: prov.tokensThawing == 0 uint256 tokens = (_shares * (pool.tokens - pool.tokensThawing)) / pool.shares; uint256 thawingShares = pool.tokensThawing == 0 ? tokens : ((tokens * pool.sharesThawing) / pool.tokensThawing); uint64 thawingUntil = uint64(block.timestamp + uint256(_provisions[_serviceProvider][_verifier].thawingPeriod)); - pool.shares = pool.shares - _shares; + pool.tokensThawing = pool.tokensThawing + tokens; pool.sharesThawing = pool.sharesThawing + thawingShares; + pool.shares = pool.shares - _shares; delegation.shares = delegation.shares - _shares; bytes32 thawRequestId = _createThawRequest( @@ -791,7 +836,8 @@ contract HorizonStaking is HorizonStakingBase, IHorizonStakingMain { _verifier, beneficiary, thawingShares, - thawingUntil + thawingUntil, + pool.thawingNonce ); emit TokensUndelegated(_serviceProvider, _verifier, msg.sender, tokens); @@ -809,7 +855,12 @@ contract HorizonStaking is HorizonStakingBase, IHorizonStakingMain { uint256 _nThawRequests ) private { DelegationPoolInternal storage pool = _getDelegationPool(_serviceProvider, _verifier); - require(pool.tokens != 0, HorizonStakingInvalidDelegationPoolState(_serviceProvider, _verifier)); + + // An invalid delegation pool has shares but no tokens + require( + pool.tokens != 0 || pool.shares == 0, + HorizonStakingInvalidDelegationPoolState(_serviceProvider, _verifier) + ); uint256 tokensThawed = 0; uint256 sharesThawing = pool.sharesThawing; @@ -820,9 +871,12 @@ contract HorizonStaking is HorizonStakingBase, IHorizonStakingMain { msg.sender, tokensThawing, sharesThawing, - _nThawRequests + _nThawRequests, + pool.thawingNonce ); + // The next subtraction should never revert becase: pool.tokens >= pool.tokensThawing and pool.tokensThawing >= tokensThawed + // In the event the pool gets completely slashed tokensThawed will fulfil to 0. pool.tokens = pool.tokens - tokensThawed; pool.sharesThawing = sharesThawing; pool.tokensThawing = tokensThawing; @@ -849,6 +903,7 @@ contract HorizonStaking is HorizonStakingBase, IHorizonStakingMain { * @param _owner The address of the owner of the thaw request * @param _shares The number of shares to thaw * @param _thawingUntil The timestamp until which the shares are thawing + * @param _thawingNonce Owner's validity nonce for the thaw request * @return The ID of the thaw request */ function _createThawRequest( @@ -856,13 +911,19 @@ contract HorizonStaking is HorizonStakingBase, IHorizonStakingMain { address _verifier, address _owner, uint256 _shares, - uint64 _thawingUntil + uint64 _thawingUntil, + uint256 _thawingNonce ) private returns (bytes32) { LinkedList.List storage thawRequestList = _thawRequestLists[_serviceProvider][_verifier][_owner]; require(thawRequestList.count < MAX_THAW_REQUESTS, HorizonStakingTooManyThawRequests()); bytes32 thawRequestId = keccak256(abi.encodePacked(_serviceProvider, _verifier, _owner, thawRequestList.nonce)); - _thawRequests[thawRequestId] = ThawRequest({ shares: _shares, thawingUntil: _thawingUntil, next: bytes32(0) }); + _thawRequests[thawRequestId] = ThawRequest({ + shares: _shares, + thawingUntil: _thawingUntil, + next: bytes32(0), + thawingNonce: _thawingNonce + }); if (thawRequestList.count != 0) _thawRequests[thawRequestList.tail].next = thawRequestId; thawRequestList.addTail(thawRequestId); @@ -880,6 +941,7 @@ contract HorizonStaking is HorizonStakingBase, IHorizonStakingMain { * @param _tokensThawing The current amount of tokens already thawing * @param _sharesThawing The current amount of shares already thawing * @param _nThawRequests The number of thaw requests to fulfill. If set to 0, all thaw requests are fulfilled. + * @param _thawingNonce The current valid thawing nonce. Any thaw request with a different nonce is invalid and should be ignored. * @return The amount of thawed tokens * @return The amount of tokens still thawing * @return The amount of shares still thawing @@ -890,7 +952,8 @@ contract HorizonStaking is HorizonStakingBase, IHorizonStakingMain { address _owner, uint256 _tokensThawing, uint256 _sharesThawing, - uint256 _nThawRequests + uint256 _nThawRequests, + uint256 _thawingNonce ) private returns (uint256, uint256, uint256) { LinkedList.List storage thawRequestList = _thawRequestLists[_serviceProvider][_verifier][_owner]; require(thawRequestList.count > 0, HorizonStakingNothingThawing()); @@ -900,7 +963,7 @@ contract HorizonStaking is HorizonStakingBase, IHorizonStakingMain { _getNextThawRequest, _fulfillThawRequest, _deleteThawRequest, - abi.encode(tokensThawed, _tokensThawing, _sharesThawing), + abi.encode(tokensThawed, _tokensThawing, _sharesThawing, _thawingNonce), _nThawRequests ); @@ -929,20 +992,30 @@ contract HorizonStaking is HorizonStakingBase, IHorizonStakingMain { } // decode - (uint256 tokensThawed, uint256 tokensThawing, uint256 sharesThawing) = abi.decode( + (uint256 tokensThawed, uint256 tokensThawing, uint256 sharesThawing, uint256 thawingNonce) = abi.decode( _acc, - (uint256, uint256, uint256) + (uint256, uint256, uint256, uint256) ); - // process - uint256 tokens = (thawRequest.shares * tokensThawing) / sharesThawing; - tokensThawing = tokensThawing - tokens; - sharesThawing = sharesThawing - thawRequest.shares; - tokensThawed = tokensThawed + tokens; - emit ThawRequestFulfilled(_thawRequestId, tokens, thawRequest.shares, thawRequest.thawingUntil); + // process - only fulfill thaw requests for the current valid nonce + uint256 tokens = 0; + bool validThawRequest = thawRequest.thawingNonce == thawingNonce; + if (validThawRequest) { + tokens = (thawRequest.shares * tokensThawing) / sharesThawing; + tokensThawing = tokensThawing - tokens; + sharesThawing = sharesThawing - thawRequest.shares; + tokensThawed = tokensThawed + tokens; + } + emit ThawRequestFulfilled( + _thawRequestId, + tokens, + thawRequest.shares, + thawRequest.thawingUntil, + validThawRequest + ); // encode - _acc = abi.encode(tokensThawed, tokensThawing, sharesThawing); + _acc = abi.encode(tokensThawed, tokensThawing, sharesThawing, thawingNonce); return (false, _acc); } diff --git a/packages/horizon/contracts/staking/HorizonStakingBase.sol b/packages/horizon/contracts/staking/HorizonStakingBase.sol index d9fb613c3..54e55e1cc 100644 --- a/packages/horizon/contracts/staking/HorizonStakingBase.sol +++ b/packages/horizon/contracts/staking/HorizonStakingBase.sol @@ -96,6 +96,7 @@ abstract contract HorizonStakingBase is pool.shares = poolInternal.shares; pool.tokensThawing = poolInternal.tokensThawing; pool.sharesThawing = poolInternal.sharesThawing; + pool.thawingNonce = poolInternal.thawingNonce; return pool; } diff --git a/packages/horizon/test/shared/horizon-staking/HorizonStakingShared.t.sol b/packages/horizon/test/shared/horizon-staking/HorizonStakingShared.t.sol index f9796cf94..7590d4fe3 100644 --- a/packages/horizon/test/shared/horizon-staking/HorizonStakingShared.t.sol +++ b/packages/horizon/test/shared/horizon-staking/HorizonStakingShared.t.sol @@ -351,6 +351,7 @@ abstract contract HorizonStakingSharedTest is GraphBaseTest { assertEq(afterProvision.createdAt, uint64(block.timestamp)); assertEq(afterProvision.maxVerifierCutPending, maxVerifierCut); assertEq(afterProvision.thawingPeriodPending, thawingPeriod); + assertEq(afterProvision.thawingNonce, 0); assertEq(afterServiceProvider.tokensStaked, beforeServiceProvider.tokensStaked); assertEq(afterServiceProvider.tokensProvisioned, tokens + beforeServiceProvider.tokensProvisioned); assertEq(afterServiceProvider.__DEPRECATED_tokensAllocated, beforeServiceProvider.__DEPRECATED_tokensAllocated); @@ -384,6 +385,7 @@ abstract contract HorizonStakingSharedTest is GraphBaseTest { assertEq(afterProvision.createdAt, beforeProvision.createdAt); assertEq(afterProvision.maxVerifierCutPending, beforeProvision.maxVerifierCutPending); assertEq(afterProvision.thawingPeriodPending, beforeProvision.thawingPeriodPending); + assertEq(afterProvision.thawingNonce, beforeProvision.thawingNonce); assertEq(afterServiceProvider.tokensStaked, beforeServiceProvider.tokensStaked); assertEq(afterServiceProvider.tokensProvisioned, beforeServiceProvider.tokensProvisioned + tokens); assertEq(afterServiceProvider.__DEPRECATED_tokensAllocated, beforeServiceProvider.__DEPRECATED_tokensAllocated); @@ -406,7 +408,7 @@ abstract contract HorizonStakingSharedTest is GraphBaseTest { bytes32 expectedThawRequestId = keccak256( abi.encodePacked(users.indexer, verifier, users.indexer, beforeThawRequestList.nonce) ); - uint256 thawingShares = beforeProvision.sharesThawing == 0 + uint256 thawingShares = beforeProvision.tokensThawing == 0 ? tokens : (beforeProvision.sharesThawing * tokens) / beforeProvision.tokensThawing; @@ -437,12 +439,16 @@ abstract contract HorizonStakingSharedTest is GraphBaseTest { // assert assertEq(afterProvision.tokens, beforeProvision.tokens); assertEq(afterProvision.tokensThawing, beforeProvision.tokensThawing + tokens); - assertEq(afterProvision.sharesThawing, beforeProvision.sharesThawing + thawingShares); + assertEq( + afterProvision.sharesThawing, + beforeProvision.tokensThawing == 0 ? thawingShares : beforeProvision.sharesThawing + thawingShares + ); assertEq(afterProvision.maxVerifierCut, beforeProvision.maxVerifierCut); assertEq(afterProvision.thawingPeriod, beforeProvision.thawingPeriod); assertEq(afterProvision.createdAt, beforeProvision.createdAt); assertEq(afterProvision.maxVerifierCutPending, beforeProvision.maxVerifierCutPending); assertEq(afterProvision.thawingPeriodPending, beforeProvision.thawingPeriodPending); + assertEq(afterProvision.thawingNonce, beforeProvision.thawingNonce); assertEq(thawRequestId, expectedThawRequestId); assertEq(afterThawRequest.shares, thawingShares); assertEq(afterThawRequest.thawingUntil, block.timestamp + beforeProvision.thawingPeriod); @@ -471,7 +477,13 @@ abstract contract HorizonStakingSharedTest is GraphBaseTest { serviceProvider ); - CalcValues_ThawRequestData memory calcValues = calcThawRequestData(serviceProvider, verifier, serviceProvider, nThawRequests, false); + CalcValues_ThawRequestData memory calcValues = calcThawRequestData( + serviceProvider, + verifier, + serviceProvider, + nThawRequests, + false + ); // deprovision for (uint i = 0; i < calcValues.thawRequestsFulfilledList.length; i++) { @@ -481,7 +493,8 @@ abstract contract HorizonStakingSharedTest is GraphBaseTest { calcValues.thawRequestsFulfilledListIds[i], calcValues.thawRequestsFulfilledListTokens[i], thawRequest.shares, - thawRequest.thawingUntil + thawRequest.thawingUntil, + beforeProvision.thawingNonce == thawRequest.thawingNonce ); } vm.expectEmit(address(staking)); @@ -514,8 +527,12 @@ abstract contract HorizonStakingSharedTest is GraphBaseTest { assertEq(afterProvision.createdAt, beforeProvision.createdAt); assertEq(afterProvision.maxVerifierCutPending, beforeProvision.maxVerifierCutPending); assertEq(afterProvision.thawingPeriodPending, beforeProvision.thawingPeriodPending); + assertEq(afterProvision.thawingNonce, beforeProvision.thawingNonce); assertEq(afterServiceProvider.tokensStaked, beforeServiceProvider.tokensStaked); - assertEq(afterServiceProvider.tokensProvisioned, beforeServiceProvider.tokensProvisioned - calcValues.tokensThawed); + assertEq( + afterServiceProvider.tokensProvisioned, + beforeServiceProvider.tokensProvisioned - calcValues.tokensThawed + ); assertEq(afterServiceProvider.__DEPRECATED_tokensAllocated, beforeServiceProvider.__DEPRECATED_tokensAllocated); assertEq(afterServiceProvider.__DEPRECATED_tokensLocked, beforeServiceProvider.__DEPRECATED_tokensLocked); assertEq( @@ -554,6 +571,7 @@ abstract contract HorizonStakingSharedTest is GraphBaseTest { ServiceProviderInternal serviceProvider; LinkedList.List thawRequestList; } + function _reprovision( address serviceProvider, address verifier, @@ -569,7 +587,13 @@ abstract contract HorizonStakingSharedTest is GraphBaseTest { }); // calc - CalcValues_ThawRequestData memory calcValues = calcThawRequestData(serviceProvider, verifier, serviceProvider, nThawRequests, false); + CalcValues_ThawRequestData memory calcValues = calcThawRequestData( + serviceProvider, + verifier, + serviceProvider, + nThawRequests, + false + ); // reprovision for (uint i = 0; i < calcValues.thawRequestsFulfilledList.length; i++) { @@ -579,7 +603,8 @@ abstract contract HorizonStakingSharedTest is GraphBaseTest { calcValues.thawRequestsFulfilledListIds[i], calcValues.thawRequestsFulfilledListTokens[i], thawRequest.shares, - thawRequest.thawingUntil + thawRequest.thawingUntil, + beforeValues.provision.thawingNonce == thawRequest.thawingNonce ); } vm.expectEmit(address(staking)); @@ -615,6 +640,7 @@ abstract contract HorizonStakingSharedTest is GraphBaseTest { assertEq(afterProvision.createdAt, beforeValues.provision.createdAt); assertEq(afterProvision.maxVerifierCutPending, beforeValues.provision.maxVerifierCutPending); assertEq(afterProvision.thawingPeriodPending, beforeValues.provision.thawingPeriodPending); + assertEq(afterProvision.thawingNonce, beforeValues.provision.thawingNonce); // assert: provision new verifier assertEq(afterProvisionNewVerifier.tokens, beforeValues.provisionNewVerifier.tokens + calcValues.tokensThawed); @@ -623,8 +649,15 @@ abstract contract HorizonStakingSharedTest is GraphBaseTest { assertEq(afterProvisionNewVerifier.maxVerifierCut, beforeValues.provisionNewVerifier.maxVerifierCut); assertEq(afterProvisionNewVerifier.thawingPeriod, beforeValues.provisionNewVerifier.thawingPeriod); assertEq(afterProvisionNewVerifier.createdAt, beforeValues.provisionNewVerifier.createdAt); - assertEq(afterProvisionNewVerifier.maxVerifierCutPending, beforeValues.provisionNewVerifier.maxVerifierCutPending); - assertEq(afterProvisionNewVerifier.thawingPeriodPending, beforeValues.provisionNewVerifier.thawingPeriodPending); + assertEq( + afterProvisionNewVerifier.maxVerifierCutPending, + beforeValues.provisionNewVerifier.maxVerifierCutPending + ); + assertEq( + afterProvisionNewVerifier.thawingPeriodPending, + beforeValues.provisionNewVerifier.thawingPeriodPending + ); + assertEq(afterProvisionNewVerifier.thawingNonce, beforeValues.provisionNewVerifier.thawingNonce); // assert: service provider assertEq(afterServiceProvider.tokensStaked, beforeValues.serviceProvider.tokensStaked); @@ -632,8 +665,14 @@ abstract contract HorizonStakingSharedTest is GraphBaseTest { afterServiceProvider.tokensProvisioned, beforeValues.serviceProvider.tokensProvisioned + calcValues.tokensThawed - calcValues.tokensThawed ); - assertEq(afterServiceProvider.__DEPRECATED_tokensAllocated, beforeValues.serviceProvider.__DEPRECATED_tokensAllocated); - assertEq(afterServiceProvider.__DEPRECATED_tokensLocked, beforeValues.serviceProvider.__DEPRECATED_tokensLocked); + assertEq( + afterServiceProvider.__DEPRECATED_tokensAllocated, + beforeValues.serviceProvider.__DEPRECATED_tokensAllocated + ); + assertEq( + afterServiceProvider.__DEPRECATED_tokensLocked, + beforeValues.serviceProvider.__DEPRECATED_tokensLocked + ); assertEq( afterServiceProvider.__DEPRECATED_tokensLockedUntil, beforeValues.serviceProvider.__DEPRECATED_tokensLockedUntil @@ -662,7 +701,10 @@ abstract contract HorizonStakingSharedTest is GraphBaseTest { ? bytes32(0) : beforeValues.thawRequestList.tail ); - assertEq(afterThawRequestList.count, beforeValues.thawRequestList.count - calcValues.thawRequestsFulfilledList.length); + assertEq( + afterThawRequestList.count, + beforeValues.thawRequestList.count - calcValues.thawRequestsFulfilledList.length + ); assertEq(afterThawRequestList.nonce, beforeValues.thawRequestList.nonce); } @@ -699,6 +741,7 @@ abstract contract HorizonStakingSharedTest is GraphBaseTest { assertEq(afterProvision.createdAt, beforeProvision.createdAt); assertEq(afterProvision.maxVerifierCutPending, maxVerifierCut); assertEq(afterProvision.thawingPeriodPending, thawingPeriod); + assertEq(afterProvision.thawingNonce, beforeProvision.thawingNonce); } function _acceptProvisionParameters(address serviceProvider) internal { @@ -734,6 +777,7 @@ abstract contract HorizonStakingSharedTest is GraphBaseTest { assertEq(afterProvision.thawingPeriod, beforeProvision.thawingPeriodPending); assertEq(afterProvision.thawingPeriod, afterProvision.thawingPeriodPending); assertEq(afterProvision.createdAt, beforeProvision.createdAt); + assertEq(afterProvision.thawingNonce, beforeProvision.thawingNonce); } function _setOperator(address operator, address verifier, bool allow) internal { @@ -842,6 +886,7 @@ abstract contract HorizonStakingSharedTest is GraphBaseTest { assertEq(beforePool.shares + calcShares, afterPool.shares); assertEq(beforePool.tokensThawing, afterPool.tokensThawing); assertEq(beforePool.sharesThawing, afterPool.sharesThawing); + assertEq(beforePool.thawingNonce, afterPool.thawingNonce); assertEq(beforeDelegation.shares + calcShares, afterDelegation.shares); assertEq(beforeDelegation.__DEPRECATED_tokensLocked, afterDelegation.__DEPRECATED_tokensLocked); assertEq(beforeDelegation.__DEPRECATED_tokensLockedUntil, afterDelegation.__DEPRECATED_tokensLockedUntil); @@ -890,7 +935,9 @@ abstract contract HorizonStakingSharedTest is GraphBaseTest { // calc CalcValues_Undelegate memory calcValues; - calcValues.tokens = ((beforeValues.pool.tokens - beforeValues.pool.tokensThawing) * shares) / beforeValues.pool.shares; + calcValues.tokens = + ((beforeValues.pool.tokens - beforeValues.pool.tokensThawing) * shares) / + beforeValues.pool.shares; calcValues.thawingShares = beforeValues.pool.tokensThawing == 0 ? calcValues.tokens : (beforeValues.pool.sharesThawing * calcValues.tokens) / beforeValues.pool.tokensThawing; @@ -939,7 +986,13 @@ abstract contract HorizonStakingSharedTest is GraphBaseTest { assertEq(beforeValues.pool.shares, afterPool.shares + shares); assertEq(beforeValues.pool.tokens, afterPool.tokens); assertEq(beforeValues.pool.tokensThawing + calcValues.tokens, afterPool.tokensThawing); - assertEq(beforeValues.pool.sharesThawing + calcValues.thawingShares, afterPool.sharesThawing); + assertEq( + beforeValues.pool.tokensThawing == 0 + ? calcValues.thawingShares + : beforeValues.pool.sharesThawing + calcValues.thawingShares, + afterPool.sharesThawing + ); + assertEq(beforeValues.pool.thawingNonce, afterPool.thawingNonce); assertEq(beforeValues.delegation.shares - shares, afterDelegation.shares); assertEq(afterThawRequest.shares, calcValues.thawingShares); assertEq(afterThawRequest.thawingUntil, calcValues.thawingUntil); @@ -987,6 +1040,7 @@ abstract contract HorizonStakingSharedTest is GraphBaseTest { uint256 senderBalance; uint256 stakingBalance; } + function __withdrawDelegated( address _serviceProvider, address _verifier, @@ -1024,7 +1078,8 @@ abstract contract HorizonStakingSharedTest is GraphBaseTest { calcValues.thawRequestsFulfilledListIds[i], calcValues.thawRequestsFulfilledListTokens[i], thawRequest.shares, - thawRequest.thawingUntil + thawRequest.thawingUntil, + beforeValues.pool.thawingNonce == thawRequest.thawingNonce ); } vm.expectEmit(address(staking)); @@ -1038,13 +1093,23 @@ abstract contract HorizonStakingSharedTest is GraphBaseTest { if (calcValues.tokensThawed != 0) { vm.expectEmit(); if (reDelegate) { - emit IHorizonStakingMain.TokensDelegated(_newServiceProvider, _verifier, msgSender, calcValues.tokensThawed); + emit IHorizonStakingMain.TokensDelegated( + _newServiceProvider, + _verifier, + msgSender, + calcValues.tokensThawed + ); } else { emit Transfer(address(staking), msgSender, calcValues.tokensThawed); } } vm.expectEmit(); - emit IHorizonStakingMain.DelegatedTokensWithdrawn(_serviceProvider, _verifier, msgSender, calcValues.tokensThawed); + emit IHorizonStakingMain.DelegatedTokensWithdrawn( + _serviceProvider, + _verifier, + msgSender, + calcValues.tokensThawed + ); staking.withdrawDelegated( _serviceProvider, _verifier, @@ -1067,6 +1132,7 @@ abstract contract HorizonStakingSharedTest is GraphBaseTest { assertEq(afterValues.pool.shares, beforeValues.pool.shares); assertEq(afterValues.pool.tokensThawing, calcValues.tokensThawing); assertEq(afterValues.pool.sharesThawing, calcValues.sharesThawing); + assertEq(afterValues.pool.thawingNonce, beforeValues.pool.thawingNonce); for (uint i = 0; i < calcValues.thawRequestsFulfilledListIds.length; i++) { ThawRequest memory thawRequest = staking.getThawRequest(calcValues.thawRequestsFulfilledListIds[i]); @@ -1090,7 +1156,10 @@ abstract contract HorizonStakingSharedTest is GraphBaseTest { ? bytes32(0) : beforeValues.thawRequestList.tail ); - assertEq(afterValues.thawRequestList.count, beforeValues.thawRequestList.count - calcValues.thawRequestsFulfilledList.length); + assertEq( + afterValues.thawRequestList.count, + beforeValues.thawRequestList.count - calcValues.thawRequestsFulfilledList.length + ); assertEq(afterValues.thawRequestList.nonce, beforeValues.thawRequestList.nonce); if (reDelegate) { @@ -1106,7 +1175,10 @@ abstract contract HorizonStakingSharedTest is GraphBaseTest { assertEq(afterValues.newPool.tokensThawing, beforeValues.newPool.tokensThawing); assertEq(afterValues.newPool.sharesThawing, beforeValues.newPool.sharesThawing); assertEq(afterValues.newDelegation.shares, beforeValues.newDelegation.shares + calcShares); - assertEq(afterValues.newDelegation.__DEPRECATED_tokensLocked, beforeValues.newDelegation.__DEPRECATED_tokensLocked); + assertEq( + afterValues.newDelegation.__DEPRECATED_tokensLocked, + beforeValues.newDelegation.__DEPRECATED_tokensLocked + ); assertEq( afterValues.newDelegation.__DEPRECATED_tokensLockedUntil, beforeValues.newDelegation.__DEPRECATED_tokensLockedUntil @@ -1160,6 +1232,7 @@ abstract contract HorizonStakingSharedTest is GraphBaseTest { assertEq(beforePool.shares, afterPool.shares); assertEq(beforePool.tokensThawing, afterPool.tokensThawing); assertEq(beforePool.sharesThawing, afterPool.sharesThawing); + assertEq(beforePool.thawingNonce, afterPool.thawingNonce); } function _setDelegationFeeCut( @@ -1322,19 +1395,25 @@ abstract contract HorizonStakingSharedTest is GraphBaseTest { // assert assertEq(afterProvision.tokens + calcValues.providerTokensSlashed, before.provision.tokens); assertEq(afterProvision.tokensThawing, provisionThawingTokens); - assertEq(afterProvision.sharesThawing, before.provision.sharesThawing); + assertEq( + afterProvision.sharesThawing, + afterProvision.tokensThawing == 0 ? 0 : before.provision.sharesThawing + ); assertEq(afterProvision.maxVerifierCut, before.provision.maxVerifierCut); assertEq(afterProvision.maxVerifierCutPending, before.provision.maxVerifierCutPending); assertEq(afterProvision.thawingPeriod, before.provision.thawingPeriod); assertEq(afterProvision.thawingPeriodPending, before.provision.thawingPeriodPending); - + assertEq( + afterProvision.thawingNonce, + (before.provision.sharesThawing != 0 && afterProvision.sharesThawing == 0) ? before.provision.thawingNonce + 1 : before.provision.thawingNonce); if (isDelegationSlashingEnabled) { uint256 poolThawingTokens = (before.pool.tokensThawing * (1e18 - ((calcValues.delegationTokensSlashed * 1e18) / before.pool.tokens))) / (1e18); assertEq(afterPool.tokens + calcValues.delegationTokensSlashed, before.pool.tokens); assertEq(afterPool.shares, before.pool.shares); assertEq(afterPool.tokensThawing, poolThawingTokens); - assertEq(afterPool.sharesThawing, before.pool.sharesThawing); + assertEq(afterPool.sharesThawing, afterPool.tokensThawing == 0 ? 0 : before.pool.sharesThawing); + assertEq(afterPool.thawingNonce, (before.pool.sharesThawing != 0 && afterPool.sharesThawing == 0) ? before.pool.thawingNonce + 1 : before.pool.thawingNonce); } assertEq(before.stakingBalance - tokensSlashed, afterStakingBalance); @@ -1652,6 +1731,7 @@ abstract contract HorizonStakingSharedTest is GraphBaseTest { assertEq(afterValues.pool.shares, beforeValues.pool.shares); assertEq(afterValues.pool.tokensThawing, beforeValues.pool.tokensThawing); assertEq(afterValues.pool.sharesThawing, beforeValues.pool.sharesThawing); + assertEq(afterValues.pool.thawingNonce, beforeValues.pool.thawingNonce); assertEq(afterValues.serviceProvider.tokensProvisioned, beforeValues.serviceProvider.tokensProvisioned); if (rewardsDestination != address(0)) { @@ -1763,6 +1843,8 @@ abstract contract HorizonStakingSharedTest is GraphBaseTest { uint256 tokensThawing; // Shares representing the thawing tokens uint256 sharesThawing; + // Thawing nonce + uint256 thawingNonce; } function _getStorage_DelegationPoolInternal( @@ -1789,7 +1871,8 @@ abstract contract HorizonStakingSharedTest is GraphBaseTest { shares: uint256(vm.load(address(staking), bytes32(baseSlot + 3))), _gap_delegators_mapping: uint256(vm.load(address(staking), bytes32(baseSlot + 4))), tokensThawing: uint256(vm.load(address(staking), bytes32(baseSlot + 5))), - sharesThawing: uint256(vm.load(address(staking), bytes32(baseSlot + 6))) + sharesThawing: uint256(vm.load(address(staking), bytes32(baseSlot + 6))), + thawingNonce: uint256(vm.load(address(staking), bytes32(baseSlot + 7))) }); return delegationPoolInternal; @@ -2120,14 +2203,17 @@ abstract contract HorizonStakingSharedTest is GraphBaseTest { bytes32 thawRequestId = thawRequestList.head; while (thawRequestId != bytes32(0) && (iterations == 0 || thawRequestsFulfilled < iterations)) { ThawRequest memory thawRequest = staking.getThawRequest(thawRequestId); + bool isThawRequestValid = thawRequest.thawingNonce == (delegation ? pool.thawingNonce : prov.thawingNonce); if (thawRequest.thawingUntil <= block.timestamp) { thawRequestsFulfilled++; - uint256 tokens = delegation - ? (thawRequest.shares * pool.tokensThawing) / pool.sharesThawing - : (thawRequest.shares * prov.tokensThawing) / prov.sharesThawing; - tokensThawed += tokens; - tokensThawing -= tokens; - sharesThawing -= thawRequest.shares; + if (isThawRequestValid) { + uint256 tokens = delegation + ? (thawRequest.shares * pool.tokensThawing) / pool.sharesThawing + : (thawRequest.shares * prov.tokensThawing) / prov.sharesThawing; + tokensThawed += tokens; + tokensThawing -= tokens; + sharesThawing -= thawRequest.shares; + } } else { break; } @@ -2142,11 +2228,14 @@ abstract contract HorizonStakingSharedTest is GraphBaseTest { thawRequestId = thawRequestList.head; while (thawRequestId != bytes32(0) && (iterations == 0 || i < iterations)) { ThawRequest memory thawRequest = staking.getThawRequest(thawRequestId); + bool isThawRequestValid = thawRequest.thawingNonce == (delegation ? pool.thawingNonce : prov.thawingNonce); + if (thawRequest.thawingUntil <= block.timestamp) { - uint256 tokens = delegation - ? (thawRequest.shares * pool.tokensThawing) / pool.sharesThawing - : (thawRequest.shares * prov.tokensThawing) / prov.sharesThawing; - thawRequestsFulfilledListTokens[i] = tokens; + if (isThawRequestValid) { + thawRequestsFulfilledListTokens[i] = delegation + ? (thawRequest.shares * pool.tokensThawing) / pool.sharesThawing + : (thawRequest.shares * prov.tokensThawing) / prov.sharesThawing; + } thawRequestsFulfilledListIds[i] = thawRequestId; thawRequestsFulfilledList[i] = staking.getThawRequest(thawRequestId); thawRequestId = thawRequestsFulfilledList[i].next; diff --git a/packages/horizon/test/staking/delegation/addToPool.t.sol b/packages/horizon/test/staking/delegation/addToPool.t.sol index de2647dfc..ff5b957ca 100644 --- a/packages/horizon/test/staking/delegation/addToPool.t.sol +++ b/packages/horizon/test/staking/delegation/addToPool.t.sol @@ -76,7 +76,7 @@ contract HorizonStakingDelegationAddToPoolTest is HorizonStakingTest { staking.addToDelegationPool(users.indexer, subgraphDataServiceAddress, 1); } - function test_Deletaion_AddToPool_RevertWhen_NoProvision() public { + function test_Delegation_AddToPool_RevertWhen_NoProvision() public { vm.startPrank(subgraphDataServiceAddress); bytes memory expectedError = abi.encodeWithSelector( IHorizonStakingMain.HorizonStakingInvalidProvision.selector, @@ -87,5 +87,62 @@ contract HorizonStakingDelegationAddToPoolTest is HorizonStakingTest { staking.addToDelegationPool(users.indexer, subgraphDataServiceAddress, 1); } - // TODO: test recovering an invalid delegation pool + function test_Delegation_AddToPool_WhenInvalidPool( + uint256 tokens, + uint256 delegationTokens, + uint256 recoverAmount + ) public useIndexer useProvision(tokens, 0, 0) useDelegationSlashing() { + recoverAmount = bound(recoverAmount, 1, MAX_STAKING_TOKENS); + delegationTokens = bound(delegationTokens, 1, MAX_STAKING_TOKENS); + + // create delegation pool + resetPrank(users.delegator); + _delegate(users.indexer, subgraphDataServiceAddress, delegationTokens, 0); + + // slash entire provision + pool + resetPrank(subgraphDataServiceAddress); + _slash(users.indexer, subgraphDataServiceAddress, tokens + delegationTokens, 0); + + // recover pool by adding tokens + resetPrank(users.indexer); + token.approve(address(staking), recoverAmount); + _addToDelegationPool(users.indexer, subgraphDataServiceAddress, recoverAmount); + } + + + function test_Delegation_AddToPool_WhenInvalidPool_RevertWhen_PoolHasNoShares( + uint256 tokens, + uint256 delegationTokens, + uint256 recoverAmount + ) public useIndexer useProvision(tokens, 0, 0) useDelegationSlashing() { + recoverAmount = bound(recoverAmount, 1, MAX_STAKING_TOKENS); + delegationTokens = bound(delegationTokens, 1, MAX_STAKING_TOKENS); + + // create delegation pool + resetPrank(users.delegator); + _delegate(users.indexer, subgraphDataServiceAddress, delegationTokens, 0); + + // undelegate shares so we have thawing shares/tokens + DelegationInternal memory delegation = _getStorage_Delegation( + users.indexer, + subgraphDataServiceAddress, + users.delegator, + false + ); + resetPrank(users.delegator); + _undelegate(users.indexer, subgraphDataServiceAddress, delegation.shares); + + // slash entire provision + pool + resetPrank(subgraphDataServiceAddress); + _slash(users.indexer, subgraphDataServiceAddress, tokens + delegationTokens, 0); + + // addTokens + bytes memory expectedError = abi.encodeWithSelector( + IHorizonStakingMain.HorizonStakingInvalidDelegationPool.selector, + users.indexer, + subgraphDataServiceAddress + ); + vm.expectRevert(expectedError); + staking.addToDelegationPool(users.indexer, subgraphDataServiceAddress, 1); + } } \ No newline at end of file diff --git a/packages/horizon/test/staking/delegation/delegate.t.sol b/packages/horizon/test/staking/delegation/delegate.t.sol index 5deb9b495..ab58e4bde 100644 --- a/packages/horizon/test/staking/delegation/delegate.t.sol +++ b/packages/horizon/test/staking/delegation/delegate.t.sol @@ -73,12 +73,15 @@ contract HorizonStakingDelegateTest is HorizonStakingTest { uint256 delegationTokens ) public useIndexer useProvision(tokens, 0, 0) useDelegationSlashing() { delegationTokens = bound(delegationTokens, 1, MAX_STAKING_TOKENS); + resetPrank(users.delegator); _delegate(users.indexer, subgraphDataServiceAddress, delegationTokens, 0); + // slash entire provision + pool resetPrank(subgraphDataServiceAddress); _slash(users.indexer, subgraphDataServiceAddress, tokens + delegationTokens, 0); + // attempt to delegate to a pool on invalid state, should revert resetPrank(users.delegator); token.approve(address(staking), delegationTokens); vm.expectRevert(abi.encodeWithSelector( @@ -93,15 +96,20 @@ contract HorizonStakingDelegateTest is HorizonStakingTest { uint256 tokens, uint256 delegationTokens ) public useIndexer useProvision(tokens, 0, 0) useDelegationSlashing() { - delegationTokens = bound(delegationTokens, 1, MAX_STAKING_TOKENS); + delegationTokens = bound(delegationTokens, 2, MAX_STAKING_TOKENS); + resetPrank(users.delegator); _delegate(users.indexer, subgraphDataServiceAddress, delegationTokens, 0); + + // undelegate some shares but not all DelegationInternal memory delegation = _getStorage_Delegation(users.indexer, subgraphDataServiceAddress, users.delegator, false); - _undelegate(users.indexer, subgraphDataServiceAddress, delegation.shares); + _undelegate(users.indexer, subgraphDataServiceAddress, delegation.shares / 2); + // slash entire provision + pool resetPrank(subgraphDataServiceAddress); _slash(users.indexer, subgraphDataServiceAddress, tokens + delegationTokens, 0); - + + // attempt to delegate to a pool on invalid state, should revert resetPrank(users.delegator); token.approve(address(staking), delegationTokens); vm.expectRevert(abi.encodeWithSelector( @@ -111,4 +119,31 @@ contract HorizonStakingDelegateTest is HorizonStakingTest { )); staking.delegate(users.indexer, subgraphDataServiceAddress, delegationTokens, 0); } + + function testDelegate_AfterRecoveringPool( + uint256 tokens, + uint256 delegationTokens, + uint256 recoverAmount + ) public useIndexer useProvision(tokens, 0, 0) useDelegationSlashing() { + recoverAmount = bound(recoverAmount, 1, MAX_STAKING_TOKENS); + delegationTokens = bound(delegationTokens, 1, MAX_STAKING_TOKENS); + + // create delegation pool + resetPrank(users.delegator); + _delegate(users.indexer, subgraphDataServiceAddress, delegationTokens, 0); + + // slash entire provision + pool + resetPrank(subgraphDataServiceAddress); + _slash(users.indexer, subgraphDataServiceAddress, tokens + delegationTokens, 0); + + // recover pool by adding tokens + resetPrank(users.indexer); + token.approve(address(staking), recoverAmount); + _addToDelegationPool(users.indexer, subgraphDataServiceAddress, recoverAmount); + + // delegate to pool - should be allowed now + vm.assume(delegationTokens >= recoverAmount); // to avoid getting issued 0 shares + resetPrank(users.delegator); + _delegate(users.indexer, subgraphDataServiceAddress, delegationTokens, 0); + } } diff --git a/packages/horizon/test/staking/delegation/undelegate.t.sol b/packages/horizon/test/staking/delegation/undelegate.t.sol index c5d61b6e5..4cad2e0c3 100644 --- a/packages/horizon/test/staking/delegation/undelegate.t.sol +++ b/packages/horizon/test/staking/delegation/undelegate.t.sol @@ -17,7 +17,12 @@ contract HorizonStakingUndelegateTest is HorizonStakingTest { uint256 delegationAmount ) public useIndexer useProvision(amount, 0, 0) useDelegation(delegationAmount) { resetPrank(users.delegator); - DelegationInternal memory delegation = _getStorage_Delegation(users.indexer, subgraphDataServiceAddress, users.delegator, false); + DelegationInternal memory delegation = _getStorage_Delegation( + users.indexer, + subgraphDataServiceAddress, + users.delegator, + false + ); _undelegate(users.indexer, subgraphDataServiceAddress, delegation.shares); } @@ -31,7 +36,12 @@ contract HorizonStakingUndelegateTest is HorizonStakingTest { resetPrank(users.delegator); _delegate(users.indexer, subgraphDataServiceAddress, delegationAmount, 0); - DelegationInternal memory delegation = _getStorage_Delegation(users.indexer, subgraphDataServiceAddress, users.delegator, false); + DelegationInternal memory delegation = _getStorage_Delegation( + users.indexer, + subgraphDataServiceAddress, + users.delegator, + false + ); uint256 undelegateAmount = delegation.shares / undelegateSteps; for (uint i = 0; i < undelegateSteps; i++) { @@ -83,7 +93,12 @@ contract HorizonStakingUndelegateTest is HorizonStakingTest { uint256 overDelegationShares ) public useIndexer useProvision(amount, 0, 0) useDelegation(delegationAmount) { resetPrank(users.delegator); - DelegationInternal memory delegation = _getStorage_Delegation(users.indexer, subgraphDataServiceAddress, users.delegator, false); + DelegationInternal memory delegation = _getStorage_Delegation( + users.indexer, + subgraphDataServiceAddress, + users.delegator, + false + ); overDelegationShares = bound(overDelegationShares, delegation.shares + 1, MAX_STAKING_TOKENS + 1); bytes memory expectedError = abi.encodeWithSignature( @@ -103,7 +118,12 @@ contract HorizonStakingUndelegateTest is HorizonStakingTest { resetPrank(users.delegator); _delegate(users.indexer, delegationAmount); - DelegationInternal memory delegation = _getStorage_Delegation(users.indexer, subgraphDataServiceAddress, users.delegator, true); + DelegationInternal memory delegation = _getStorage_Delegation( + users.indexer, + subgraphDataServiceAddress, + users.delegator, + true + ); _undelegate(users.indexer, delegation.shares); } @@ -112,22 +132,98 @@ contract HorizonStakingUndelegateTest is HorizonStakingTest { uint256 delegationTokens ) public useIndexer useProvision(tokens, 0, 0) useDelegationSlashing() { delegationTokens = bound(delegationTokens, 1, MAX_STAKING_TOKENS); + resetPrank(users.delegator); _delegate(users.indexer, subgraphDataServiceAddress, delegationTokens, 0); + // slash all of the provision + delegation resetPrank(subgraphDataServiceAddress); _slash(users.indexer, subgraphDataServiceAddress, tokens + delegationTokens, 0); - + + // attempt to undelegate - should revert resetPrank(users.delegator); - DelegationInternal memory delegation = _getStorage_Delegation(users.indexer, subgraphDataServiceAddress, users.delegator, false); - vm.expectRevert(abi.encodeWithSelector( - IHorizonStakingMain.HorizonStakingInvalidDelegationPoolState.selector, + DelegationInternal memory delegation = _getStorage_Delegation( users.indexer, - subgraphDataServiceAddress - )); + subgraphDataServiceAddress, + users.delegator, + false + ); + vm.expectRevert( + abi.encodeWithSelector( + IHorizonStakingMain.HorizonStakingInvalidDelegationPoolState.selector, + users.indexer, + subgraphDataServiceAddress + ) + ); staking.undelegate(users.indexer, subgraphDataServiceAddress, delegation.shares); } + function testUndelegate_AfterRecoveringPool( + uint256 tokens, + uint256 delegationTokens + ) public useIndexer useProvision(tokens, 0, 0) useDelegationSlashing { + delegationTokens = bound(delegationTokens, 1, MAX_STAKING_TOKENS); + + // delegate + resetPrank(users.delegator); + _delegate(users.indexer, subgraphDataServiceAddress, delegationTokens, 0); + + // slash all of the provision + delegation + resetPrank(subgraphDataServiceAddress); + _slash(users.indexer, subgraphDataServiceAddress, tokens + delegationTokens, 0); + + // recover the delegation pool + resetPrank(users.indexer); + token.approve(address(staking), delegationTokens); + _addToDelegationPool(users.indexer, subgraphDataServiceAddress, delegationTokens); + + // undelegate -- should now work + DelegationInternal memory delegation = _getStorage_Delegation( + users.indexer, + subgraphDataServiceAddress, + users.delegator, + false + ); + resetPrank(users.delegator); + _undelegate(users.indexer, subgraphDataServiceAddress, delegation.shares); + } + + function testUndelegate_ThawingShares_AfterRecoveringPool() + public + useIndexer + useProvision(MAX_STAKING_TOKENS, 0, 0) + useDelegationSlashing + { + uint256 delegationTokens = MAX_STAKING_TOKENS / 10; + + // delegate + resetPrank(users.delegator); + _delegate(users.indexer, subgraphDataServiceAddress, delegationTokens, 0); + + // undelegate half shares so we have some thawing shares/tokens + DelegationInternal memory delegation = _getStorage_Delegation( + users.indexer, + subgraphDataServiceAddress, + users.delegator, + false + ); + resetPrank(users.delegator); + _undelegate(users.indexer, subgraphDataServiceAddress, delegation.shares / 2); + + // slash all of the provision + delegation + resetPrank(subgraphDataServiceAddress); + _slash(users.indexer, subgraphDataServiceAddress, MAX_STAKING_TOKENS + delegationTokens, 0); + + // recover the delegation pool + resetPrank(users.indexer); + token.approve(address(staking), delegationTokens); + _addToDelegationPool(users.indexer, subgraphDataServiceAddress, delegationTokens); + + // undelegate the rest + resetPrank(users.delegator); + _undelegate(users.indexer, subgraphDataServiceAddress, delegation.shares - delegation.shares / 2); + } + function testUndelegate_RevertIf_BeneficiaryIsZero( uint256 amount, uint256 delegationAmount diff --git a/packages/horizon/test/staking/delegation/withdraw.t.sol b/packages/horizon/test/staking/delegation/withdraw.t.sol index 866f49942..ecc4f160f 100644 --- a/packages/horizon/test/staking/delegation/withdraw.t.sol +++ b/packages/horizon/test/staking/delegation/withdraw.t.sol @@ -9,7 +9,6 @@ import { LinkedList } from "../../../contracts/libraries/LinkedList.sol"; import { HorizonStakingTest } from "../HorizonStaking.t.sol"; contract HorizonStakingWithdrawDelegationTest is HorizonStakingTest { - /* * MODIFIERS */ @@ -17,7 +16,12 @@ contract HorizonStakingWithdrawDelegationTest is HorizonStakingTest { modifier useUndelegate(uint256 shares) { vm.stopPrank(); vm.startPrank(users.delegator); - DelegationInternal memory delegation = _getStorage_Delegation(users.indexer, subgraphDataServiceAddress, users.delegator, false); + DelegationInternal memory delegation = _getStorage_Delegation( + users.indexer, + subgraphDataServiceAddress, + users.delegator, + false + ); shares = bound(shares, 1, delegation.shares); _undelegate(users.indexer, subgraphDataServiceAddress, shares); @@ -27,8 +31,8 @@ contract HorizonStakingWithdrawDelegationTest is HorizonStakingTest { /* * HELPERS */ - function _setupNewIndexer(uint256 tokens) private returns(address) { - (, address msgSender,) = vm.readCallers(); + function _setupNewIndexer(uint256 tokens) private returns (address) { + (, address msgSender, ) = vm.readCallers(); address newIndexer = createUser("newIndexer"); vm.startPrank(newIndexer); @@ -52,7 +56,11 @@ contract HorizonStakingWithdrawDelegationTest is HorizonStakingTest { useDelegation(delegationAmount) useUndelegate(withdrawShares) { - LinkedList.List memory thawingRequests = staking.getThawRequestList(users.indexer, subgraphDataServiceAddress, users.delegator); + LinkedList.List memory thawingRequests = staking.getThawRequestList( + users.indexer, + subgraphDataServiceAddress, + users.delegator + ); ThawRequest memory thawRequest = staking.getThawRequest(thawingRequests.tail); skip(thawRequest.thawingUntil + 1); @@ -62,12 +70,7 @@ contract HorizonStakingWithdrawDelegationTest is HorizonStakingTest { function testWithdrawDelegation_RevertWhen_NotThawing( uint256 delegationAmount - ) - public - useIndexer - useProvision(10_000_000 ether, 0, MAX_THAWING_PERIOD) - useDelegation(delegationAmount) - { + ) public useIndexer useProvision(10_000_000 ether, 0, MAX_THAWING_PERIOD) useDelegation(delegationAmount) { bytes memory expectedError = abi.encodeWithSignature("HorizonStakingNothingThawing()"); vm.expectRevert(expectedError); staking.withdrawDelegated(users.indexer, subgraphDataServiceAddress, address(0), 0, 0); @@ -100,7 +103,7 @@ contract HorizonStakingWithdrawDelegationTest is HorizonStakingTest { { uint256 previousBalance = token.balanceOf(users.delegator); _withdrawDelegated(users.indexer, subgraphDataServiceAddress, address(0), 0, 0); - + // Nothing changed since thawing period haven't finished uint256 newBalance = token.balanceOf(users.delegator); assertEq(newBalance, previousBalance); @@ -120,7 +123,7 @@ contract HorizonStakingWithdrawDelegationTest is HorizonStakingTest { uint256 previousBalance = token.balanceOf(users.delegator); _withdrawDelegated(users.indexer, subgraphDataServiceAddress, newIndexer, 0, 0); - + uint256 newBalance = token.balanceOf(users.delegator); assertEq(newBalance, previousBalance); @@ -134,10 +137,19 @@ contract HorizonStakingWithdrawDelegationTest is HorizonStakingTest { resetPrank(users.delegator); _delegate(users.indexer, delegationAmount); - DelegationInternal memory delegation = _getStorage_Delegation(users.indexer, subgraphDataServiceAddress, users.delegator, true); + DelegationInternal memory delegation = _getStorage_Delegation( + users.indexer, + subgraphDataServiceAddress, + users.delegator, + true + ); _undelegate(users.indexer, delegation.shares); - LinkedList.List memory thawingRequests = staking.getThawRequestList(users.indexer, subgraphDataServiceLegacyAddress, users.delegator); + LinkedList.List memory thawingRequests = staking.getThawRequestList( + users.indexer, + subgraphDataServiceLegacyAddress, + users.delegator + ); ThawRequest memory thawRequest = staking.getThawRequest(thawingRequests.tail); skip(thawRequest.thawingUntil + 1); @@ -149,26 +161,70 @@ contract HorizonStakingWithdrawDelegationTest is HorizonStakingTest { uint256 tokens, uint256 delegationTokens ) public useIndexer useProvision(tokens, 0, MAX_THAWING_PERIOD) useDelegationSlashing() { - delegationTokens = bound(delegationTokens, 1, MAX_STAKING_TOKENS); + delegationTokens = bound(delegationTokens, 2, MAX_STAKING_TOKENS); + resetPrank(users.delegator); _delegate(users.indexer, subgraphDataServiceAddress, delegationTokens, 0); - DelegationInternal memory delegation = _getStorage_Delegation(users.indexer, subgraphDataServiceAddress, users.delegator, false); - _undelegate(users.indexer, subgraphDataServiceAddress, delegation.shares); - skip(MAX_THAWING_PERIOD + 1); + // undelegate some shares + DelegationInternal memory delegation = _getStorage_Delegation( + users.indexer, + subgraphDataServiceAddress, + users.delegator, + false + ); + _undelegate(users.indexer, subgraphDataServiceAddress, delegation.shares / 2); + // slash all of the provision + delegation resetPrank(subgraphDataServiceAddress); _slash(users.indexer, subgraphDataServiceAddress, tokens + delegationTokens, 0); - + + // fast forward in time and attempt to withdraw + skip(MAX_THAWING_PERIOD + 1); resetPrank(users.delegator); - vm.expectRevert(abi.encodeWithSelector( - IHorizonStakingMain.HorizonStakingInvalidDelegationPoolState.selector, - users.indexer, - subgraphDataServiceAddress - )); + vm.expectRevert( + abi.encodeWithSelector( + IHorizonStakingMain.HorizonStakingInvalidDelegationPoolState.selector, + users.indexer, + subgraphDataServiceAddress + ) + ); staking.withdrawDelegated(users.indexer, subgraphDataServiceAddress, address(0), 0, 0); } + function testWithdrawDelegation_AfterRecoveringPool( + uint256 tokens + ) public useIndexer useProvision(tokens, 0, MAX_THAWING_PERIOD) useDelegationSlashing { + uint256 delegationTokens = MAX_STAKING_TOKENS / 10; + + // delegate + resetPrank(users.delegator); + _delegate(users.indexer, subgraphDataServiceAddress, delegationTokens, 0); + + // undelegate some shares + DelegationInternal memory delegation = _getStorage_Delegation( + users.indexer, + subgraphDataServiceAddress, + users.delegator, + false + ); + _undelegate(users.indexer, subgraphDataServiceAddress, delegation.shares / 2); + + // slash all of the provision + delegation + resetPrank(subgraphDataServiceAddress); + _slash(users.indexer, subgraphDataServiceAddress, tokens + delegationTokens, 0); + + // recover the delegation pool + resetPrank(users.indexer); + token.approve(address(staking), delegationTokens); + _addToDelegationPool(users.indexer, subgraphDataServiceAddress, delegationTokens); + + // fast forward in time and withdraw - this withdraw will net 0 tokens + skip(MAX_THAWING_PERIOD + 1); + resetPrank(users.delegator); + _withdrawDelegated(users.indexer, subgraphDataServiceAddress, address(0), 0, 0); + } + function testWithdrawDelegation_WithBeneficiary( uint256 delegationAmount, address beneficiary @@ -221,4 +277,4 @@ contract HorizonStakingWithdrawDelegationTest is HorizonStakingTest { vm.expectRevert(expectedError); staking.withdrawDelegated(users.indexer, subgraphDataServiceAddress, address(0), 0, 1); } -} \ No newline at end of file +} diff --git a/packages/horizon/test/staking/provision/deprovision.t.sol b/packages/horizon/test/staking/provision/deprovision.t.sol index 6d03c3f6e..3e53e735d 100644 --- a/packages/horizon/test/staking/provision/deprovision.t.sol +++ b/packages/horizon/test/staking/provision/deprovision.t.sol @@ -104,4 +104,49 @@ contract HorizonStakingDeprovisionTest is HorizonStakingTest { _deprovision(users.indexer, subgraphDataServiceAddress, 0); } + + function testDeprovision_AfterProvisionFullySlashed( + uint256 amount, + uint64 thawingPeriod, + uint256 thawAmount + ) public useIndexer useProvision(amount, 0, thawingPeriod) { + // thaw some funds so there are some shares and tokens thawing + thawAmount = bound(thawAmount, 1, amount); + _thaw(users.indexer, subgraphDataServiceAddress, thawAmount); + + // slash all of it + resetPrank(subgraphDataServiceAddress); + _slash(users.indexer, subgraphDataServiceAddress, amount, 0); + + // now deprovision + resetPrank(users.indexer); + _deprovision(users.indexer, subgraphDataServiceAddress, 0); + } + + function testDeprovision_AfterResetingThawingPool( + uint256 amount, + uint64 thawingPeriod, + uint256 thawAmount + ) public useIndexer useProvision(amount, 0, thawingPeriod) { + // thaw some funds so there are some shares and tokens thawing + thawAmount = bound(thawAmount, 1, amount); + _thaw(users.indexer, subgraphDataServiceAddress, thawAmount); + + // slash all of it + resetPrank(subgraphDataServiceAddress); + _slash(users.indexer, subgraphDataServiceAddress, amount, 0); + + // put some funds back in + resetPrank(users.indexer); + _stake(amount); + _addToProvision(users.indexer, subgraphDataServiceAddress, amount); + + // thaw some funds again + resetPrank(users.indexer); + _thaw(users.indexer, subgraphDataServiceAddress, thawAmount); + + // now deprovision + resetPrank(users.indexer); + _deprovision(users.indexer, subgraphDataServiceAddress, 0); + } } diff --git a/packages/horizon/test/staking/provision/thaw.t.sol b/packages/horizon/test/staking/provision/thaw.t.sol index bb0b3409d..9d0f2ff1c 100644 --- a/packages/horizon/test/staking/provision/thaw.t.sol +++ b/packages/horizon/test/staking/provision/thaw.t.sol @@ -98,4 +98,45 @@ contract HorizonStakingThawTest is HorizonStakingTest { vm.expectRevert(expectedError); staking.thaw(users.indexer, subgraphDataServiceAddress, thawAmount); } + + function testThaw_RevertWhen_ProvisionFullySlashed ( + uint256 amount, + uint64 thawingPeriod, + uint256 thawAmount + ) public useIndexer useProvision(amount, 0, thawingPeriod) { + thawAmount = bound(thawAmount, 1, amount); + + // slash all of it + resetPrank(subgraphDataServiceAddress); + _slash(users.indexer, subgraphDataServiceAddress, amount, 0); + + // Attempt to thaw on a provision that has been fully slashed + resetPrank(users.indexer); + bytes memory expectedError = abi.encodeWithSignature("HorizonStakingInsufficientTokens(uint256,uint256)", 0, thawAmount); + vm.expectRevert(expectedError); + staking.thaw(users.indexer, subgraphDataServiceAddress, thawAmount); + } + + function testThaw_AfterResetingThawingPool( + uint256 amount, + uint64 thawingPeriod, + uint256 thawAmount + ) public useIndexer useProvision(amount, 0, thawingPeriod) { + // thaw some funds so there are some shares thawing and tokens thawing + thawAmount = bound(thawAmount, 1, amount); + _thaw(users.indexer, subgraphDataServiceAddress, thawAmount); + + // slash all of it + resetPrank(subgraphDataServiceAddress); + _slash(users.indexer, subgraphDataServiceAddress, amount, 0); + + // put some funds back in + resetPrank(users.indexer); + _stake(amount); + _addToProvision(users.indexer, subgraphDataServiceAddress, amount); + + // we should be able to thaw again + resetPrank(users.indexer); + _thaw(users.indexer, subgraphDataServiceAddress, thawAmount); + } } diff --git a/packages/horizon/test/staking/slash/slash.t.sol b/packages/horizon/test/staking/slash/slash.t.sol index 66a645b7a..7c7933419 100644 --- a/packages/horizon/test/staking/slash/slash.t.sol +++ b/packages/horizon/test/staking/slash/slash.t.sol @@ -21,7 +21,7 @@ contract HorizonStakingSlashTest is HorizonStakingTest { slashTokens = bound(slashTokens, 1, tokens); uint256 maxVerifierTokens = (slashTokens * maxVerifierCut) / MAX_PPM; vm.assume(verifierCutAmount <= maxVerifierTokens); - + vm.startPrank(subgraphDataServiceAddress); _slash(users.indexer, subgraphDataServiceAddress, slashTokens, verifierCutAmount); } @@ -35,7 +35,7 @@ contract HorizonStakingSlashTest is HorizonStakingTest { slashTokens = bound(slashTokens, 1, tokens); uint256 maxVerifierTokens = (slashTokens * maxVerifierCut) / MAX_PPM; vm.assume(verifierCutAmount > maxVerifierTokens); - + vm.startPrank(subgraphDataServiceAddress); vm.assume(slashTokens > 0); bytes memory expectedError = abi.encodeWithSelector( @@ -89,15 +89,12 @@ contract HorizonStakingSlashTest is HorizonStakingTest { ) public useIndexer useProvision(tokens, MAX_PPM, 0) { delegationTokens = bound(delegationTokens, 0, MAX_STAKING_TOKENS); vm.assume(slashTokens > tokens + delegationTokens); - + vm.startPrank(subgraphDataServiceAddress); _slash(users.indexer, subgraphDataServiceAddress, slashTokens, 0); } - function testSlash_RevertWhen_NoProvision( - uint256 tokens, - uint256 slashTokens - ) public useIndexer useStake(tokens) { + function testSlash_RevertWhen_NoProvision(uint256 tokens, uint256 slashTokens) public useIndexer useStake(tokens) { vm.assume(slashTokens > 0); bytes memory expectedError = abi.encodeWithSelector( IHorizonStakingMain.HorizonStakingInsufficientTokens.selector, @@ -108,4 +105,39 @@ contract HorizonStakingSlashTest is HorizonStakingTest { vm.startPrank(subgraphDataServiceAddress); staking.slash(users.indexer, slashTokens, 0, subgraphDataServiceAddress); } -} \ No newline at end of file + + function testSlash_Everything( + uint256 tokens, + uint256 delegationTokens + ) public useIndexer useProvision(tokens, MAX_PPM, 0) useDelegationSlashing { + delegationTokens = bound(delegationTokens, 1, MAX_STAKING_TOKENS); + + resetPrank(users.delegator); + _delegate(users.indexer, subgraphDataServiceAddress, delegationTokens, 0); + + vm.startPrank(subgraphDataServiceAddress); + _slash(users.indexer, subgraphDataServiceAddress, tokens + delegationTokens, 0); + } + + function testSlash_Everything_WithUndelegation( + uint256 tokens + ) public useIndexer useProvision(tokens, MAX_PPM, 0) useDelegationSlashing { + uint256 delegationTokens = MAX_STAKING_TOKENS / 10; + + resetPrank(users.delegator); + _delegate(users.indexer, subgraphDataServiceAddress, delegationTokens, 0); + + // undelegate half shares so we have some thawing shares/tokens + DelegationInternal memory delegation = _getStorage_Delegation( + users.indexer, + subgraphDataServiceAddress, + users.delegator, + false + ); + resetPrank(users.delegator); + _undelegate(users.indexer, subgraphDataServiceAddress, delegation.shares / 2); + + vm.startPrank(subgraphDataServiceAddress); + _slash(users.indexer, subgraphDataServiceAddress, tokens + delegationTokens, 0); + } +}