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') }) })