Skip to content

feat(horizon): add delegation withdrawable bucket (tokensWithdrawable)#1340

Open
cargopete wants to merge 1 commit into
graphprotocol:mainfrom
cargopete:feat/delegation-withdrawable-bucket
Open

feat(horizon): add delegation withdrawable bucket (tokensWithdrawable)#1340
cargopete wants to merge 1 commit into
graphprotocol:mainfrom
cargopete:feat/delegation-withdrawable-bucket

Conversation

@cargopete
Copy link
Copy Markdown
Contributor

Summary

Implements the "withdrawable bucket" design proposed by PaulieB in the Graph Forum (Accurately Representing Delegated GRT in APR/APY Calculations), which addresses a distortion in delegation APR/APY metrics caused by thawed-but-not-yet-withdrawn tokens remaining in the delegation pool denominator.

The Problem

When a delegator calls undelegate, their tokens enter a 28-day thaw period. After the thaw completes, the tokens still sit in pool.tokens until withdrawDelegated is explicitly called. This means:

  • pool.tokens includes tokens that no longer earn rewards
  • APR/APY calculations that divide rewards by pool.tokens are systematically depressed
  • The longer withdrawals are delayed, the worse the distortion

The Fix

This PR introduces a pool-level tokensWithdrawable bucket — a running total of delegation that has completed thawing but hasn't been withdrawn yet. A new permissionless function releaseThawedDelegation allows anyone (bots, dashboards, delegators) to scan expired thaw requests and move them into this bucket without transferring tokens.

The key formula change:

// Before
getDelegatedTokensAvailable = pool.tokens - pool.tokensThawing

// After  
getDelegatedTokensAvailable = pool.tokens - pool.tokensThawing - pool.tokensWithdrawable

This correctly reflects only the actively-earning delegation base for APR/APY and capacity calculations.

Changes

IHorizonStakingTypes.sol

  • DelegationPool (public struct): add tokensWithdrawable
  • DelegationPoolInternal: add tokensWithdrawable
  • DelegationInternal: add tokensReleasedPendingWithdrawal (per-delegator tally)

IHorizonStakingMain.sol

  • New DelegationThawReleased event
  • New releaseThawedDelegation(serviceProvider, verifier, delegator, nThawRequests) external function

IHorizonStakingBase.sol

  • Updated getDelegatedTokensAvailable NatSpec to document new formula
  • New getDelegatedTokensWithdrawable view getter

HorizonStakingBase.sol

  • getDelegationPool: expose tokensWithdrawable in public return value
  • getDelegatedTokensWithdrawable: implemented
  • _getDelegatedTokensAvailable: updated formula

HorizonStaking.sol

  • _undelegate: active-base fix — exclude tokensWithdrawable when computing share → token conversion
  • _withdrawDelegated: calls _releaseThawedDelegation first (handles the common case where delegators skip the explicit release step), then drains delegation.tokensReleasedPendingWithdrawal
  • New releaseThawedDelegation external function (permissionless wrapper)
  • New _releaseThawedDelegation private implementation

Backward Compatibility

  • Existing delegators do not need to change their workflow — withdrawDelegated calls _releaseThawedDelegation internally as a lazy first step
  • releaseThawedDelegation is a pure value-add: bots or dashboards can call it to keep tokensWithdrawable accurate before APR/APY snapshots
  • No changes to existing function signatures

Testing Checklist

  • _releaseThawedDelegation processes expired requests and skips unexpired ones
  • pool.tokensWithdrawable is correctly incremented/decremented across release → withdraw lifecycle
  • pool.tokensThawing / sharesThawing are correctly decremented on release
  • delegation.tokensReleasedPendingWithdrawal is zeroed after withdrawDelegated
  • getDelegatedTokensAvailable returns tokens - tokensThawing - tokensWithdrawable
  • _undelegate share-value calculation uses corrected active base
  • Stale (wrong nonce) thaw requests are removed from list but don't accrue tokens
  • releaseThawedDelegation is callable by any address (permissionless)
  • withdrawDelegated with no prior releaseThawedDelegation call still works end-to-end

Ref: https://forum.thegraph.com/t/accurately-representing-delegated-grt-in-apr-apy-calculations/6924

…hdrawable tokens

Introduces pool-level  and per-delegator
 to track delegation that has completed
thawing but not yet been withdrawn.  Adds a permissionless
 so bots and dashboards can maintain accurate
pool accounting without requiring each delegator to call
 first.

Key changes:
- DelegationPool / DelegationPoolInternal: add tokensWithdrawable field
- DelegationInternal: add tokensReleasedPendingWithdrawal field
- _getDelegatedTokensAvailable: tokens - tokensThawing - tokensWithdrawable
- _undelegate: use updated active-base formula when converting shares
- _withdrawDelegated: call _releaseThawedDelegation, drain per-delegator tally
- New _releaseThawedDelegation (private) + releaseThawedDelegation (external)
- New DelegationThawReleased event
- New getDelegatedTokensWithdrawable view getter
@openzeppelin-code
Copy link
Copy Markdown

feat(horizon): add delegation withdrawable bucket (tokensWithdrawable)

Generated at commit: d6920bc2c9ab9f8cbabf21be8b2987285a952235

🚨 Report Summary

Severity Level Results
Contracts Critical
High
Medium
Low
Note
Total
3
5
0
14
37
59
Dependencies Critical
High
Medium
Low
Note
Total
0
0
0
0
0
0

For more details view the full report in OpenZeppelin Code Inspector

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant