Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
142 changes: 117 additions & 25 deletions packages/horizon/contracts/staking/HorizonStaking.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
}

/**
Expand Down
11 changes: 10 additions & 1 deletion packages/horizon/contracts/staking/HorizonStakingBase.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,15 +74,19 @@ 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 {
uint256 tokens;
uint256 shares;
uint256 tokensThawing;
uint256 sharesThawing;
uint256 tokensWithdrawable;
uint256 thawingNonce;
}

Expand All @@ -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 {
Expand All @@ -111,6 +117,7 @@ interface IHorizonStakingTypes {
mapping(address delegator => DelegationInternal delegation) delegators;
uint256 tokensThawing;
uint256 sharesThawing;
uint256 tokensWithdrawable;
uint256 thawingNonce;
}

Expand All @@ -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;
}

/**
Expand Down
Loading