diff --git a/packages/horizon/contracts/staking/HorizonStaking.sol b/packages/horizon/contracts/staking/HorizonStaking.sol index 7040ac343..70a7e2851 100644 --- a/packages/horizon/contracts/staking/HorizonStaking.sol +++ b/packages/horizon/contracts/staking/HorizonStaking.sol @@ -340,6 +340,16 @@ contract HorizonStaking is HorizonStakingBase, IHorizonStakingMain { ); } + /// @inheritdoc IHorizonStakingMain + function releaseThawedDelegation( + address serviceProvider, + address verifier, + address delegator, + uint256 nThawRequests + ) external override notPaused returns (uint256) { + return _releaseThawedDelegation(serviceProvider, verifier, delegator, nThawRequests); + } + /// @inheritdoc IHorizonStakingMain function setDelegationFeeCut( address serviceProvider, @@ -919,8 +929,9 @@ contract HorizonStaking is HorizonStakingBase, IHorizonStakingMain { // Calculate thawing shares to issue - convert delegation pool shares to thawing pool shares // delegation pool shares -> delegation pool tokens -> thawing pool shares + // Active tokens exclude both in-period thawing and completed-but-not-withdrawn (withdrawable) tokens. // Thawing pool is reset/initialized when the pool is empty: prov.tokensThawing == 0 - uint256 tokens = (_shares * (pool.tokens - pool.tokensThawing)) / pool.shares; + uint256 tokens = (_shares * (pool.tokens - pool.tokensThawing - pool.tokensWithdrawable)) / pool.shares; // Thawing shares are rounded down to protect the pool and avoid taking extra tokens from other participants. uint256 thawingShares = pool.tokensThawing == 0 ? tokens : ((tokens * pool.sharesThawing) / pool.tokensThawing); @@ -983,36 +994,117 @@ contract HorizonStaking is HorizonStakingBase, IHorizonStakingMain { HorizonStakingInvalidDelegationPoolState(_serviceProvider, _verifier) ); - uint256 tokensThawed = 0; - uint256 sharesThawing = pool.sharesThawing; - uint256 tokensThawing = pool.tokensThawing; + // Release any completed thaw requests into the withdrawable bucket first. + // This covers the common case where the delegator calls withdrawDelegated directly + // without having called releaseThawedDelegation beforehand. + _releaseThawedDelegation(_serviceProvider, _verifier, msg.sender, _nThawRequests); - FulfillThawRequestsParams memory params = FulfillThawRequestsParams({ - requestType: ThawRequestType.Delegation, - serviceProvider: _serviceProvider, - verifier: _verifier, - owner: msg.sender, - tokensThawing: tokensThawing, - sharesThawing: sharesThawing, - nThawRequests: _nThawRequests, - thawingNonce: pool.thawingNonce - }); - (tokensThawed, tokensThawing, sharesThawing) = _fulfillThawRequests(params); + // Drain the caller's withdrawable balance. + DelegationInternal storage delegation = pool.delegators[msg.sender]; + uint256 tokensThawed = delegation.tokensReleasedPendingWithdrawal; + require(tokensThawed != 0, HorizonStakingNothingThawing()); - // 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. + // Update pool state. These subtractions are safe: + // pool.tokens >= pool.tokensWithdrawable and pool.tokensWithdrawable >= tokensThawed + // (enforced by _releaseThawedDelegation accumulation). pool.tokens = pool.tokens - tokensThawed; - pool.sharesThawing = sharesThawing; - pool.tokensThawing = tokensThawing; + pool.tokensWithdrawable = pool.tokensWithdrawable - tokensThawed; + delegation.tokensReleasedPendingWithdrawal = 0; - if (tokensThawed != 0) { - if (_newServiceProvider != address(0) && _newVerifier != address(0)) { - _delegate(_newServiceProvider, _newVerifier, tokensThawed, _minSharesForNewProvider); - } else { - _graphToken().pushTokens(msg.sender, tokensThawed); - emit DelegatedTokensWithdrawn(_serviceProvider, _verifier, msg.sender, tokensThawed); + if (_newServiceProvider != address(0) && _newVerifier != address(0)) { + _delegate(_newServiceProvider, _newVerifier, tokensThawed, _minSharesForNewProvider); + } else { + _graphToken().pushTokens(msg.sender, tokensThawed); + emit DelegatedTokensWithdrawn(_serviceProvider, _verifier, msg.sender, tokensThawed); + } + } + + /** + * @notice Move completed delegation thaw requests for `_delegator` into the withdrawable bucket. + * @dev Traverses the thaw request linked list, processes every request whose `thawingUntil` + * has passed (up to `_nThawRequests`, or all if 0), removes each from the list, and updates + * `pool.tokensThawing`, `pool.sharesThawing`, `pool.tokensWithdrawable`, and + * `delegation.tokensReleasedPendingWithdrawal`. + * + * Emits {DelegationThawReleased} if any requests were released. + * + * @param _serviceProvider The service provider address + * @param _verifier The verifier address + * @param _delegator The delegator whose thaw requests to release + * @param _nThawRequests Max requests to process. 0 = release all completed ones. + * @return tokensReleased Total tokens moved into the withdrawable bucket + */ + function _releaseThawedDelegation( + address _serviceProvider, + address _verifier, + address _delegator, + uint256 _nThawRequests + ) private returns (uint256 tokensReleased) { + DelegationPoolInternal storage pool = _getDelegationPool(_serviceProvider, _verifier); + ILinkedList.List storage thawRequestList = _getThawRequestList( + ThawRequestType.Delegation, + _serviceProvider, + _verifier, + _delegator + ); + + if (thawRequestList.count == 0) { + return 0; + } + + uint256 tokensThawing = pool.tokensThawing; + uint256 sharesThawing = pool.sharesThawing; + uint256 thawingNonce = pool.thawingNonce; + uint256 requestsReleased = 0; + tokensReleased = 0; + + bytes32 thawRequestId = thawRequestList.head; + while (thawRequestId != bytes32(0)) { + if (_nThawRequests != 0 && requestsReleased >= _nThawRequests) { + break; } + + ThawRequest storage thawRequest = _getThawRequest(ThawRequestType.Delegation, thawRequestId); + bytes32 nextId = thawRequest.nextRequest; + + if (thawRequest.thawingUntil > block.timestamp) { + // Thaw requests are ordered by creation time; the first unexpired request + // means all remaining ones are also unexpired. + break; + } + + if (thawRequest.thawingNonce == thawingNonce) { + // sharesThawing is non-zero whenever valid thaw requests exist. + uint256 tokens = (thawRequest.shares * tokensThawing) / sharesThawing; + tokensThawing -= tokens; + sharesThawing -= thawRequest.shares; + tokensReleased += tokens; + } + + // Remove request from list and storage regardless of nonce validity. + thawRequestList.count -= 1; + if (thawRequestId == thawRequestList.head) { + thawRequestList.head = nextId; + } + if (thawRequestId == thawRequestList.tail) { + thawRequestList.tail = bytes32(0); + } + delete _thawRequests[ThawRequestType.Delegation][thawRequestId]; + + requestsReleased++; + thawRequestId = nextId; + } + + if (tokensReleased == 0) { + return 0; } + + pool.tokensThawing = tokensThawing; + pool.sharesThawing = sharesThawing; + pool.tokensWithdrawable += tokensReleased; + pool.delegators[_delegator].tokensReleasedPendingWithdrawal += tokensReleased; + + emit DelegationThawReleased(_serviceProvider, _verifier, _delegator, requestsReleased, tokensReleased); } /** diff --git a/packages/horizon/contracts/staking/HorizonStakingBase.sol b/packages/horizon/contracts/staking/HorizonStakingBase.sol index 615de4994..d46813cb9 100644 --- a/packages/horizon/contracts/staking/HorizonStakingBase.sol +++ b/packages/horizon/contracts/staking/HorizonStakingBase.sol @@ -86,6 +86,7 @@ abstract contract HorizonStakingBase is pool.shares = poolInternal.shares; pool.tokensThawing = poolInternal.tokensThawing; pool.sharesThawing = poolInternal.sharesThawing; + pool.tokensWithdrawable = poolInternal.tokensWithdrawable; pool.thawingNonce = poolInternal.thawingNonce; return pool; } @@ -148,6 +149,14 @@ abstract contract HorizonStakingBase is return _getDelegatedTokensAvailable(serviceProvider, verifier); } + /// @inheritdoc IHorizonStakingBase + function getDelegatedTokensWithdrawable( + address serviceProvider, + address verifier + ) external view override returns (uint256) { + return _getDelegationPool(serviceProvider, verifier).tokensWithdrawable; + } + /// @inheritdoc IHorizonStakingBase function getThawRequest( ThawRequestType requestType, @@ -355,6 +364,6 @@ abstract contract HorizonStakingBase is */ function _getDelegatedTokensAvailable(address _serviceProvider, address _verifier) private view returns (uint256) { DelegationPoolInternal storage poolInternal = _getDelegationPool(_serviceProvider, _verifier); - return poolInternal.tokens - poolInternal.tokensThawing; + return poolInternal.tokens - poolInternal.tokensThawing - poolInternal.tokensWithdrawable; } } diff --git a/packages/interfaces/contracts/horizon/internal/IHorizonStakingBase.sol b/packages/interfaces/contracts/horizon/internal/IHorizonStakingBase.sol index c48f20099..82b2cce99 100644 --- a/packages/interfaces/contracts/horizon/internal/IHorizonStakingBase.sol +++ b/packages/interfaces/contracts/horizon/internal/IHorizonStakingBase.sol @@ -136,14 +136,28 @@ interface IHorizonStakingBase { function getProviderTokensAvailable(address serviceProvider, address verifier) external view returns (uint256); /** - * @notice Gets the delegator's tokens available in a provision. - * @dev Calculated as the tokens available minus the tokens thawing. + * @notice Gets the actively-earning delegated tokens in a pool. + * @dev Calculated as `pool.tokens - pool.tokensThawing - pool.tokensWithdrawable`. + * This is the correct denominator for APR/APY calculations — it excludes both + * in-period thaw requests and completed-but-not-yet-withdrawn delegation. * @param serviceProvider The address of the service provider. * @param verifier The address of the verifier. - * @return The amount of tokens available. + * @return The amount of actively-earning delegated tokens. */ function getDelegatedTokensAvailable(address serviceProvider, address verifier) external view returns (uint256); + /** + * @notice Gets the pool-level total of delegation tokens that have completed thawing + * but have not yet been withdrawn by delegators. + * @dev Updated via {releaseThawedDelegation} or lazily during {withdrawDelegated}. + * These tokens do not earn rewards. Dashboards should subtract this from `delegatedTokens` + * (along with `tokensThawing`) to derive the actively-earning delegation base. + * @param serviceProvider The address of the service provider. + * @param verifier The address of the verifier. + * @return The amount of tokens pending withdrawal. + */ + function getDelegatedTokensWithdrawable(address serviceProvider, address verifier) external view returns (uint256); + /** * @notice Gets a thaw request. * @param thawRequestType The type of thaw request. diff --git a/packages/interfaces/contracts/horizon/internal/IHorizonStakingMain.sol b/packages/interfaces/contracts/horizon/internal/IHorizonStakingMain.sol index 19c1e1cf8..6fb6aebaa 100644 --- a/packages/interfaces/contracts/horizon/internal/IHorizonStakingMain.sol +++ b/packages/interfaces/contracts/horizon/internal/IHorizonStakingMain.sol @@ -217,6 +217,27 @@ interface IHorizonStakingMain { uint256 tokens ); + /** + * @notice Emitted when completed (fully-thawed) delegation thaw requests are moved into + * the withdrawable bucket via {releaseThawedDelegation}. + * @dev After this event fires, `tokensWithdrawable` on the pool increases by `tokens` and + * `tokensThawing` decreases by the same amount. The tokens are still in the pool — they have + * not been transferred. A subsequent call to {withdrawDelegated} is required to move them + * to the delegator's wallet. + * @param serviceProvider The address of the service provider + * @param verifier The address of the verifier + * @param delegator The address of the delegator whose thaw requests were released + * @param thawRequestsReleased The number of thaw requests moved to withdrawable + * @param tokens The total tokens moved to withdrawable + */ + event DelegationThawReleased( + address indexed serviceProvider, + address indexed verifier, + address indexed delegator, + uint256 thawRequestsReleased, + uint256 tokens + ); + /** * @notice Emitted when `delegator` withdrew delegated `tokens` from `indexer` using `withdrawDelegated`. * @dev This event is for the legacy `withdrawDelegated` function. @@ -816,6 +837,38 @@ interface IHorizonStakingMain { */ function withdrawDelegated(address serviceProvider, address verifier, uint256 nThawRequests) external; + /** + * @notice Move completed delegation thaw requests into the withdrawable bucket without transferring tokens. + * @dev This is a permissionless state-update function — anyone may call it for any delegator. + * It traverses `delegator`'s thaw request list for `(serviceProvider, verifier)`, finds every + * request whose `thawingUntil` has passed, removes it from the linked list, decrements + * `pool.tokensThawing` / `pool.sharesThawing`, and increments `pool.tokensWithdrawable` and + * `delegation.tokensReleasedPendingWithdrawal` by the corresponding token amount. + * + * Calling this before {withdrawDelegated} is optional — {withdrawDelegated} performs the same + * release step internally. Its primary use-case is to let bots or dashboards keep pool state + * current so that `tokensThawing` accurately reflects only in-period thaw requests and + * `tokensWithdrawable` accurately reflects completed-but-not-yet-withdrawn delegation. + * + * Requirements: + * - `delegator` must have at least one thaw request in the list. + * - At least one thaw request must have already expired. + * + * Emits {DelegationThawReleased}. + * + * @param serviceProvider The service provider address + * @param verifier The verifier address + * @param delegator The delegator whose completed thaw requests to release + * @param nThawRequests Max thaw requests to release. Set to 0 to release all completed ones. + * @return Total tokens moved into the withdrawable bucket + */ + function releaseThawedDelegation( + address serviceProvider, + address verifier, + address delegator, + uint256 nThawRequests + ) external returns (uint256); + /** * @notice Re-delegate undelegated tokens from a provision after thawing to a `newServiceProvider` and `newVerifier`. * @dev The parameter `nThawRequests` can be set to a non zero value to fulfill a specific number of thaw diff --git a/packages/interfaces/contracts/horizon/internal/IHorizonStakingTypes.sol b/packages/interfaces/contracts/horizon/internal/IHorizonStakingTypes.sol index e8fff211b..ae4f6685c 100644 --- a/packages/interfaces/contracts/horizon/internal/IHorizonStakingTypes.sol +++ b/packages/interfaces/contracts/horizon/internal/IHorizonStakingTypes.sol @@ -74,8 +74,11 @@ interface IHorizonStakingTypes { * @dev See {DelegationPoolInternal} for the actual storage representation * @param tokens Total tokens as pool reserves * @param shares Total shares minted in the pool - * @param tokensThawing Tokens thawing in the pool + * @param tokensThawing Tokens whose thaw period has not yet expired * @param sharesThawing Shares representing the thawing tokens + * @param tokensWithdrawable Tokens whose thaw period has expired and are pending explicit withdrawal. + * These do not earn rewards. Use `tokens - tokensThawing - tokensWithdrawable` for the + * actively-earning delegation base. * @param thawingNonce Value of the current thawing nonce. Thaw requests with older nonces are invalid. */ struct DelegationPool { @@ -83,6 +86,7 @@ interface IHorizonStakingTypes { uint256 shares; uint256 tokensThawing; uint256 sharesThawing; + uint256 tokensWithdrawable; uint256 thawingNonce; } @@ -97,8 +101,10 @@ interface IHorizonStakingTypes { * @param tokens Total tokens as pool reserves * @param shares Total shares minted in the pool * @param delegators Delegation details by delegator - * @param tokensThawing Tokens thawing in the pool + * @param tokensThawing Tokens whose thaw period has not yet expired * @param sharesThawing Shares representing the thawing tokens + * @param tokensWithdrawable Tokens whose thaw period has expired and are pending explicit withdrawal. + * These do not earn rewards. Updated via {releaseThawedDelegation} or lazily during {withdrawDelegated}. * @param thawingNonce Value of the current thawing nonce. Thaw requests with older nonces are invalid. */ struct DelegationPoolInternal { @@ -111,6 +117,7 @@ interface IHorizonStakingTypes { mapping(address delegator => DelegationInternal delegation) delegators; uint256 tokensThawing; uint256 sharesThawing; + uint256 tokensWithdrawable; uint256 thawingNonce; } @@ -130,11 +137,14 @@ interface IHorizonStakingTypes { * @param shares Shares owned by the delegator in the pool * @param __DEPRECATED_tokensLocked Tokens locked for undelegation * @param __DEPRECATED_tokensLockedUntil Epoch when locked tokens can be withdrawn + * @param tokensReleasedPendingWithdrawal Per-delegator tally of tokens moved to the withdrawable + * bucket via {releaseThawedDelegation}. Reset to zero when {withdrawDelegated} is called. */ struct DelegationInternal { uint256 shares; uint256 __DEPRECATED_tokensLocked; uint256 __DEPRECATED_tokensLockedUntil; + uint256 tokensReleasedPendingWithdrawal; } /**