Skip to content

indexer and provision delegationExchangeRate is inflated between undelegate and withdraw #327

@warfollowsme

Description

@warfollowsme

Description

In the handleTokensUndelegated handler in src/mappings/horizonStaking.ts, when a delegator undelegates, delegatorShares is decreased immediately but delegatedTokens is not. The tokens are only subtracted from delegatedTokens later, when handleDelegatedTokensWithdrawn fires.

However, delegationExchangeRate is recalculated right after shares are reduced, using the formula:

delegationExchangeRate = delegatedTokens / delegatorShares

Since delegatedTokens remains unchanged while delegatorShares has decreased, the resulting exchange rate is artificially inflated for the entire duration between the undelegate and withdraw events (i.e., the full thawing period).

Impact

  • Any consumer of the subgraph that relies on delegationExchangeRate to compute the current value of a delegation position will overestimate the value of remaining delegators' positions during the thawing period.
  • This affects both the Indexer-level and Provision-level exchange rates.
  • The field delegatedThawingTokens is correctly tracked and incremented during undelegation, but it is not used in the exchange rate calculation.

Steps to Reproduce

  1. Delegator A and Delegator B each delegate 1000 GRT to an indexer (assume 1:1 exchange rate, 1000 shares each).
  2. Pool state: delegatedTokens = 2000, delegatorShares = 2000, exchangeRate = 1.0.
  3. Delegator A calls undelegate for all 1000 shares. The event TokensUndelegated fires with shares = 1000, tokens = 1000.
  4. After the handler runs:
    • delegatorShares = 1000 (reduced)
    • delegatedTokens = 2000 (unchanged)
    • delegatedThawingTokens = 1000 (increased)
    • delegationExchangeRate = 2000 / 1000 = 2.0 (inflated)
  5. Delegator B's position now appears to be worth 1000 shares * 2.0 rate = 2000 GRT instead of the correct 1000 GRT.
  6. Only when Delegator A calls withdraw and DelegatedTokensWithdrawn fires does delegatedTokens drop to 1000 and the rate normalizes back to 1.0.

Expected Behavior

The exchange rate should reflect the actual claimable value per share. Tokens that are pending withdrawal (thawing) are no longer backed by any shares and should be excluded from the exchange rate calculation.

Suggested Fix

1. Fix exchange rate formulas in src/mappings/helpers/helpers.ts

The updateDelegationExchangeRate and updateDelegationExchangeRateForProvision functions in src/mappings/helpers/helpers.ts should subtract delegatedThawingTokens from delegatedTokens before dividing by shares:

export function updateDelegationExchangeRate(indexer: Indexer): Indexer {
  indexer.delegationExchangeRate = indexer.delegatedTokens
    .minus(indexer.delegatedThawingTokens)
    .toBigDecimal()
    .div(indexer.delegatorShares.toBigDecimal())
    .truncate(18)
  return indexer as Indexer
}
export function updateDelegationExchangeRateForProvision(provision: Provision): Provision {
  provision.delegationExchangeRate = provision.delegatedTokens
    .minus(provision.delegatedThawingTokens)
    .toBigDecimal()
    .div(provision.delegatorShares.toBigDecimal())
    .truncate(18)
  return provision as Provision
}

This ensures that tokens in the thawing state — which are already "claimed" by the undelegating participant and no longer represented by any shares — do not inflate the exchange rate for remaining pool participants.

2. Fix field update order in handleTokensUndelegated

Current code (provision):

provision.delegatorShares = provision.delegatorShares.minus(event.params.shares)
if (provision.delegatorShares != BigInt.fromI32(0)) {
    provision = updateDelegationExchangeRateForProvision(provision as Provision)
}
provision.delegatedThawingTokens = provision.delegatedThawingTokens.plus(event.params.tokens) // too late

Fixed code (provision):

provision.delegatorShares = provision.delegatorShares.minus(event.params.shares)
provision.delegatedThawingTokens = provision.delegatedThawingTokens.plus(event.params.tokens) // moved before rate calc
if (provision.delegatorShares != BigInt.fromI32(0)) {
    provision = updateDelegationExchangeRateForProvision(provision as Provision)
}

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions