From 2bb99c890c93f45c955e515f38149bea0466e191 Mon Sep 17 00:00:00 2001 From: Chris Wessels Date: Mon, 9 Nov 2026 10:22:33 +0000 Subject: [PATCH] fix(staking): compute effective cuts on active stake and delegation Horizon keeps undelegated tokens in the delegation pool (delegatedThawingTokens) until they are withdrawn, but rewards and query fees added to the pool only go to active shares. Thawing provision tokens likewise no longer back the service provider. The stake ratios, effective cuts and over-delegation dilution were computed from gross amounts, which overstated the indexer's effective cut (e.g. 0.1164 instead of 0.0961 for a provision with 2.5% of its delegation thawing). Compute them on delegatedTokens - delegatedThawingTokens and tokensProvisioned - tokensThawing (stakedTokens - lockedTokens - thawingTokens at the indexer level), and recalculate the metrics in every Horizon handler that changes provisioned, thawing, staked or delegated amounts, not only when delegation is added. --- src/mappings/helpers/helpers.ts | 45 +++++-- src/mappings/horizonStaking.ts | 16 +++ tests/factories/horizonStaking.ts | 194 ++++++++++++++++++++++++++++++ tests/horizonStaking.test.ts | 184 ++++++++++++++++++++++++++++ 4 files changed, 429 insertions(+), 10 deletions(-) create mode 100644 tests/factories/horizonStaking.ts create mode 100644 tests/horizonStaking.test.ts diff --git a/src/mappings/helpers/helpers.ts b/src/mappings/helpers/helpers.ts index d6365869..7c446c23 100644 --- a/src/mappings/helpers/helpers.ts +++ b/src/mappings/helpers/helpers.ts @@ -1010,9 +1010,21 @@ 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) +} + +// Delegation that still earns rewards and fees. Horizon keeps undelegated tokens in the pool +// (tracked in delegatedThawingTokens) until withdrawn, but new rewards only go to active shares. +export function getActiveIndexerDelegation(indexer: Indexer): BigInt { + return indexer.delegatedTokens.minus(indexer.delegatedThawingTokens) +} + export function calculateOwnStakeRatio(indexer: Indexer): BigDecimal { - let stakedTokensBD = indexer.stakedTokens.minus(indexer.lockedTokens).toBigDecimal() - let delegatedTokensBD = indexer.delegatedTokens.toBigDecimal() + let stakedTokensBD = getActiveIndexerStake(indexer).toBigDecimal() + let delegatedTokensBD = getActiveIndexerDelegation(indexer).toBigDecimal() let graphNetwork = loadGraphNetwork() let delegationRatioBD = BigInt.fromI32(graphNetwork.delegationRatio).toBigDecimal() let maxPossibleTotalUsable = stakedTokensBD + stakedTokensBD * delegationRatioBD @@ -1077,8 +1089,8 @@ export function calculateLegacyIndexerRewardOwnGenerationRatio(indexer: Indexer) } export function calculateOverdelegationDilution(indexer: Indexer): BigDecimal { - let stakedTokensBD = indexer.stakedTokens.toBigDecimal() - let delegatedTokensBD = indexer.delegatedTokens.toBigDecimal() + let stakedTokensBD = getActiveIndexerStake(indexer).toBigDecimal() + let delegatedTokensBD = getActiveIndexerDelegation(indexer).toBigDecimal() let graphNetwork = loadGraphNetwork() let delegationRatioBD = BigInt.fromI32(graphNetwork.delegationRatio).toBigDecimal() let maxDelegatedStake = stakedTokensBD * delegationRatioBD @@ -1112,18 +1124,31 @@ export function updateLegacyAdvancedIndexerMetrics(indexer: Indexer): Indexer { return indexer as Indexer } +// Provisioned tokens that are not thawing. +export function getActiveProvisionTokens(provision: Provision): BigInt { + return provision.tokensProvisioned.minus(provision.tokensThawing) +} + +// Delegated tokens that are not thawing. Thawing delegation stays in the pool until withdrawn, +// but rewards and query fees added to the pool only accrue to active shares. +export function getActiveProvisionDelegation(provision: Provision): BigInt { + return provision.delegatedTokens.minus(provision.delegatedThawingTokens) +} + export function calculateOwnStakeRatioForProvision(provision: Provision): BigDecimal { - let totalTokens = provision.tokensProvisioned.plus(provision.delegatedTokens) + let ownTokens = getActiveProvisionTokens(provision) + let totalTokens = ownTokens.plus(getActiveProvisionDelegation(provision)) return totalTokens == BigInt.fromI32(0) ? BigDecimal.fromString('0') - : provision.tokensProvisioned.toBigDecimal().div(totalTokens.toBigDecimal()) + : ownTokens.toBigDecimal().div(totalTokens.toBigDecimal()) } export function calculateDelegatedStakeRatioForProvision(provision: Provision): BigDecimal { - let totalTokens = provision.tokensProvisioned.plus(provision.delegatedTokens) + let delegatedTokens = getActiveProvisionDelegation(provision) + let totalTokens = getActiveProvisionTokens(provision).plus(delegatedTokens) return totalTokens == BigInt.fromI32(0) ? BigDecimal.fromString('0') - : provision.delegatedTokens.toBigDecimal().div(totalTokens.toBigDecimal()) + : delegatedTokens.toBigDecimal().div(totalTokens.toBigDecimal()) } export function calculateIndexingRewardEffectiveCutForProvision(provision: Provision): BigDecimal { @@ -1154,8 +1179,8 @@ export function calculateIndexerRewardOwnGenerationRatioForProvision(provision: } export function calculateOverdelegationDilutionForProvision(provision: Provision): BigDecimal { - let provisionedTokensBD = provision.tokensProvisioned.toBigDecimal() - let delegatedTokensBD = provision.delegatedTokens.toBigDecimal() + let provisionedTokensBD = getActiveProvisionTokens(provision).toBigDecimal() + let delegatedTokensBD = getActiveProvisionDelegation(provision).toBigDecimal() let dataService = DataService.load(provision.dataService)! if (dataService.delegationRatio == null) { return BigDecimal.fromString('0') diff --git a/src/mappings/horizonStaking.ts b/src/mappings/horizonStaking.ts index 69901ca8..b3b69960 100644 --- a/src/mappings/horizonStaking.ts +++ b/src/mappings/horizonStaking.ts @@ -20,6 +20,7 @@ export function handleHorizonStakeDeposited(event: HorizonStakeDeposited): void let indexer = createOrLoadIndexer(event.params.serviceProvider, event.block.timestamp, graphNetwork) let previousStake = indexer.stakedTokens indexer.stakedTokens = indexer.stakedTokens.plus(event.params.tokens) + indexer = updateAdvancedIndexerMetrics(indexer as Indexer) indexer.save() // Update graph network @@ -45,6 +46,7 @@ export function handleHorizonStakeLocked(event: HorizonStakeLocked): void { let indexer = Indexer.load(id)! indexer.lockedTokens = event.params.tokens indexer.tokensLockedUntil = event.params.until.toI32() + indexer = updateAdvancedIndexerMetrics(indexer as Indexer) indexer.save() // update graph network @@ -62,6 +64,7 @@ export function handleHorizonStakeWithdrawn(event: HorizonStakeWithdrawn): void indexer.stakedTokens = indexer.stakedTokens.minus(event.params.tokens) indexer.lockedTokens = BigInt.fromI32(0) // set to 0 to prevent issues when Stage 2 comes indexer.tokensLockedUntil = 0 // always set to 0 when withdrawn + indexer = updateAdvancedIndexerMetrics(indexer as Indexer) indexer.save() // Update graph network @@ -84,6 +87,7 @@ export function handleProvisionCreated(event: ProvisionCreated): void { let provision = createOrLoadProvision(event.params.serviceProvider, event.params.verifier, event.block.timestamp) indexer.provisionedTokens = indexer.provisionedTokens.plus(event.params.tokens) + indexer = updateAdvancedIndexerMetrics(indexer as Indexer) indexer = calculateCapacities(indexer as Indexer) indexer.save() @@ -98,6 +102,7 @@ export function handleProvisionCreated(event: ProvisionCreated): void { provision.maxVerifierCutPending = event.params.maxVerifierCut provision.thawingPeriod = event.params.thawingPeriod provision.thawingPeriodPending = event.params.thawingPeriod + provision = updateAdvancedProvisionMetrics(provision as Provision) provision.save() } @@ -108,6 +113,7 @@ export function handleProvisionIncreased(event: ProvisionIncreased): void { let provision = createOrLoadProvision(event.params.serviceProvider, event.params.verifier, event.block.timestamp) indexer.provisionedTokens = indexer.provisionedTokens.plus(event.params.tokens) + indexer = updateAdvancedIndexerMetrics(indexer as Indexer) indexer = calculateCapacities(indexer as Indexer) indexer.save() @@ -118,6 +124,7 @@ export function handleProvisionIncreased(event: ProvisionIncreased): void { graphNetwork.save() provision.tokensProvisioned = provision.tokensProvisioned.plus(event.params.tokens) + provision = updateAdvancedProvisionMetrics(provision as Provision) provision.save() } @@ -128,6 +135,7 @@ export function handleProvisionThawed(event: ProvisionThawed): void { let provision = createOrLoadProvision(event.params.serviceProvider, event.params.verifier, event.block.timestamp) indexer.thawingTokens = indexer.thawingTokens.plus(event.params.tokens) + indexer = updateAdvancedIndexerMetrics(indexer as Indexer) indexer = calculateCapacities(indexer as Indexer) indexer.save() @@ -138,6 +146,7 @@ export function handleProvisionThawed(event: ProvisionThawed): void { graphNetwork.save() provision.tokensThawing = provision.tokensThawing.plus(event.params.tokens) + provision = updateAdvancedProvisionMetrics(provision as Provision) provision.save() } @@ -149,6 +158,7 @@ export function handleTokensDeprovisioned(event: TokensDeprovisioned): void { indexer.provisionedTokens = indexer.provisionedTokens.minus(event.params.tokens) indexer.thawingTokens = indexer.thawingTokens.minus(event.params.tokens) + indexer = updateAdvancedIndexerMetrics(indexer as Indexer) indexer = calculateCapacities(indexer as Indexer) indexer.save() @@ -162,6 +172,7 @@ export function handleTokensDeprovisioned(event: TokensDeprovisioned): void { provision.tokensProvisioned = provision.tokensProvisioned.minus(event.params.tokens) provision.tokensThawing = provision.tokensThawing.minus(event.params.tokens) + provision = updateAdvancedProvisionMetrics(provision as Provision) provision.save() } @@ -250,6 +261,7 @@ export function handleProvisionSlashed(event: ProvisionSlashed): void { // Due to thawing tokens potentially getting cancelled, we will need to figure the thawing situation indexer.provisionedTokens = indexer.provisionedTokens.minus(event.params.tokens) indexer.stakedTokens = indexer.stakedTokens.minus(event.params.tokens) + indexer = updateAdvancedIndexerMetrics(indexer as Indexer) indexer = calculateCapacities(indexer as Indexer) indexer.save() @@ -263,6 +275,7 @@ export function handleProvisionSlashed(event: ProvisionSlashed): void { provision.tokensProvisioned = provision.tokensProvisioned.minus(event.params.tokens) // To DO, update thawing tokens according to the accounting calculation from the contract provision.tokensSlashedServiceProvider = provision.tokensSlashedServiceProvider.plus(event.params.tokens) + provision = updateAdvancedProvisionMetrics(provision as Provision) provision.save() } @@ -464,6 +477,7 @@ 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 = updateAdvancedIndexerMetrics(indexer as Indexer) indexer = calculateCapacities(indexer as Indexer) indexer.save() @@ -553,6 +567,7 @@ export function handleDelegatedTokensWithdrawn(event: DelegatedTokensWithdrawn): // might want to track locked/thawing tokens in provision too provision.delegatedTokens = provision.delegatedTokens.minus(event.params.tokens) provision.delegatedThawingTokens = provision.delegatedThawingTokens.minus(event.params.tokens) + provision = updateAdvancedProvisionMetrics(provision as Provision) provision.save() let indexerID = event.params.serviceProvider.toHexString() @@ -560,6 +575,7 @@ export function handleDelegatedTokensWithdrawn(event: DelegatedTokensWithdrawn): indexer.delegatedTokens = indexer.delegatedTokens.minus(event.params.tokens) indexer.delegatedThawingTokens = indexer.delegatedThawingTokens.minus(event.params.tokens) + indexer = updateAdvancedIndexerMetrics(indexer as Indexer) indexer = calculateCapacities(indexer as Indexer) indexer.save() diff --git a/tests/factories/horizonStaking.ts b/tests/factories/horizonStaking.ts new file mode 100644 index 00000000..bb4abf2f --- /dev/null +++ b/tests/factories/horizonStaking.ts @@ -0,0 +1,194 @@ +import { newMockEvent } from 'matchstick-as/assembly/index' +import { + DelegatedTokensWithdrawn, + DelegationFeeCutSet, + ProvisionCreated, + ProvisionThawed, + TokensDelegated, + TokensDeprovisioned, + TokensUndelegated, +} from '../../src/types/HorizonStaking/HorizonStaking' +import { Address, BigInt, ethereum } from '@graphprotocol/graph-ts' + +function addressParam(name: string, value: Address): ethereum.EventParam { + return new ethereum.EventParam(name, ethereum.Value.fromAddress(value)) +} + +function uintParam(name: string, value: BigInt): ethereum.EventParam { + return new ethereum.EventParam(name, ethereum.Value.fromUnsignedBigInt(value)) +} + +export function mockProvisionCreated( + serviceProvider: Address, + verifier: Address, + tokens: BigInt, +): ProvisionCreated { + let mockEvent = newMockEvent() + let event = new ProvisionCreated( + mockEvent.address, + mockEvent.logIndex, + mockEvent.transactionLogIndex, + mockEvent.logType, + mockEvent.block, + mockEvent.transaction, + mockEvent.parameters, + mockEvent.receipt, + ) + event.parameters = [] + event.parameters.push(addressParam('serviceProvider', serviceProvider)) + event.parameters.push(addressParam('verifier', verifier)) + event.parameters.push(uintParam('tokens', tokens)) + event.parameters.push(uintParam('maxVerifierCut', BigInt.fromI32(0))) + event.parameters.push(uintParam('thawingPeriod', BigInt.fromI32(0))) + return event +} + +export function mockProvisionThawed( + serviceProvider: Address, + verifier: Address, + tokens: BigInt, +): ProvisionThawed { + let mockEvent = newMockEvent() + let event = new ProvisionThawed( + mockEvent.address, + mockEvent.logIndex, + mockEvent.transactionLogIndex, + mockEvent.logType, + mockEvent.block, + mockEvent.transaction, + mockEvent.parameters, + mockEvent.receipt, + ) + event.parameters = [] + event.parameters.push(addressParam('serviceProvider', serviceProvider)) + event.parameters.push(addressParam('verifier', verifier)) + event.parameters.push(uintParam('tokens', tokens)) + return event +} + +export function mockTokensDeprovisioned( + serviceProvider: Address, + verifier: Address, + tokens: BigInt, +): TokensDeprovisioned { + let mockEvent = newMockEvent() + let event = new TokensDeprovisioned( + mockEvent.address, + mockEvent.logIndex, + mockEvent.transactionLogIndex, + mockEvent.logType, + mockEvent.block, + mockEvent.transaction, + mockEvent.parameters, + mockEvent.receipt, + ) + event.parameters = [] + event.parameters.push(addressParam('serviceProvider', serviceProvider)) + event.parameters.push(addressParam('verifier', verifier)) + event.parameters.push(uintParam('tokens', tokens)) + return event +} + +export function mockDelegationFeeCutSet( + serviceProvider: Address, + verifier: Address, + paymentType: i32, + feeCut: BigInt, +): DelegationFeeCutSet { + let mockEvent = newMockEvent() + let event = new DelegationFeeCutSet( + mockEvent.address, + mockEvent.logIndex, + mockEvent.transactionLogIndex, + mockEvent.logType, + mockEvent.block, + mockEvent.transaction, + mockEvent.parameters, + mockEvent.receipt, + ) + event.parameters = [] + event.parameters.push(addressParam('serviceProvider', serviceProvider)) + event.parameters.push(addressParam('verifier', verifier)) + event.parameters.push(new ethereum.EventParam('paymentType', ethereum.Value.fromI32(paymentType))) + event.parameters.push(uintParam('feeCut', feeCut)) + return event +} + +export function mockTokensDelegated( + serviceProvider: Address, + verifier: Address, + delegator: Address, + tokens: BigInt, + shares: BigInt, +): TokensDelegated { + let mockEvent = newMockEvent() + let event = new TokensDelegated( + mockEvent.address, + mockEvent.logIndex, + mockEvent.transactionLogIndex, + mockEvent.logType, + mockEvent.block, + mockEvent.transaction, + mockEvent.parameters, + mockEvent.receipt, + ) + event.parameters = [] + event.parameters.push(addressParam('serviceProvider', serviceProvider)) + event.parameters.push(addressParam('verifier', verifier)) + event.parameters.push(addressParam('delegator', delegator)) + event.parameters.push(uintParam('tokens', tokens)) + event.parameters.push(uintParam('shares', shares)) + return event +} + +export function mockTokensUndelegated( + serviceProvider: Address, + verifier: Address, + delegator: Address, + tokens: BigInt, + shares: BigInt, +): TokensUndelegated { + let mockEvent = newMockEvent() + let event = new TokensUndelegated( + mockEvent.address, + mockEvent.logIndex, + mockEvent.transactionLogIndex, + mockEvent.logType, + mockEvent.block, + mockEvent.transaction, + mockEvent.parameters, + mockEvent.receipt, + ) + event.parameters = [] + event.parameters.push(addressParam('serviceProvider', serviceProvider)) + event.parameters.push(addressParam('verifier', verifier)) + event.parameters.push(addressParam('delegator', delegator)) + event.parameters.push(uintParam('tokens', tokens)) + event.parameters.push(uintParam('shares', shares)) + return event +} + +export function mockDelegatedTokensWithdrawn( + serviceProvider: Address, + verifier: Address, + delegator: Address, + tokens: BigInt, +): DelegatedTokensWithdrawn { + let mockEvent = newMockEvent() + let event = new DelegatedTokensWithdrawn( + mockEvent.address, + mockEvent.logIndex, + mockEvent.transactionLogIndex, + mockEvent.logType, + mockEvent.block, + mockEvent.transaction, + mockEvent.parameters, + mockEvent.receipt, + ) + event.parameters = [] + event.parameters.push(addressParam('serviceProvider', serviceProvider)) + event.parameters.push(addressParam('verifier', verifier)) + event.parameters.push(addressParam('delegator', delegator)) + event.parameters.push(uintParam('tokens', tokens)) + return event +} diff --git a/tests/horizonStaking.test.ts b/tests/horizonStaking.test.ts new file mode 100644 index 00000000..d554ae06 --- /dev/null +++ b/tests/horizonStaking.test.ts @@ -0,0 +1,184 @@ +import { Address, BigDecimal, BigInt, Bytes, ethereum } from '@graphprotocol/graph-ts' + +import { + assert, + clearStore, + test, + beforeAll, + describe, + afterAll, + createMockedFunction, +} from 'matchstick-as/assembly/index' + +import { + handleDelegatedTokensWithdrawn, + handleDelegationFeeCutSet, + handleProvisionCreated, + handleProvisionThawed, + handleTokensDelegated, + handleTokensDeprovisioned, + handleTokensUndelegated, +} from '../src/mappings/horizonStaking' +import { + mockDelegatedTokensWithdrawn, + mockDelegationFeeCutSet, + mockProvisionCreated, + mockProvisionThawed, + mockTokensDelegated, + mockTokensDeprovisioned, + mockTokensUndelegated, +} from './factories/horizonStaking' +import { createOrLoadGraphNetwork, createOrLoadIndexer, joinID } from '../src/mappings/helpers/helpers' +import { 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 +// 36,201 GRT are thawing, indexing reward and query fee cuts 900000 (delegators get 10%). +// Before the fix the provision reported an effective cut of ~0.11636; on active delegation it is ~0.09614. + +const graphID = '0x0000000000000000000000000000000000000000' +const graphAddress = Address.fromString(graphID) +const controllerAddress = Address.fromString('0x0000000000000000000000000000000000000001') +const indexerID = '0x38f412c8d6346a17a53ff9ceecd2e01acecd27c0' +const indexerAddress = Address.fromString(indexerID) +const dataServiceID = '0xb2bb92d0de618878e438b55d5846cfecd9301105' +const dataServiceAddress = Address.fromString(dataServiceID) +const delegatorAddress = Address.fromString('0x0000000000000000000000000000000000000006') +const provisionID = joinID([indexerID, dataServiceID]) + +const GRT = BigInt.fromString('1000000000000000000') +const provisioned = BigInt.fromI32(11278919).times(GRT) +const delegated = BigInt.fromI32(1439299).times(GRT) +const undelegated = BigInt.fromI32(36201).times(GRT) +const provisionThaw = BigInt.fromI32(1000000).times(GRT) +// HorizonStaking feeCut is what delegators receive; the subgraph stores 1e6 - feeCut. +const delegatorFeeCut = BigInt.fromI32(100000) + +createMockedFunction(controllerAddress, 'getGovernor', 'getGovernor():(address)') + .withArgs([]) + .returns([ethereum.Value.fromAddress(controllerAddress)]) +createMockedFunction(graphAddress, 'blockNum', 'blockNum():(uint256)') + .withArgs([]) + .returns([ethereum.Value.fromI32(5)]) + +function provisionField(name: string): BigDecimal { + let provision = Provision.load(provisionID)! + return provision.get(name)!.toBigDecimal() +} + +function indexerField(name: string): BigDecimal { + let indexer = Indexer.load(indexerID)! + return indexer.get(name)!.toBigDecimal() +} + +function assertClose(actual: BigDecimal, expected: string, label: string): void { + let diff = actual.minus(BigDecimal.fromString(expected)) + let tolerance = BigDecimal.fromString('0.00001') + assert.assertTrue( + diff < tolerance && diff > tolerance.neg(), + label + ': expected ~' + expected + ', got ' + actual.toString(), + ) +} + +function setUpReproduction(): void { + let graphNetwork = createOrLoadGraphNetwork(BigInt.fromI32(1), controllerAddress) + graphNetwork.delegationRatio = 16 + graphNetwork.save() + + // Stake is deposited before the provision is created; set it directly to avoid the epoch plumbing. + let indexer = createOrLoadIndexer(Bytes.fromHexString(indexerID), BigInt.fromI32(1), graphNetwork) + indexer.stakedTokens = provisioned + indexer.save() + + handleProvisionCreated(mockProvisionCreated(indexerAddress, dataServiceAddress, provisioned)) + // paymentType 0 = query fees, 2 = indexing rewards + handleDelegationFeeCutSet(mockDelegationFeeCutSet(indexerAddress, dataServiceAddress, 0, delegatorFeeCut)) + handleDelegationFeeCutSet(mockDelegationFeeCutSet(indexerAddress, dataServiceAddress, 2, delegatorFeeCut)) + handleTokensDelegated( + mockTokensDelegated(indexerAddress, dataServiceAddress, delegatorAddress, delegated, delegated), + ) + handleTokensUndelegated( + mockTokensUndelegated(indexerAddress, dataServiceAddress, delegatorAddress, undelegated, undelegated), + ) +} + +describe('EFFECTIVE CUT ON ACTIVE STAKE', () => { + beforeAll(() => { + setUpReproduction() + }) + + afterAll(() => { + clearStore() + }) + + test('reproduction state matches the on-chain provision', () => { + assert.fieldEquals('Provision', provisionID, 'tokensProvisioned', provisioned.toString()) + assert.fieldEquals('Provision', provisionID, 'tokensThawing', '0') + assert.fieldEquals('Provision', provisionID, 'delegatedTokens', delegated.toString()) + assert.fieldEquals('Provision', provisionID, 'delegatedThawingTokens', undelegated.toString()) + assert.fieldEquals('Provision', provisionID, 'indexingRewardsCut', '900000') + assert.fieldEquals('Provision', provisionID, 'queryFeeCut', '900000') + }) + + test('provision effective cuts exclude thawing delegation', () => { + // D_active = 1,403,098; P_active = 11,278,919; 1 - 0.1 / (1,403,098 / 12,682,017) + assertClose(provisionField('delegatedStakeRatio'), '0.110637', 'provision.delegatedStakeRatio') + assertClose(provisionField('ownStakeRatio'), '0.889363', 'provision.ownStakeRatio') + assertClose(provisionField('indexingRewardEffectiveCut'), '0.096142', 'provision.indexingRewardEffectiveCut') + assertClose(provisionField('queryFeeEffectiveCut'), '0.096142', 'provision.queryFeeEffectiveCut') + }) + + test('indexer effective cuts exclude thawing delegation', () => { + assertClose(indexerField('delegatedStakeRatio'), '0.110637', 'indexer.delegatedStakeRatio') + assertClose(indexerField('indexingRewardEffectiveCut'), '0.096142', 'indexer.indexingRewardEffectiveCut') + assertClose(indexerField('queryFeeEffectiveCut'), '0.096142', 'indexer.queryFeeEffectiveCut') + }) + + test('withdrawing thawed delegation leaves the effective cut unchanged', () => { + handleDelegatedTokensWithdrawn( + mockDelegatedTokensWithdrawn(indexerAddress, dataServiceAddress, delegatorAddress, undelegated), + ) + assert.fieldEquals('Provision', provisionID, 'delegatedThawingTokens', '0') + assertClose(provisionField('indexingRewardEffectiveCut'), '0.096142', 'provision.indexingRewardEffectiveCut') + assertClose(indexerField('indexingRewardEffectiveCut'), '0.096142', 'indexer.indexingRewardEffectiveCut') + }) + + test('thawing provisioned stake is excluded and recalculated', () => { + handleProvisionThawed(mockProvisionThawed(indexerAddress, dataServiceAddress, provisionThaw)) + // P_active = 10,278,919; 1 - 0.1 / (1,403,098 / 11,682,017) + assertClose(provisionField('delegatedStakeRatio'), '0.120108', 'provision.delegatedStakeRatio') + assertClose(provisionField('indexingRewardEffectiveCut'), '0.167413', 'provision.indexingRewardEffectiveCut') + assertClose(provisionField('queryFeeEffectiveCut'), '0.167413', 'provision.queryFeeEffectiveCut') + assertClose(indexerField('indexingRewardEffectiveCut'), '0.167413', 'indexer.indexingRewardEffectiveCut') + }) + + test('deprovisioning thawed stake leaves the effective cut unchanged', () => { + handleTokensDeprovisioned(mockTokensDeprovisioned(indexerAddress, dataServiceAddress, provisionThaw)) + assert.fieldEquals('Provision', provisionID, 'tokensThawing', '0') + assertClose(provisionField('indexingRewardEffectiveCut'), '0.167413', 'provision.indexingRewardEffectiveCut') + }) +}) + +describe('EFFECTIVE CUT WHEN ALL DELEGATION IS THAWING', () => { + beforeAll(() => { + setUpReproduction() + let remaining = delegated.minus(undelegated) + handleTokensUndelegated( + mockTokensUndelegated(indexerAddress, dataServiceAddress, delegatorAddress, remaining, remaining), + ) + }) + + afterAll(() => { + clearStore() + }) + + test('falls back to the zero-delegation values', () => { + assert.fieldEquals('Provision', provisionID, 'delegatedThawingTokens', delegated.toString()) + assert.fieldEquals('Provision', provisionID, 'delegatedStakeRatio', '0') + assert.fieldEquals('Provision', provisionID, 'ownStakeRatio', '1') + assert.fieldEquals('Provision', provisionID, 'indexingRewardEffectiveCut', '0') + assert.fieldEquals('Provision', provisionID, 'queryFeeEffectiveCut', '0') + assert.fieldEquals('Indexer', indexerID, 'delegatedStakeRatio', '0') + assert.fieldEquals('Indexer', indexerID, 'indexingRewardEffectiveCut', '0') + }) +})