From 4924c4c002d95cb9d11002c93a2d2afdfd623617 Mon Sep 17 00:00:00 2001 From: Juan Manuel Rodriguez Defago Date: Tue, 10 Nov 2026 18:12:01 -0300 Subject: [PATCH] fix(staking): reconcile active stake and thawing balances after slashing Exclude idle stake from Horizon Indexer ratios and overdelegation dilution by using provisionedTokens minus thawingTokens. Preserve the existing stake basis explicitly for legacy metric calculations, including indexers that retain their legacy flag after migrating to Horizon. Mirror the contracts' proportional reduction of thawing balances when provider stake or delegation is slashed, including integer rounding. Update the corresponding Indexer and network/service aggregates before recalculating metrics, and refresh the delegation exchange rates. Network and service delegation totals already exclude thawing tokens, so subtract only the active portion of a delegation slash from those totals. Keep the full slash amount in gross balances and cumulative slashing totals. Add 12 regression tests covering idle stake, deprovisioning, partial and full slashes, rounding, aggregate balances, exchange rates, and legacy metrics. Extend the existing deprovisioning test to check Indexer cuts. Validation: - L1 and L2 codegen and builds passed. - All 144 tests passed under each configuration. - Targeted ESLint passed with four existing unused-variable warnings. - git diff --check passed. --- src/mappings/helpers/helpers.ts | 22 +-- src/mappings/horizonStaking.ts | 33 +++- tests/factories/horizonStaking.ts | 66 +++++++ tests/horizonStaking.test.ts | 286 +++++++++++++++++++++++++++++- 4 files changed, 389 insertions(+), 18 deletions(-) diff --git a/src/mappings/helpers/helpers.ts b/src/mappings/helpers/helpers.ts index 7c446c23..e09f1bef 100644 --- a/src/mappings/helpers/helpers.ts +++ b/src/mappings/helpers/helpers.ts @@ -1010,10 +1010,12 @@ function max(a: BigDecimal, b: BigDecimal): BigDecimal { return a > b ? a : b } -// Own stake that still backs the indexer: excludes legacy locked (unstaking) tokens and -// Horizon provision tokens that are thawing. Both are zero when they don't apply. -export function getActiveIndexerStake(indexer: Indexer): BigInt { - return indexer.stakedTokens.minus(indexer.lockedTokens).minus(indexer.thawingTokens) +// Horizon backing excludes idle stake. Legacy handlers retain their previous stake basis; +// isLegacy cannot select it because migrated indexers can still carry that flag. +export function getActiveIndexerStake(indexer: Indexer, useLegacyStake: boolean = false): BigInt { + return useLegacyStake + ? indexer.stakedTokens.minus(indexer.lockedTokens).minus(indexer.thawingTokens) + : indexer.provisionedTokens.minus(indexer.thawingTokens) } // Delegation that still earns rewards and fees. Horizon keeps undelegated tokens in the pool @@ -1022,8 +1024,8 @@ export function getActiveIndexerDelegation(indexer: Indexer): BigInt { return indexer.delegatedTokens.minus(indexer.delegatedThawingTokens) } -export function calculateOwnStakeRatio(indexer: Indexer): BigDecimal { - let stakedTokensBD = getActiveIndexerStake(indexer).toBigDecimal() +export function calculateOwnStakeRatio(indexer: Indexer, useLegacyStake: boolean = false): BigDecimal { + let stakedTokensBD = getActiveIndexerStake(indexer, useLegacyStake).toBigDecimal() let delegatedTokensBD = getActiveIndexerDelegation(indexer).toBigDecimal() let graphNetwork = loadGraphNetwork() let delegationRatioBD = BigInt.fromI32(graphNetwork.delegationRatio).toBigDecimal() @@ -1088,8 +1090,8 @@ export function calculateLegacyIndexerRewardOwnGenerationRatio(indexer: Indexer) : rewardCut / indexer.ownStakeRatio } -export function calculateOverdelegationDilution(indexer: Indexer): BigDecimal { - let stakedTokensBD = getActiveIndexerStake(indexer).toBigDecimal() +export function calculateOverdelegationDilution(indexer: Indexer, useLegacyStake: boolean = false): BigDecimal { + let stakedTokensBD = getActiveIndexerStake(indexer, useLegacyStake).toBigDecimal() let delegatedTokensBD = getActiveIndexerDelegation(indexer).toBigDecimal() let graphNetwork = loadGraphNetwork() let delegationRatioBD = BigInt.fromI32(graphNetwork.delegationRatio).toBigDecimal() @@ -1113,14 +1115,14 @@ export function updateAdvancedIndexerMetrics(indexer: Indexer): Indexer { } export function updateLegacyAdvancedIndexerMetrics(indexer: Indexer): Indexer { - indexer.ownStakeRatio = calculateOwnStakeRatio(indexer as Indexer) + indexer.ownStakeRatio = calculateOwnStakeRatio(indexer as Indexer, true) indexer.delegatedStakeRatio = calculateDelegatedStakeRatio(indexer as Indexer) indexer.legacyIndexingRewardEffectiveCut = calculateLegacyIndexingRewardEffectiveCut(indexer as Indexer) indexer.legacyQueryFeeEffectiveCut = calculateLegacyQueryFeeEffectiveCut(indexer as Indexer) indexer.legacyIndexerRewardsOwnGenerationRatio = calculateLegacyIndexerRewardOwnGenerationRatio( indexer as Indexer, ) - indexer.overDelegationDilution = calculateOverdelegationDilution(indexer as Indexer) + indexer.overDelegationDilution = calculateOverdelegationDilution(indexer as Indexer, true) return indexer as Indexer } diff --git a/src/mappings/horizonStaking.ts b/src/mappings/horizonStaking.ts index b3b69960..b6e51133 100644 --- a/src/mappings/horizonStaking.ts +++ b/src/mappings/horizonStaking.ts @@ -258,22 +258,29 @@ export function handleProvisionSlashed(event: ProvisionSlashed): void { let indexer = Indexer.load(event.params.serviceProvider.toHexString())! let provision = createOrLoadProvision(event.params.serviceProvider, event.params.verifier, event.block.timestamp) - // Due to thawing tokens potentially getting cancelled, we will need to figure the thawing situation + // The contract slashes active and thawing tokens proportionally, rounding thawing down. + let remainingTokens = provision.tokensProvisioned.minus(event.params.tokens) + let remainingThawing = provision.tokensThawing.times(remainingTokens).div(provision.tokensProvisioned) + let thawingSlashed = provision.tokensThawing.minus(remainingThawing) + indexer.provisionedTokens = indexer.provisionedTokens.minus(event.params.tokens) indexer.stakedTokens = indexer.stakedTokens.minus(event.params.tokens) + indexer.thawingTokens = indexer.thawingTokens.minus(thawingSlashed) indexer = updateAdvancedIndexerMetrics(indexer as Indexer) indexer = calculateCapacities(indexer as Indexer) indexer.save() dataService.totalTokensProvisioned = dataService.totalTokensProvisioned.minus(event.params.tokens) + dataService.totalTokensThawing = dataService.totalTokensThawing.minus(thawingSlashed) dataService.save() graphNetwork.totalTokensProvisioned = graphNetwork.totalTokensProvisioned.minus(event.params.tokens) graphNetwork.totalTokensStaked = graphNetwork.totalTokensStaked.minus(event.params.tokens) + graphNetwork.totalTokensThawing = graphNetwork.totalTokensThawing.minus(thawingSlashed) graphNetwork.save() - provision.tokensProvisioned = provision.tokensProvisioned.minus(event.params.tokens) - // To DO, update thawing tokens according to the accounting calculation from the contract + provision.tokensProvisioned = remainingTokens + provision.tokensThawing = remainingThawing provision.tokensSlashedServiceProvider = provision.tokensSlashedServiceProvider.plus(event.params.tokens) provision = updateAdvancedProvisionMetrics(provision as Provision) provision.save() @@ -465,7 +472,13 @@ export function handleDelegationSlashed(event: DelegationSlashed): void { // update provision let provision = createOrLoadProvision(event.params.serviceProvider, event.params.verifier, event.block.timestamp) - provision.delegatedTokens = provision.delegatedTokens.minus(event.params.tokens) + let remainingTokens = provision.delegatedTokens.minus(event.params.tokens) + let remainingThawing = provision.delegatedThawingTokens.times(remainingTokens).div(provision.delegatedTokens) + let thawingSlashed = provision.delegatedThawingTokens.minus(remainingThawing) + let activeSlashed = event.params.tokens.minus(thawingSlashed) + + provision.delegatedTokens = remainingTokens + provision.delegatedThawingTokens = remainingThawing provision.tokensSlashedDelegationPool = provision.tokensSlashedDelegationPool.plus(event.params.tokens) if (provision.delegatorShares != BigInt.fromI32(0)) { provision = updateDelegationExchangeRateForProvision(provision as Provision) @@ -477,14 +490,22 @@ export function handleDelegationSlashed(event: DelegationSlashed): void { let indexerID = event.params.serviceProvider.toHexString() let indexer = Indexer.load(indexerID)! indexer.delegatedTokens = indexer.delegatedTokens.minus(event.params.tokens) + indexer.delegatedThawingTokens = indexer.delegatedThawingTokens.minus(thawingSlashed) + if (indexer.delegatorShares != BigInt.fromI32(0)) { + indexer = updateDelegationExchangeRate(indexer as Indexer) + } indexer = updateAdvancedIndexerMetrics(indexer as Indexer) indexer = calculateCapacities(indexer as Indexer) indexer.save() - // upgrade graph network + // These totals exclude thawing delegation, which was removed on TokensUndelegated. let graphNetwork = createOrLoadGraphNetwork(event.block.number, event.address) - graphNetwork.totalDelegatedTokens = graphNetwork.totalDelegatedTokens.minus(event.params.tokens) + graphNetwork.totalDelegatedTokens = graphNetwork.totalDelegatedTokens.minus(activeSlashed) graphNetwork.save() + + let dataService = createOrLoadDataService(event.params.verifier) + dataService.totalTokensDelegated = dataService.totalTokensDelegated.minus(activeSlashed) + dataService.save() } export function handleTokensUndelegated(event: TokensUndelegated): void { diff --git a/tests/factories/horizonStaking.ts b/tests/factories/horizonStaking.ts index bb4abf2f..9f5f7b4a 100644 --- a/tests/factories/horizonStaking.ts +++ b/tests/factories/horizonStaking.ts @@ -2,7 +2,12 @@ import { newMockEvent } from 'matchstick-as/assembly/index' import { DelegatedTokensWithdrawn, DelegationFeeCutSet, + DelegationSlashed, + HorizonStakeDeposited, + HorizonStakeWithdrawn, ProvisionCreated, + ProvisionIncreased, + ProvisionSlashed, ProvisionThawed, TokensDelegated, TokensDeprovisioned, @@ -18,6 +23,67 @@ function uintParam(name: string, value: BigInt): ethereum.EventParam { return new ethereum.EventParam(name, ethereum.Value.fromUnsignedBigInt(value)) } +export function mockProvisionSlashed( + serviceProvider: Address, + verifier: Address, + tokens: BigInt, +): ProvisionSlashed { + let event = changetype(newMockEvent()) + event.parameters = [ + addressParam('serviceProvider', serviceProvider), + addressParam('verifier', verifier), + uintParam('tokens', tokens), + ] + return event +} + +export function mockDelegationSlashed( + serviceProvider: Address, + verifier: Address, + tokens: BigInt, +): DelegationSlashed { + let event = changetype(newMockEvent()) + event.parameters = [ + addressParam('serviceProvider', serviceProvider), + addressParam('verifier', verifier), + uintParam('tokens', tokens), + ] + return event +} + +export function mockProvisionIncreased( + serviceProvider: Address, + verifier: Address, + tokens: BigInt, +): ProvisionIncreased { + let event = changetype(newMockEvent()) + event.parameters = [ + addressParam('serviceProvider', serviceProvider), + addressParam('verifier', verifier), + uintParam('tokens', tokens), + ] + return event +} + +export function mockHorizonStakeDeposited( + serviceProvider: Address, + tokens: BigInt, +): HorizonStakeDeposited { + let event = changetype(newMockEvent()) + event.block.number = BigInt.fromI32(5) + event.parameters = [addressParam('serviceProvider', serviceProvider), uintParam('tokens', tokens)] + return event +} + +export function mockHorizonStakeWithdrawn( + serviceProvider: Address, + tokens: BigInt, +): HorizonStakeWithdrawn { + let event = changetype(newMockEvent()) + event.parameters = [addressParam('serviceProvider', serviceProvider), uintParam('tokens', tokens)] + return event +} + export function mockProvisionCreated( serviceProvider: Address, verifier: Address, diff --git a/tests/horizonStaking.test.ts b/tests/horizonStaking.test.ts index d554ae06..fc42786f 100644 --- a/tests/horizonStaking.test.ts +++ b/tests/horizonStaking.test.ts @@ -5,6 +5,7 @@ import { clearStore, test, beforeAll, + beforeEach, describe, afterAll, createMockedFunction, @@ -13,7 +14,12 @@ import { import { handleDelegatedTokensWithdrawn, handleDelegationFeeCutSet, + handleDelegationSlashed, + handleHorizonStakeDeposited, + handleHorizonStakeWithdrawn, handleProvisionCreated, + handleProvisionIncreased, + handleProvisionSlashed, handleProvisionThawed, handleTokensDelegated, handleTokensDeprovisioned, @@ -22,14 +28,24 @@ import { import { mockDelegatedTokensWithdrawn, mockDelegationFeeCutSet, + mockDelegationSlashed, + mockHorizonStakeDeposited, + mockHorizonStakeWithdrawn, mockProvisionCreated, + mockProvisionIncreased, + mockProvisionSlashed, mockProvisionThawed, mockTokensDelegated, mockTokensDeprovisioned, mockTokensUndelegated, } from './factories/horizonStaking' -import { createOrLoadGraphNetwork, createOrLoadIndexer, joinID } from '../src/mappings/helpers/helpers' -import { Indexer, Provision } from '../src/types/schema' +import { + createOrLoadGraphNetwork, + createOrLoadIndexer, + joinID, + updateLegacyAdvancedIndexerMetrics, +} from '../src/mappings/helpers/helpers' +import { DataService, Indexer, Provision } from '../src/types/schema' // Reproduces indexer 0x38f4...27c0 on the subgraph service at Arbitrum One block 522,643,996: // tokensProvisioned 11,278,919 GRT, tokensThawing 0, delegatedTokens 1,439,299 GRT of which @@ -156,6 +172,272 @@ describe('EFFECTIVE CUT ON ACTIVE STAKE', () => { handleTokensDeprovisioned(mockTokensDeprovisioned(indexerAddress, dataServiceAddress, provisionThaw)) assert.fieldEquals('Provision', provisionID, 'tokensThawing', '0') assertClose(provisionField('indexingRewardEffectiveCut'), '0.167413', 'provision.indexingRewardEffectiveCut') + assertClose(indexerField('indexingRewardEffectiveCut'), '0.167413', 'indexer.indexingRewardEffectiveCut') + assertClose(indexerField('queryFeeEffectiveCut'), '0.167413', 'indexer.queryFeeEffectiveCut') + }) +}) + +function grt(tokens: i32): BigInt { + return BigInt.fromI32(tokens).times(GRT) +} + +function setUpAccounting(providerTokens: BigInt, delegationTokens: BigInt): void { + let network = createOrLoadGraphNetwork(BigInt.fromI32(1), controllerAddress) + network.delegationRatio = 16 + network.maxThawingPeriod = BigInt.fromI32(100) + network.epochLength = 2 + network.totalTokensStaked = providerTokens + network.stakedIndexersCount = 1 + network.save() + + let indexer = createOrLoadIndexer(indexerAddress, BigInt.fromI32(1), network) + indexer.stakedTokens = providerTokens + // Migration does not clear this flag; Horizon metrics must still use provision backing. + indexer.isLegacy = true + indexer.save() + handleProvisionCreated(mockProvisionCreated(indexerAddress, dataServiceAddress, providerTokens)) + let service = DataService.load(dataServiceID)! + service.delegationRatio = 16 + service.save() + handleDelegationFeeCutSet( + mockDelegationFeeCutSet(indexerAddress, dataServiceAddress, 0, delegatorFeeCut), + ) + handleDelegationFeeCutSet( + mockDelegationFeeCutSet(indexerAddress, dataServiceAddress, 2, delegatorFeeCut), + ) + handleTokensDelegated( + mockTokensDelegated( + indexerAddress, + dataServiceAddress, + delegatorAddress, + delegationTokens, + delegationTokens, + ), + ) +} + +function assertThawingTotals(provisionThawing: BigInt, aggregateThawing: BigInt): void { + assert.fieldEquals('Provision', provisionID, 'tokensThawing', provisionThawing.toString()) + assert.fieldEquals('Indexer', indexerID, 'thawingTokens', aggregateThawing.toString()) + assert.fieldEquals( + 'DataService', + dataServiceID, + 'totalTokensThawing', + provisionThawing.toString(), + ) + assert.fieldEquals('GraphNetwork', '1', 'totalTokensThawing', aggregateThawing.toString()) +} + +function assertDelegationBalances(gross: BigInt, thawing: BigInt, active: BigInt): void { + assert.fieldEquals('Provision', provisionID, 'delegatedTokens', gross.toString()) + assert.fieldEquals('Provision', provisionID, 'delegatedThawingTokens', thawing.toString()) + assert.fieldEquals('Indexer', indexerID, 'delegatedTokens', gross.toString()) + assert.fieldEquals('Indexer', indexerID, 'delegatedThawingTokens', thawing.toString()) + assert.fieldEquals('GraphNetwork', '1', 'totalDelegatedTokens', active.toString()) + assert.fieldEquals('DataService', dataServiceID, 'totalTokensDelegated', active.toString()) +} + +describe('HORIZON ACTIVE STAKE ACCOUNTING', () => { + beforeEach(() => { + clearStore() + }) + + afterAll(() => { + clearStore() + }) + + test('idle deposits and withdrawals do not change effective cuts', () => { + setUpAccounting(grt(100), grt(20)) + handleHorizonStakeDeposited(mockHorizonStakeDeposited(indexerAddress, grt(100))) + assertClose(indexerField('indexingRewardEffectiveCut'), '0.4', 'cut after idle deposit') + assertClose(indexerField('queryFeeEffectiveCut'), '0.4', 'query cut after idle deposit') + handleHorizonStakeWithdrawn(mockHorizonStakeWithdrawn(indexerAddress, grt(100))) + assertClose(indexerField('indexingRewardEffectiveCut'), '0.4', 'cut after idle withdrawal') + assert.fieldEquals('Indexer', indexerID, 'stakedTokens', grt(100).toString()) + }) + + test('provisioning idle stake updates ratios, and deprovisioning preserves active backing', () => { + setUpAccounting(grt(100), grt(20)) + handleHorizonStakeDeposited(mockHorizonStakeDeposited(indexerAddress, grt(100))) + handleProvisionIncreased(mockProvisionIncreased(indexerAddress, dataServiceAddress, grt(50))) + assertClose(indexerField('indexingRewardEffectiveCut'), '0.15', 'cut after provision increase') + assertClose( + provisionField('indexingRewardEffectiveCut'), + '0.15', + 'provision cut after increase', + ) + handleProvisionThawed(mockProvisionThawed(indexerAddress, dataServiceAddress, grt(50))) + assertClose(indexerField('indexingRewardEffectiveCut'), '0.4', 'cut after thaw') + handleTokensDeprovisioned(mockTokensDeprovisioned(indexerAddress, dataServiceAddress, grt(50))) + assertClose(indexerField('indexingRewardEffectiveCut'), '0.4', 'cut after deprovision') + assert.fieldEquals('Indexer', indexerID, 'stakedTokens', grt(200).toString()) + }) + + test('idle stake does not increase the Horizon overdelegation limit', () => { + setUpAccounting(grt(100), grt(2000)) + assertClose(indexerField('overDelegationDilution'), '0.2', 'initial dilution') + handleHorizonStakeDeposited(mockHorizonStakeDeposited(indexerAddress, grt(100))) + assertClose(indexerField('overDelegationDilution'), '0.2', 'dilution after idle deposit') + handleProvisionIncreased(mockProvisionIncreased(indexerAddress, dataServiceAddress, grt(100))) + assert.fieldEquals('Indexer', indexerID, 'overDelegationDilution', '0') + }) + + test('partial provision slash updates thawing totals before effective cuts', () => { + setUpAccounting(grt(100), grt(20)) + handleProvisionThawed(mockProvisionThawed(indexerAddress, dataServiceAddress, grt(80))) + handleProvisionSlashed(mockProvisionSlashed(indexerAddress, dataServiceAddress, grt(50))) + assertThawingTotals(grt(40), grt(40)) + assert.fieldEquals('Provision', provisionID, 'tokensProvisioned', grt(50).toString()) + assert.fieldEquals('Provision', provisionID, 'tokensSlashedServiceProvider', grt(50).toString()) + assert.fieldEquals('Indexer', indexerID, 'stakedTokens', grt(50).toString()) + assert.fieldEquals('Indexer', indexerID, 'provisionedTokens', grt(50).toString()) + assert.fieldEquals('GraphNetwork', '1', 'totalTokensStaked', grt(50).toString()) + assert.fieldEquals('GraphNetwork', '1', 'totalTokensProvisioned', grt(50).toString()) + assert.fieldEquals('DataService', dataServiceID, 'totalTokensProvisioned', grt(50).toString()) + assert.fieldEquals('Indexer', indexerID, 'tokenCapacity', grt(30).toString()) + assertClose(provisionField('indexingRewardEffectiveCut'), '0.85', 'provision cut after slash') + assertClose(indexerField('indexingRewardEffectiveCut'), '0.85', 'indexer cut after slash') + assertClose(indexerField('queryFeeEffectiveCut'), '0.85', 'query cut after slash') + handleTokensDeprovisioned(mockTokensDeprovisioned(indexerAddress, dataServiceAddress, grt(40))) + assertThawingTotals(grt(0), grt(0)) + assertClose( + indexerField('indexingRewardEffectiveCut'), + '0.85', + 'cut after post-slash deprovision', + ) + }) + + test('provision slashing rounds remaining thawing down to whole wei', () => { + setUpAccounting(grt(100).plus(BigInt.fromI32(1)), grt(20)) + handleProvisionThawed( + mockProvisionThawed(indexerAddress, dataServiceAddress, grt(80).plus(BigInt.fromI32(1))), + ) + handleProvisionSlashed(mockProvisionSlashed(indexerAddress, dataServiceAddress, grt(50))) + assertThawingTotals(grt(40), grt(40)) + assert.fieldEquals( + 'Provision', + provisionID, + 'tokensProvisioned', + grt(50).plus(BigInt.fromI32(1)).toString(), + ) + }) + + test('full provision slash clears thawing balances', () => { + setUpAccounting(grt(100), grt(20)) + handleProvisionThawed(mockProvisionThawed(indexerAddress, dataServiceAddress, grt(80))) + handleProvisionSlashed(mockProvisionSlashed(indexerAddress, dataServiceAddress, grt(100))) + assertThawingTotals(grt(0), grt(0)) + assert.fieldEquals('Indexer', indexerID, 'provisionedTokens', '0') + assert.fieldEquals('Indexer', indexerID, 'tokenCapacity', '0') + assert.fieldEquals('Indexer', indexerID, 'indexingRewardEffectiveCut', '0') + }) + + test('slashing one provision preserves other provisions in the aggregate thawing balance', () => { + setUpAccounting(grt(100), grt(20)) + let otherService = Address.fromString('0x0000000000000000000000000000000000000007') + handleHorizonStakeDeposited(mockHorizonStakeDeposited(indexerAddress, grt(20))) + handleProvisionCreated(mockProvisionCreated(indexerAddress, otherService, grt(20))) + handleProvisionThawed(mockProvisionThawed(indexerAddress, otherService, grt(10))) + handleProvisionThawed(mockProvisionThawed(indexerAddress, dataServiceAddress, grt(80))) + handleProvisionSlashed(mockProvisionSlashed(indexerAddress, dataServiceAddress, grt(50))) + assertThawingTotals(grt(40), grt(50)) + assert.fieldEquals( + 'Provision', + joinID([indexerID, otherService.toHexString()]), + 'tokensThawing', + grt(10).toString(), + ) + }) + + test('delegation slash separates active losses from thawing losses and updates exchange rates', () => { + setUpAccounting(grt(100), grt(100)) + handleTokensUndelegated( + mockTokensUndelegated(indexerAddress, dataServiceAddress, delegatorAddress, grt(40), grt(40)), + ) + // The contract exhausts provider stake before slashing delegation. + handleProvisionSlashed(mockProvisionSlashed(indexerAddress, dataServiceAddress, grt(100))) + handleDelegationSlashed(mockDelegationSlashed(indexerAddress, dataServiceAddress, grt(50))) + assertDelegationBalances(grt(50), grt(20), grt(30)) + assert.fieldEquals('Provision', provisionID, 'delegationExchangeRate', '0.5') + assert.fieldEquals('Indexer', indexerID, 'delegationExchangeRate', '0.5') + assert.fieldEquals('Provision', provisionID, 'delegatorShares', grt(60).toString()) + assert.fieldEquals('Indexer', indexerID, 'delegatorShares', grt(60).toString()) + assert.fieldEquals('Provision', provisionID, 'tokensSlashedDelegationPool', grt(50).toString()) + handleDelegatedTokensWithdrawn( + mockDelegatedTokensWithdrawn(indexerAddress, dataServiceAddress, delegatorAddress, grt(20)), + ) + assertDelegationBalances(grt(30), grt(0), grt(30)) + assert.fieldEquals('Indexer', indexerID, 'delegationExchangeRate', '0.5') + }) + + test('delegation slashing applies contract rounding to active aggregate losses', () => { + setUpAccounting(grt(100), grt(100).plus(BigInt.fromI32(1))) + let thawed = grt(40).plus(BigInt.fromI32(1)) + handleTokensUndelegated( + mockTokensUndelegated(indexerAddress, dataServiceAddress, delegatorAddress, thawed, thawed), + ) + handleProvisionSlashed(mockProvisionSlashed(indexerAddress, dataServiceAddress, grt(100))) + handleDelegationSlashed(mockDelegationSlashed(indexerAddress, dataServiceAddress, grt(50))) + assertDelegationBalances( + grt(50).plus(BigInt.fromI32(1)), + grt(20), + grt(30).plus(BigInt.fromI32(1)), + ) + }) + + test('full delegation slash clears tokens but preserves active shares', () => { + setUpAccounting(grt(100), grt(100)) + handleTokensUndelegated( + mockTokensUndelegated(indexerAddress, dataServiceAddress, delegatorAddress, grt(40), grt(40)), + ) + handleProvisionSlashed(mockProvisionSlashed(indexerAddress, dataServiceAddress, grt(100))) + handleDelegationSlashed(mockDelegationSlashed(indexerAddress, dataServiceAddress, grt(100))) + assertDelegationBalances(grt(0), grt(0), grt(0)) + assert.fieldEquals('Provision', provisionID, 'delegatorShares', grt(60).toString()) + assert.fieldEquals('Indexer', indexerID, 'delegatorShares', grt(60).toString()) + assert.fieldEquals('Provision', provisionID, 'delegationExchangeRate', '0') + assert.fieldEquals('Indexer', indexerID, 'delegationExchangeRate', '0') + assert.fieldEquals('Provision', provisionID, 'indexingRewardEffectiveCut', '0') + }) + + test('slashing an entirely thawing delegation pool does not subtract active totals again', () => { + setUpAccounting(grt(100), grt(100)) + handleTokensUndelegated( + mockTokensUndelegated( + indexerAddress, + dataServiceAddress, + delegatorAddress, + grt(100), + grt(100), + ), + ) + handleProvisionSlashed(mockProvisionSlashed(indexerAddress, dataServiceAddress, grt(100))) + handleDelegationSlashed(mockDelegationSlashed(indexerAddress, dataServiceAddress, grt(50))) + assertDelegationBalances(grt(50), grt(50), grt(0)) + assert.fieldEquals('Provision', provisionID, 'delegatorShares', '0') + assert.fieldEquals('Indexer', indexerID, 'delegatorShares', '0') + }) + + test('legacy metrics still use unlocked staked tokens without provisions', () => { + let network = createOrLoadGraphNetwork(BigInt.fromI32(1), controllerAddress) + network.delegationRatio = 16 + network.save() + let indexer = createOrLoadIndexer(indexerAddress, BigInt.fromI32(1), network) + indexer.isLegacy = true + indexer.stakedTokens = grt(100) + indexer.lockedTokens = grt(20) + indexer.delegatedTokens = grt(40) + indexer.legacyIndexingRewardCut = 900000 + indexer.legacyQueryFeeCut = 900000 + indexer = updateLegacyAdvancedIndexerMetrics(indexer) + indexer.save() + assertClose(indexerField('ownStakeRatio'), '0.666667', 'legacy own stake ratio') + assertClose(indexerField('legacyIndexingRewardEffectiveCut'), '0.7', 'legacy effective cut') + assertClose(indexerField('legacyQueryFeeEffectiveCut'), '0.7', 'legacy query cut') + indexer.delegatedTokens = grt(1600) + indexer = updateLegacyAdvancedIndexerMetrics(indexer) + indexer.save() + assertClose(indexerField('overDelegationDilution'), '0.2', 'legacy dilution') }) })