From d216388a3ab2d95f91cd3eadb34ce7135a348d84 Mon Sep 17 00:00:00 2001 From: neriumpete Date: Tue, 29 Sep 2026 18:18:13 +0300 Subject: [PATCH] fix: close over-allocated allocations with collect and stopService --- .../__tests__/unallocate.test.ts | 76 ++++++++++++ .../src/indexer-management/allocations.ts | 24 +--- .../resolvers/allocations.ts | 112 +++++++----------- 3 files changed, 120 insertions(+), 92 deletions(-) create mode 100644 packages/indexer-common/src/indexer-management/__tests__/unallocate.test.ts diff --git a/packages/indexer-common/src/indexer-management/__tests__/unallocate.test.ts b/packages/indexer-common/src/indexer-management/__tests__/unallocate.test.ts new file mode 100644 index 000000000..049e4579e --- /dev/null +++ b/packages/indexer-common/src/indexer-management/__tests__/unallocate.test.ts @@ -0,0 +1,76 @@ +import { createLogger, Logger } from '@graphprotocol/common-ts' +import { AllocationManager } from '../allocations' +import { IndexingStatusCode } from '../types' + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +declare const __LOG_LEVEL__: any + +const indexer = '0x0000000000000000000000000000000000000001' +const allocationID = '0x000000000000000000000000000000000000000a' + +const buildAllocationManager = (isOverAllocated: boolean) => { + const subgraphService = { + isOverAllocated: jest.fn().mockResolvedValue(isOverAllocated), + interface: { encodeFunctionData: jest.fn((name: string) => name) }, + multicall: { + populateTransaction: jest.fn(async (calls: string[]) => ({ data: calls })), + }, + collect: { + populateTransaction: jest.fn(async () => ({ data: 'collect-only' })), + }, + } + const network = { contracts: { SubgraphService: subgraphService } } + const manager = new AllocationManager( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + undefined as any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + undefined as any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + undefined as any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + network as any, + ) + return { manager, subgraphService } +} + +describe('populateUnallocateTransaction', () => { + let logger: Logger + + beforeAll(() => { + logger = createLogger({ + name: 'unallocate-test', + async: false, + level: __LOG_LEVEL__ ?? 'error', + }) + }) + + // An over-allocated collect resizes the allocation to zero rather than closing it, + // so stopService is still needed to close it. + it.each([false, true])( + 'multicalls collect and stopService when isOverAllocated is %s', + async (isOverAllocated) => { + const { manager, subgraphService } = buildAllocationManager(isOverAllocated) + + const tx = await manager.populateUnallocateTransaction(logger, { + allocationID, + poi: { + poi: '0x' + '11'.repeat(32), + publicPOI: '0x' + '22'.repeat(32), + blockNumber: 1, + indexingStatus: IndexingStatusCode.Healthy, + }, + isLegacy: false, + indexer, + actionID: 1, + protocolNetwork: 'eip155:42161', + }) + + expect(subgraphService.multicall.populateTransaction).toHaveBeenCalledWith([ + 'collect', + 'stopService', + ]) + expect(subgraphService.collect.populateTransaction).not.toHaveBeenCalled() + expect(tx.data).toEqual(['collect', 'stopService']) + }, + ) +}) diff --git a/packages/indexer-common/src/indexer-management/allocations.ts b/packages/indexer-common/src/indexer-management/allocations.ts index 8fe56d59f..def401ada 100644 --- a/packages/indexer-common/src/indexer-management/allocations.ts +++ b/packages/indexer-common/src/indexer-management/allocations.ts @@ -1221,28 +1221,8 @@ export class AllocationManager { } } - // Horizon: Need to collect indexing rewards and stop service - // Check if indexer is over-allocated - if so, collect() will auto-close the allocation - // and we should NOT call stopService to avoid "AllocationClosed" revert - const isOverAllocated = await this.network.contracts.SubgraphService.isOverAllocated( - params.indexer, - ) - - logger.debug('Checking over-allocation status for unallocate', { - allocationID: params.allocationID, - isOverAllocated, - }) - - if (isOverAllocated) { - // Reuse populatePresentPOITransaction - collect will auto-close the allocation - logger.info( - 'Indexer is over-allocated, using collect-only transaction (allocation will auto-close)', - { allocationID: params.allocationID }, - ) - return await this.populatePresentPOITransaction(logger, params) - } - - // Normal path: multicall collect + stopService + // Horizon: collect indexing rewards and stop service. When over-allocated, collect + // resizes the allocation to zero instead of closing it, so stopService is still needed. const collectData = encodeCollectData(params.allocationID, params.poi) const collectCallData = this.network.contracts.SubgraphService.interface.encodeFunctionData('collect', [ diff --git a/packages/indexer-common/src/indexer-management/resolvers/allocations.ts b/packages/indexer-common/src/indexer-management/resolvers/allocations.ts index eab651002..a986b03eb 100644 --- a/packages/indexer-common/src/indexer-management/resolvers/allocations.ts +++ b/packages/indexer-common/src/indexer-management/resolvers/allocations.ts @@ -827,85 +827,57 @@ async function closeHorizonAllocation( throw indexerError(IndexerErrorCode.IE065, 'Allocation has already been closed') } - // Check if indexer is over-allocated - if so, collect() will auto-close the allocation - // and we should NOT call stopService to avoid "AllocationClosed" revert - const isOverAllocated = await contracts.SubgraphService.isOverAllocated(address) - - logger.debug('Checking over-allocation status for close allocation', { - allocationId: allocation.id, - isOverAllocated, - }) - + // When over-allocated, collect resizes the allocation to zero instead of closing it, + // so stopService is still needed. const collectData = encodeCollectData(allocation.id, poiData) + const collectCallData = contracts.SubgraphService.interface.encodeFunctionData( + 'collect', + [address, PaymentTypes.IndexingRewards, collectData], + ) + const closeAllocationData = encodeStopServiceData(allocation.id) + const stopServiceCallData = contracts.SubgraphService.interface.encodeFunctionData( + 'stopService', + [address, closeAllocationData], + ) - let receipt: TransactionReceipt | 'paused' | 'unauthorized' - let rewardsAssigned: bigint - - if (isOverAllocated) { - // Reuse shared collect logic - allocation will auto-close - logger.info( - 'Indexer is over-allocated, using collect-only transaction (allocation will auto-close)', - { allocationId: allocation.id }, - ) - const result = await executeCollectTransaction( - network, - allocation.id, - collectData, - logger, - ) - receipt = result.receipt - rewardsAssigned = result.rewardsCollected - } else { - // Normal path: multicall collect + stopService - const collectCallData = contracts.SubgraphService.interface.encodeFunctionData( - 'collect', - [address, PaymentTypes.IndexingRewards, collectData], - ) - const closeAllocationData = encodeStopServiceData(allocation.id) - const stopServiceCallData = contracts.SubgraphService.interface.encodeFunctionData( - 'stopService', - [address, closeAllocationData], - ) + const receipt = await transactionManager.executeTransaction( + async () => + contracts.SubgraphService.multicall.estimateGas([ + collectCallData, + stopServiceCallData, + ]), + async (gasLimit) => + contracts.SubgraphService.multicall([collectCallData, stopServiceCallData], { + gasLimit, + }), + logger, + ) - receipt = await transactionManager.executeTransaction( - async () => - contracts.SubgraphService.multicall.estimateGas([ - collectCallData, - stopServiceCallData, - ]), - async (gasLimit) => - contracts.SubgraphService.multicall([collectCallData, stopServiceCallData], { - gasLimit, - }), - logger, + if (receipt === 'paused' || receipt === 'unauthorized') { + throw indexerError( + IndexerErrorCode.IE062, + `Allocation '${allocation.id}' could not be closed: ${receipt}`, ) + } - if (receipt === 'paused' || receipt === 'unauthorized') { - throw indexerError( - IndexerErrorCode.IE062, - `Allocation '${allocation.id}' could not be closed: ${receipt}`, - ) - } + const collectEventLogs = transactionManager.findEvent( + 'ServicePaymentCollected', + contracts.SubgraphService.interface, + 'serviceProvider', + address, + receipt, + logger, + ) - const collectEventLogs = transactionManager.findEvent( - 'ServicePaymentCollected', - contracts.SubgraphService.interface, - 'serviceProvider', - address, - receipt, - logger, + if (!collectEventLogs) { + throw indexerError( + IndexerErrorCode.IE015, + `Collecting indexing rewards for allocation '${allocation.id}' failed`, ) - - if (!collectEventLogs) { - throw indexerError( - IndexerErrorCode.IE015, - `Collecting indexing rewards for allocation '${allocation.id}' failed`, - ) - } - - rewardsAssigned = collectEventLogs.tokens ?? 0n } + const rewardsAssigned: bigint = collectEventLogs.tokens ?? 0n + if (rewardsAssigned === 0n) { logger.warn('No rewards were distributed upon closing the allocation') }