From e384429fe297d00651a808e90609beadb9a83c5a Mon Sep 17 00:00:00 2001 From: MoonBoi9001 Date: Fri, 23 Jan 2026 13:38:59 -0500 Subject: [PATCH 01/14] feat(actions): add PRESENT_POI action type for Horizon allocations Add a new action type that posts a POI on-chain to collect indexing rewards from Horizon allocations without closing them. This enables periodic reward collection while keeping allocations active. Changes: - ActionType.PRESENT_POI enum value ('present_poi') - GraphQL presentPOI mutation and PresentPOIResult type - Database model validation for PRESENT_POI actions - AllocationManager methods (preparePresentPOI, confirmPresentPOI, etc.) - CLI support with present_poi command - POI parameter normalization and block number validation - Improved help text clarifying optional parameters Co-Authored-By: Claude Opus 4.5 --- packages/indexer-cli/src/actions.ts | 115 ++++-- .../src/commands/indexer/actions/queue.ts | 23 +- packages/indexer-common/src/actions.ts | 1 + .../src/indexer-management/allocations.ts | 354 ++++++++++++++---- .../src/indexer-management/client.ts | 18 + .../src/indexer-management/models/action.ts | 9 + .../indexer-management/resolvers/actions.ts | 2 + .../resolvers/allocations.ts | 278 ++++++++++---- .../src/indexer-management/types.ts | 10 + 9 files changed, 627 insertions(+), 183 deletions(-) diff --git a/packages/indexer-cli/src/actions.ts b/packages/indexer-cli/src/actions.ts index 74f464929..69ac52738 100644 --- a/packages/indexer-cli/src/actions.ts +++ b/packages/indexer-cli/src/actions.ts @@ -27,6 +27,48 @@ export interface GenericActionInputParams { param6: string | undefined } +interface NormalizedPOIParams { + poi: string | undefined + publicPOI: string | undefined + poiBlockNumber: number | undefined +} + +/** + * Normalizes POI-related parameters for action inputs. + * Converts '0' or '0x0' to proper zero-filled bytes and parses block number. + */ +function normalizePOIParams( + poi: string | undefined, + publicPOI: string | undefined, + blockNumber: string | undefined, +): NormalizedPOIParams { + const zeroPOI = hexlify(new Uint8Array(32).fill(0)) + + let normalizedPoi = poi + if (normalizedPoi === '0' || normalizedPoi === '0x0') { + normalizedPoi = zeroPOI + } + + let normalizedPublicPoi = publicPOI + if (normalizedPublicPoi === '0' || normalizedPublicPoi === '0x0') { + normalizedPublicPoi = zeroPOI + } + + let poiBlockNumber: number | undefined = undefined + if (blockNumber !== undefined) { + poiBlockNumber = parseInt(blockNumber, 10) + if (isNaN(poiBlockNumber)) { + throw new Error(`Invalid block number: ${blockNumber}`) + } + } + + return { + poi: normalizedPoi, + publicPOI: normalizedPublicPoi, + poiBlockNumber, + } +} + // Make separate functions for each action type parsing from generic? export async function buildActionInput( type: ActionType, @@ -57,24 +99,17 @@ export async function buildActionInput( isLegacy, } case ActionType.UNALLOCATE: { - let poi = actionParams.param2 - if (poi == '0' || poi == '0x0') { - poi = hexlify(new Uint8Array(32).fill(0)) - } - let publicPOI = actionParams.param5 - if (publicPOI == '0' || publicPOI == '0x0') { - publicPOI = hexlify(new Uint8Array(32).fill(0)) - } - let poiBlockNumber: number | undefined = undefined - if (actionParams.param4 !== undefined) { - poiBlockNumber = Number(actionParams.param4) - } + const { poi, publicPOI, poiBlockNumber } = normalizePOIParams( + actionParams.param2, + actionParams.param5, + actionParams.param4, + ) return { deploymentID: actionParams.targetDeployment, allocationID: actionParams.param1, - poi: poi, - publicPOI: publicPOI, - poiBlockNumber: poiBlockNumber, + poi, + publicPOI, + poiBlockNumber, force: actionParams.param3 === 'true', type, source, @@ -86,25 +121,18 @@ export async function buildActionInput( } } case ActionType.REALLOCATE: { - let poi = actionParams.param3 - if (poi == '0' || poi == '0x0') { - poi = hexlify(new Uint8Array(32).fill(0)) - } - let publicPOI = actionParams.param6 - if (publicPOI == '0' || publicPOI == '0x0') { - publicPOI = hexlify(new Uint8Array(32).fill(0)) - } - let poiBlockNumber: number | undefined = undefined - if (actionParams.param5 !== undefined) { - poiBlockNumber = Number(actionParams.param5) - } + const { poi, publicPOI, poiBlockNumber } = normalizePOIParams( + actionParams.param3, + actionParams.param6, + actionParams.param5, + ) return { deploymentID: actionParams.targetDeployment, allocationID: actionParams.param1, amount: actionParams.param2?.toString(), - poi: poi, - publicPOI: publicPOI, - poiBlockNumber: poiBlockNumber, + poi, + publicPOI, + poiBlockNumber, force: actionParams.param4 === 'true', type, source, @@ -115,6 +143,29 @@ export async function buildActionInput( isLegacy, } } + case ActionType.PRESENT_POI: { + // present_poi + const { poi, publicPOI, poiBlockNumber } = normalizePOIParams( + actionParams.param2, + actionParams.param5, + actionParams.param4, + ) + return { + deploymentID: actionParams.targetDeployment, + allocationID: actionParams.param1, + poi, + publicPOI, + poiBlockNumber, + force: actionParams.param3 === 'true', + type, + source, + reason, + status, + priority, + protocolNetwork, + isLegacy, + } + } } } @@ -132,6 +183,10 @@ export async function validateActionInput( break case ActionType.REALLOCATE: requiredFields = requiredFields.concat(['targetDeployment', 'param1', 'param2']) + break + case ActionType.PRESENT_POI: + requiredFields = requiredFields.concat(['targetDeployment', 'param1']) + break } return await validateRequiredParams( diff --git a/packages/indexer-cli/src/commands/indexer/actions/queue.ts b/packages/indexer-cli/src/commands/indexer/actions/queue.ts index 0e13d33f0..9c93d7eae 100644 --- a/packages/indexer-cli/src/commands/indexer/actions/queue.ts +++ b/packages/indexer-cli/src/commands/indexer/actions/queue.ts @@ -15,16 +15,14 @@ import { } from '@graphprotocol/indexer-common' const HELP = ` -${chalk.bold( - 'graph indexer actions queue', -)} [options] -${chalk.bold('graph indexer actions queue')} [options] allocate -${chalk.bold( - 'graph indexer actions queue', -)} [options] unallocate -${chalk.bold( - 'graph indexer actions queue', -)} [options] reallocate +${chalk.bold('graph indexer actions queue')} [options] ... + +${chalk.dim('Action Types:')} + + allocate + unallocate [poi] [force] [blockNumber] [publicPOI] + reallocate [poi] [force] [blockNumber] [publicPOI] + present_poi [poi] [force] [blockNumber] [publicPOI] ${chalk.dim('Options:')} @@ -35,7 +33,10 @@ ${chalk.dim('Options:')} -r, --reason Specify the reason for the action to be taken -p, --priority Define a priority order for the action - For action type specific options, see the help for the specific action type. +${chalk.dim('Notes:')} + + POI parameters are optional - the system auto-resolves POI from the indexer's state. + Use [force]=true to submit with a zero POI if auto-resolution fails. ` module.exports = { diff --git a/packages/indexer-common/src/actions.ts b/packages/indexer-common/src/actions.ts index 68769dc9b..f0dcc9a69 100644 --- a/packages/indexer-common/src/actions.ts +++ b/packages/indexer-common/src/actions.ts @@ -227,6 +227,7 @@ export enum ActionType { ALLOCATE = 'allocate', UNALLOCATE = 'unallocate', REALLOCATE = 'reallocate', + PRESENT_POI = 'present_poi', } export enum ActionStatus { diff --git a/packages/indexer-common/src/indexer-management/allocations.ts b/packages/indexer-common/src/indexer-management/allocations.ts index 153fee73c..f14f522cd 100644 --- a/packages/indexer-common/src/indexer-management/allocations.ts +++ b/packages/indexer-common/src/indexer-management/allocations.ts @@ -29,6 +29,7 @@ import { preprocessRules, Network, ReallocateAllocationResult, + PresentPOIResult, SubgraphIdentifierType, SubgraphStatus, uniqueAllocationID, @@ -88,6 +89,14 @@ export interface UnallocateTransactionParams { protocolNetwork: string } +export interface PresentPOITransactionParams { + allocationID: string + poi: POIData + indexer: string + actionID: number + protocolNetwork: string +} + export interface ReallocateTransactionParams { closingAllocationID: string poi: POIData @@ -125,6 +134,21 @@ export type TransactionResult = | (TransactionReceipt | 'paused' | 'unauthorized')[] | ActionFailure[] +/** + * Encodes collect indexing rewards data for Horizon allocations. + * Shared helper used by collect and unallocate operations. + */ +export function encodeCollectData(allocationId: string, poiData: POIData): string { + const encodedPOIMetadata = encodePOIMetadata( + poiData.blockNumber, + poiData.publicPOI, + poiData.indexingStatus, + 0, + 0, + ) + return encodeCollectIndexingRewardsData(allocationId, poiData.poi, encodedPOIMetadata) +} + export class AllocationManager { constructor( private logger: Logger, @@ -535,6 +559,19 @@ export class AllocationManager { action.allocationID!, receipts, ) + case ActionType.PRESENT_POI: + if (receipts.length !== 1) { + this.logger.error('Invalid number of receipts for present-poi action', { + receipts, + }) + throw new Error('Invalid number of receipts for present-poi action') + } + return await this.confirmPresentPOI( + action.id, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + action.allocationID!, + receipts[0], + ) } } @@ -612,6 +649,19 @@ export class AllocationManager { action.id, action.protocolNetwork, ) + case ActionType.PRESENT_POI: + return await this.preparePresentPOI( + logger, + context, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + action.allocationID!, + action.poi === null ? undefined : action.poi, + action.force === null ? false : action.force, + action.poiBlockNumber === null ? undefined : action.poiBlockNumber, + action.publicPOI === null ? undefined : action.publicPOI, + action.id, + action.protocolNetwork, + ) } } catch (error) { logger.error(`Failed to prepare tx call data`, { @@ -947,8 +997,29 @@ export class AllocationManager { publicPOI: publicPOI || 'none provided', poiBlockNumber: poiBlockNumber || 'none provided', }) + const allocation = await this.network.networkMonitor.allocation(allocationID) + // For Horizon allocations, reuse preparePresentPOIParams logic + if (!allocation.isLegacy) { + const presentPOIParams = await this.preparePresentPOIParams( + logger, + context, + allocationID, + poi, + force, + poiBlockNumber, + publicPOI, + actionID, + protocolNetwork, + ) + return { + ...presentPOIParams, + isLegacy: false, + } + } + + // Legacy allocation path const poiData = await this.network.networkMonitor.resolvePOI( allocation, poi, @@ -957,21 +1028,12 @@ export class AllocationManager { force, ) - // Double-check whether the allocation is still active on chain, to - // avoid unnecessary transactions. - if (allocation.isLegacy) { - const state = await this.network.contracts.HorizonStaking.getAllocationState( - allocation.id, - ) - if (state !== 1n) { - throw indexerError(IndexerErrorCode.IE065) - } - } else { - const allocation = - await this.network.contracts.SubgraphService.getAllocation(allocationID) - if (allocation.closedAt !== 0n) { - throw indexerError(IndexerErrorCode.IE065) - } + // Double-check whether the legacy allocation is still active on chain + const state = await this.network.contracts.HorizonStaking.getAllocationState( + allocation.id, + ) + if (state !== 1n) { + throw indexerError(IndexerErrorCode.IE065) } return { @@ -979,7 +1041,7 @@ export class AllocationManager { actionID, allocationID: allocation.id, poi: poiData, - isLegacy: allocation.isLegacy, + isLegacy: true, indexer: allocation.indexer, } } @@ -1122,73 +1184,51 @@ export class AllocationManager { actionID: params.actionID, ...tx, } - } else { - // 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, - }) + } - // collect - const collectIndexingRewardsData = encodeCollectIndexingRewardsData( - params.allocationID, - params.poi.poi, - encodePOIMetadata( - params.poi.blockNumber, - params.poi.publicPOI, - params.poi.indexingStatus, - 0, - 0, - ), + // 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) + } - if (isOverAllocated) { - logger.info( - 'Indexer is over-allocated, using collect-only transaction (allocation will auto-close)', - { allocationID: params.allocationID }, - ) - const tx = - await this.network.contracts.SubgraphService.collect.populateTransaction( - params.indexer, - PaymentTypes.IndexingRewards, - collectIndexingRewardsData, - ) - return { - protocolNetwork: params.protocolNetwork, - actionID: params.actionID, - ...tx, - } - } else { - // Normal path: multicall collect + stopService - const collectCallData = - this.network.contracts.SubgraphService.interface.encodeFunctionData('collect', [ - params.indexer, - PaymentTypes.IndexingRewards, - collectIndexingRewardsData, - ]) - - const stopServiceCallData = - this.network.contracts.SubgraphService.interface.encodeFunctionData( - 'stopService', - [params.indexer, encodeStopServiceData(params.allocationID)], - ) + // Normal path: multicall collect + stopService + const collectData = encodeCollectData(params.allocationID, params.poi) + const collectCallData = + this.network.contracts.SubgraphService.interface.encodeFunctionData('collect', [ + params.indexer, + PaymentTypes.IndexingRewards, + collectData, + ]) - const tx = - await this.network.contracts.SubgraphService.multicall.populateTransaction([ - collectCallData, - stopServiceCallData, - ]) - return { - protocolNetwork: params.protocolNetwork, - actionID: params.actionID, - ...tx, - } - } + const stopServiceCallData = + this.network.contracts.SubgraphService.interface.encodeFunctionData('stopService', [ + params.indexer, + encodeStopServiceData(params.allocationID), + ]) + + const tx = await this.network.contracts.SubgraphService.multicall.populateTransaction( + [collectCallData, stopServiceCallData], + ) + return { + protocolNetwork: params.protocolNetwork, + actionID: params.actionID, + ...tx, } } @@ -1217,6 +1257,162 @@ export class AllocationManager { return await this.populateUnallocateTransaction(logger, params) } + // ---- PRESENT_POI (rewards only, no close) ---- + + async preparePresentPOIParams( + logger: Logger, + context: TransactionPreparationContext, + allocationID: string, + poi: string | undefined, + force: boolean, + poiBlockNumber: number | undefined, + publicPOI: string | undefined, + actionID: number, + protocolNetwork: string, + ): Promise { + logger.info('Preparing to present POI (collect indexing rewards without closing)', { + allocationID: allocationID, + poi: poi || 'none provided', + publicPOI: publicPOI || 'none provided', + poiBlockNumber: poiBlockNumber || 'none provided', + }) + + const allocation = await this.network.networkMonitor.allocation(allocationID) + + // Present POI without closing only works for Horizon allocations + if (allocation.isLegacy) { + throw indexerError( + IndexerErrorCode.IE061, + `Cannot present POI (collect rewards) without closing for legacy allocations. Use unallocate instead.`, + ) + } + + const poiData = await this.network.networkMonitor.resolvePOI( + allocation, + poi, + publicPOI, + poiBlockNumber, + force, + ) + + // Double-check whether the allocation is still active on chain + const allocationData = + await this.network.contracts.SubgraphService.getAllocation(allocationID) + if (allocationData.closedAt !== 0n) { + throw indexerError(IndexerErrorCode.IE065, 'Allocation has already been closed') + } + + return { + protocolNetwork, + actionID, + allocationID: allocation.id, + poi: poiData, + indexer: allocation.indexer, + } + } + + async populatePresentPOITransaction( + logger: Logger, + params: PresentPOITransactionParams, + ): Promise { + logger.debug(`Populating present-poi transaction (rewards only)`, { + allocationID: params.allocationID, + poiData: params.poi, + }) + + // Present POI and collect indexing rewards without closing the allocation + const collectData = encodeCollectData(params.allocationID, params.poi) + + const tx = await this.network.contracts.SubgraphService.collect.populateTransaction( + params.indexer, + PaymentTypes.IndexingRewards, + collectData, + ) + + return { + protocolNetwork: params.protocolNetwork, + actionID: params.actionID, + ...tx, + } + } + + async preparePresentPOI( + logger: Logger, + context: TransactionPreparationContext, + allocationID: string, + poi: string | undefined, + force: boolean, + poiBlockNumber: number | undefined, + publicPOI: string | undefined, + actionID: number, + protocolNetwork: string, + ): Promise { + const params = await this.preparePresentPOIParams( + logger, + context, + allocationID, + poi, + force, + poiBlockNumber, + publicPOI, + actionID, + protocolNetwork, + ) + return await this.populatePresentPOITransaction(logger, params) + } + + async confirmPresentPOI( + actionID: number, + allocationID: string, + receipt: TransactionReceipt | 'paused' | 'unauthorized', + ): Promise { + const logger = this.logger.child({ action: actionID }) + + logger.info(`Confirming present-poi transaction (rewards only)`, { + allocationID, + }) + + if (receipt === 'paused' || receipt === 'unauthorized') { + throw indexerError( + IndexerErrorCode.IE062, + `Present POI for allocation '${allocationID}' failed: ${receipt}`, + ) + } + + const collectEventLogs = this.network.transactionManager.findEvent( + 'ServicePaymentCollected', + this.network.contracts.SubgraphService.interface, + 'serviceProvider', + this.network.specification.indexerOptions.address, + receipt, + this.logger, + ) + + if (!collectEventLogs) { + throw indexerError( + IndexerErrorCode.IE015, + `Present POI transaction was never successfully mined`, + ) + } + + const rewardsCollected = collectEventLogs.tokens ?? 0n + + logger.info(`Successfully presented POI and collected indexing rewards`, { + allocation: allocationID, + indexingRewards: formatGRT(rewardsCollected), + transaction: receipt.hash, + }) + + return { + actionID, + type: 'present_poi', + transactionID: receipt.hash, + allocation: allocationID, + indexingRewardsCollected: formatGRT(rewardsCollected), + protocolNetwork: this.network.specification.networkIdentifier, + } + } + async prepareReallocateParams( logger: Logger, context: TransactionPreparationContext, diff --git a/packages/indexer-common/src/indexer-management/client.ts b/packages/indexer-common/src/indexer-management/client.ts index 27cd68ce9..6931c90bb 100644 --- a/packages/indexer-common/src/indexer-management/client.ts +++ b/packages/indexer-common/src/indexer-management/client.ts @@ -106,6 +106,15 @@ const SCHEMA_SDL = gql` protocolNetwork: String! } + type PresentPOIResult { + actionID: Int! + type: String! + transactionID: String + allocation: String! + indexingRewardsCollected: String! + protocolNetwork: String! + } + enum ActionStatus { queued approved @@ -120,6 +129,7 @@ const SCHEMA_SDL = gql` allocate unallocate reallocate + present_poi } type Action { @@ -509,6 +519,14 @@ const SCHEMA_SDL = gql` force: Boolean protocolNetwork: String! ): ReallocateAllocationResult! + presentPOI( + allocation: String! + poi: String + blockNumber: Int + publicPOI: String + force: Boolean + protocolNetwork: String! + ): PresentPOIResult! submitCollectReceiptsJob(allocation: String!, protocolNetwork: String!): Boolean! updateAction(action: ActionInput!): Action! diff --git a/packages/indexer-common/src/indexer-management/models/action.ts b/packages/indexer-common/src/indexer-management/models/action.ts index e40f37b99..3fb884e37 100644 --- a/packages/indexer-common/src/indexer-management/models/action.ts +++ b/packages/indexer-common/src/indexer-management/models/action.ts @@ -66,6 +66,7 @@ export const defineActionModels = (sequelize: Sequelize): ActionModels => { ActionType.ALLOCATE, ActionType.UNALLOCATE, ActionType.REALLOCATE, + ActionType.PRESENT_POI, ), allowNull: false, validate: { @@ -201,6 +202,14 @@ export const defineActionModels = (sequelize: Sequelize): ActionModels => { `ActionType.REALLOCATE action must have required params: ['deploymentID','allocationID', 'amount]`, ) } + break + case ActionType.PRESENT_POI: + if (this.deploymentID === null || this.allocationID === null) { + throw new Error( + `ActionType.PRESENT_POI action must have required params: ['deploymentID','allocationID']`, + ) + } + break } }, }, diff --git a/packages/indexer-common/src/indexer-management/resolvers/actions.ts b/packages/indexer-common/src/indexer-management/resolvers/actions.ts index 308e57c3d..45ca09ba3 100644 --- a/packages/indexer-common/src/indexer-management/resolvers/actions.ts +++ b/packages/indexer-common/src/indexer-management/resolvers/actions.ts @@ -416,5 +416,7 @@ function compareActions(enqueued: Action, proposed: ActionInput): boolean { return poi && force case ActionType.REALLOCATE: return amount && poi && force + case ActionType.PRESENT_POI: + return poi && force } } diff --git a/packages/indexer-common/src/indexer-management/resolvers/allocations.ts b/packages/indexer-common/src/indexer-management/resolvers/allocations.ts index 3f0eef1e9..5e7f2a6e0 100644 --- a/packages/indexer-common/src/indexer-management/resolvers/allocations.ts +++ b/packages/indexer-common/src/indexer-management/resolvers/allocations.ts @@ -18,6 +18,7 @@ import { AllocationStatus, CloseAllocationResult, CreateAllocationResult, + encodeCollectData, epochElapsedBlocks, horizonAllocationIdProof, HorizonTransitionValue, @@ -35,8 +36,6 @@ import { uniqueAllocationID, } from '@graphprotocol/indexer-common' import { - encodeCollectIndexingRewardsData, - encodePOIMetadata, encodeStartServiceData, encodeStopServiceData, PaymentTypes, @@ -717,6 +716,101 @@ async function closeLegacyAllocation( return { txHash: receipt.hash, rewardsAssigned } } +/** + * Execute collect transaction and extract rewards - shared helper for collect and close + */ +async function executeCollectTransaction( + network: Network, + allocationId: string, + collectData: string, + logger: Logger, +): Promise<{ receipt: TransactionReceipt; rewardsCollected: bigint }> { + const contracts = network.contracts + const transactionManager = network.transactionManager + const address = network.specification.indexerOptions.address + + const receipt = await transactionManager.executeTransaction( + async () => + contracts.SubgraphService.collect.estimateGas( + address, + PaymentTypes.IndexingRewards, + collectData, + ), + async (gasLimit) => + contracts.SubgraphService.collect( + address, + PaymentTypes.IndexingRewards, + collectData, + { gasLimit }, + ), + logger, + ) + + if (receipt === 'paused' || receipt === 'unauthorized') { + throw indexerError( + IndexerErrorCode.IE062, + `Collect for allocation '${allocationId}' failed: ${receipt}`, + ) + } + + const collectEventLogs = transactionManager.findEvent( + 'ServicePaymentCollected', + contracts.SubgraphService.interface, + 'serviceProvider', + address, + receipt, + logger, + ) + + if (!collectEventLogs) { + throw indexerError( + IndexerErrorCode.IE015, + `Collect transaction was never successfully mined`, + ) + } + + const rewardsCollected = collectEventLogs.tokens ?? 0n + + return { receipt, rewardsCollected } +} + +/** + * Present POI and collect indexing rewards without closing the allocation (Horizon only) + */ +async function presentHorizonPOI( + allocation: Allocation, + poiData: POIData, + network: Network, + logger: Logger, +): Promise<{ txHash: string; rewardsCollected: bigint }> { + const contracts = network.contracts + const address = network.specification.indexerOptions.address + + // Double-check whether the allocation is still active on chain + const allocationState = await contracts.SubgraphService.getAllocation(allocation.id) + if (allocationState.closedAt !== 0n) { + throw indexerError(IndexerErrorCode.IE065, 'Allocation has already been closed') + } + + const collectData = encodeCollectData(allocation.id, poiData) + const { receipt, rewardsCollected } = await executeCollectTransaction( + network, + allocation.id, + collectData, + logger, + ) + + logger.info(`Successfully presented POI and collected indexing rewards`, { + allocation: allocation.id, + indexer: address, + poi: poiData.poi, + transaction: receipt.hash, + indexingRewards: formatGRT(rewardsCollected), + }) + + return { txHash: receipt.hash, rewardsCollected } +} + async function closeHorizonAllocation( allocation: Allocation, poiData: POIData, @@ -744,47 +838,30 @@ async function closeHorizonAllocation( isOverAllocated, }) - const encodedPOIMetadata = encodePOIMetadata( - poiData.blockNumber, - poiData.publicPOI, - poiData.indexingStatus, - 0, - 0, - ) - const collectIndexingRewardsData = encodeCollectIndexingRewardsData( - allocation.id, - poiData.poi, - encodedPOIMetadata, - ) + const collectData = encodeCollectData(allocation.id, poiData) 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 }, ) - receipt = await transactionManager.executeTransaction( - async () => - contracts.SubgraphService.collect.estimateGas( - address, - PaymentTypes.IndexingRewards, - collectIndexingRewardsData, - ), - async (gasLimit) => - contracts.SubgraphService.collect( - address, - PaymentTypes.IndexingRewards, - collectIndexingRewardsData, - { gasLimit }, - ), + 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, collectIndexingRewardsData], + [address, PaymentTypes.IndexingRewards, collectData], ) const closeAllocationData = encodeStopServiceData(allocation.id) const stopServiceCallData = contracts.SubgraphService.interface.encodeFunctionData( @@ -804,34 +881,33 @@ async function closeHorizonAllocation( }), 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 collectIndexingRewardsEventLogs = 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 (!collectIndexingRewardsEventLogs) { - throw indexerError( - IndexerErrorCode.IE015, - `Collecting indexing rewards for allocation '${allocation.id}' failed`, - ) + rewardsAssigned = collectEventLogs.tokens ?? 0n } - const rewardsAssigned = collectIndexingRewardsEventLogs - ? collectIndexingRewardsEventLogs.tokens - : 0n if (rewardsAssigned === 0n) { logger.warn('No rewards were distributed upon closing the allocation') } @@ -1215,18 +1291,7 @@ async function reallocateHorizonAllocation( epoch: currentEpoch.toString(), }) - const encodedPOIMetadata = encodePOIMetadata( - poiData.blockNumber, - poiData.publicPOI, - poiData.indexingStatus, - 0, - 0, - ) - const collectIndexingRewardsData = encodeCollectIndexingRewardsData( - allocation.id, - poiData.poi, - encodedPOIMetadata, - ) + const collectIndexingRewardsData = encodeCollectData(allocation.id, poiData) const closeAllocationData = ethers.AbiCoder.defaultAbiCoder().encode( ['address'], [allocation.id], @@ -1875,4 +1940,91 @@ export default { throw parsedError } }, + + presentPOI: async ( + { + allocation, + poi, + blockNumber, + publicPOI, + force, + protocolNetwork, + }: { + allocation: string + poi: string | undefined + blockNumber: string | undefined + publicPOI: string | undefined + force: boolean + protocolNetwork: string + }, + { logger, multiNetworks }: IndexerManagementResolverContext, + ): Promise<{ + actionID: number + type: string + transactionID: string | undefined + allocation: string + indexingRewardsCollected: string + protocolNetwork: string + }> => { + logger = logger.child({ + component: 'presentPOIResolver', + }) + + logger.info('Presenting POI (collecting indexing rewards without closing)', { + allocation: allocation, + poi: poi || 'none provided', + force, + }) + + if (!multiNetworks) { + throw Error('IndexerManagementClient must be in `network` mode to present POI') + } + + const network = extractNetwork(protocolNetwork, multiNetworks) + const networkMonitor = network.networkMonitor + const allocationData = await networkMonitor.allocation(allocation) + + // Present POI without closing only works for Horizon allocations + if (allocationData.isLegacy) { + throw new Error( + 'Cannot present POI (collect rewards) without closing for legacy allocations. Use closeAllocation instead.', + ) + } + + try { + logger.debug('Resolving POI') + const poiData = await networkMonitor.resolvePOI( + allocationData, + poi, + publicPOI, + blockNumber === undefined ? undefined : Number(blockNumber), + force, + ) + logger.debug('POI resolved', { + userProvidedPOI: poi, + userProvidedPublicPOI: publicPOI, + userProvidedBlockNumber: blockNumber, + poi: poiData.poi, + publicPOI: poiData.publicPOI, + blockNumber: poiData.blockNumber, + force, + }) + + // Use shared helper to present POI and collect rewards + const result = await presentHorizonPOI(allocationData, poiData, network, logger) + + return { + actionID: 0, + type: 'present_poi', + transactionID: result.txHash, + allocation: allocation, + indexingRewardsCollected: formatGRT(result.rewardsCollected), + protocolNetwork: network.specification.networkIdentifier, + } + } catch (error) { + const parsedError = tryParseCustomError(error) + logger.error('Failed to present POI', { error: parsedError }) + throw parsedError + } + }, } diff --git a/packages/indexer-common/src/indexer-management/types.ts b/packages/indexer-common/src/indexer-management/types.ts index 4e976f17a..ccea31bb0 100644 --- a/packages/indexer-common/src/indexer-management/types.ts +++ b/packages/indexer-common/src/indexer-management/types.ts @@ -49,6 +49,15 @@ export interface ReallocateAllocationResult { protocolNetwork: string } +export interface PresentPOIResult { + actionID: number + type: 'present_poi' + transactionID: string | undefined + allocation: string + indexingRewardsCollected: string + protocolNetwork: string +} + export interface ActionExecutionResult { actionID: number success: boolean @@ -95,6 +104,7 @@ export type AllocationResult = | CreateAllocationResult | CloseAllocationResult | ReallocateAllocationResult + | PresentPOIResult | ActionFailure /* eslint-disable @typescript-eslint/no-explicit-any */ From 8d17c4c4198a4b578204e86b6893dd64c90ddff5 Mon Sep 17 00:00:00 2001 From: MoonBoi9001 Date: Fri, 23 Jan 2026 14:31:25 -0500 Subject: [PATCH 02/14] test(actions): add unit tests for PRESENT_POI action type Add tests for the new PRESENT_POI action type covering: - Building action input with correct structure - POI parameter normalization (zero values) - Block number validation - Required field validation - Database action insertion and querying - Filter generation for action queries Co-Authored-By: Claude Opus 4.5 --- .../src/__tests__/actions.unit.test.ts | 106 ++++++++++++++++++ .../__tests__/helpers.test.ts | 47 ++++++++ 2 files changed, 153 insertions(+) create mode 100644 packages/indexer-cli/src/__tests__/actions.unit.test.ts diff --git a/packages/indexer-cli/src/__tests__/actions.unit.test.ts b/packages/indexer-cli/src/__tests__/actions.unit.test.ts new file mode 100644 index 000000000..88004e248 --- /dev/null +++ b/packages/indexer-cli/src/__tests__/actions.unit.test.ts @@ -0,0 +1,106 @@ +import { ActionStatus, ActionType } from '@graphprotocol/indexer-common' +import { buildActionInput, validateActionInput } from '../actions' + +describe('buildActionInput', () => { + test('builds PRESENT_POI action input with correct structure', async () => { + const result = await buildActionInput( + ActionType.PRESENT_POI, + { + targetDeployment: 'QmTest123', + param1: '0xallocationId', + param2: '0x' + 'ab'.repeat(32), // poi + param3: 'false', // force + param4: '12345', // blockNumber + param5: '0x' + 'cd'.repeat(32), // publicPOI + param6: undefined, + }, + 'test', + 'test', + ActionStatus.QUEUED, + 0, + 'arbitrum-sepolia', + ) + expect(result.type).toBe(ActionType.PRESENT_POI) + expect(result.deploymentID).toBe('QmTest123') + expect(result.allocationID).toBe('0xallocationId') + expect(result.poiBlockNumber).toBe(12345) + }) + + test('normalizes zero POI values for PRESENT_POI', async () => { + const result = await buildActionInput( + ActionType.PRESENT_POI, + { + targetDeployment: 'QmTest123', + param1: '0xallocationId', + param2: '0', // poi = '0' + param3: 'false', + param4: undefined, + param5: '0x0', // publicPOI = '0x0' + param6: undefined, + }, + 'test', + 'test', + ActionStatus.QUEUED, + 0, + 'arbitrum-sepolia', + ) + const zeroPOI = '0x' + '00'.repeat(32) + expect(result.poi).toBe(zeroPOI) + expect(result.publicPOI).toBe(zeroPOI) + expect(result.force).toBe(false) + expect(result.allocationID).toBe('0xallocationId') + expect(result.poiBlockNumber).toBeUndefined() + }) +}) + +describe('validateActionInput', () => { + test('validates PRESENT_POI with required fields', async () => { + await expect( + validateActionInput(ActionType.PRESENT_POI, { + targetDeployment: 'QmTest123', + param1: '0xallocationId', + param2: undefined, + param3: undefined, + param4: undefined, + param5: undefined, + param6: undefined, + }), + ).resolves.not.toThrow() + }) + + test('rejects PRESENT_POI with invalid block number', async () => { + await expect( + buildActionInput( + ActionType.PRESENT_POI, + { + targetDeployment: 'QmTest123', + param1: '0xallocationId', + param2: undefined, + param3: undefined, + param4: 'not-a-number', // invalid blockNumber + param5: undefined, + param6: undefined, + }, + 'test', + 'test', + ActionStatus.QUEUED, + 0, + 'arbitrum-sepolia', + ), + ).rejects.toThrow('Invalid block number: not-a-number') + }) + + test('rejects PRESENT_POI missing allocationID', async () => { + await expect( + validateActionInput(ActionType.PRESENT_POI, { + targetDeployment: 'QmTest123', + param1: undefined, // missing allocationID + param2: undefined, + param3: undefined, + param4: undefined, + param5: undefined, + param6: undefined, + }), + ).rejects.toThrow() + }) +}) diff --git a/packages/indexer-common/src/indexer-management/__tests__/helpers.test.ts b/packages/indexer-common/src/indexer-management/__tests__/helpers.test.ts index 4001146e4..cbd6908e0 100644 --- a/packages/indexer-common/src/indexer-management/__tests__/helpers.test.ts +++ b/packages/indexer-common/src/indexer-management/__tests__/helpers.test.ts @@ -29,7 +29,9 @@ import { resolveChainId, SubgraphDeployment, getTestProvider, + IndexingStatusCode, } from '@graphprotocol/indexer-common' +import { encodeCollectData } from '../allocations' import { mockLogger, mockProvider } from '../../__tests__/subgraph.test' import { hexlify, Provider } from 'ethers' import { @@ -293,7 +295,52 @@ describe('Actions', () => { }), ).resolves.toHaveLength(1) }) + + test('Insert and fetch PRESENT_POI action', async () => { + const action = { + status: ActionStatus.QUEUED, + type: ActionType.PRESENT_POI, + deploymentID: 'QmQ44hgrWWt3Qf2X9XEX2fPyTbmQbChxwNm5c1t4mhKpGt', + allocationID: '0x1234567890123456789012345678901234567890', + force: false, + source: 'indexerAgent', + reason: 'test', + priority: 0, + protocolNetwork: 'eip155:421614', + isLegacy: false, + } + await models.Action.upsert(action) + await expect( + ActionManager.fetchActions(models, null, { type: ActionType.PRESENT_POI }), + ).resolves.toHaveLength(1) + }) + + test('Generate where options for PRESENT_POI', () => { + const filter = { + status: ActionStatus.QUEUED, + type: ActionType.PRESENT_POI, + } + expect(actionFilterToWhereOptions(filter)).toEqual({ + [Op.and]: [{ status: 'queued' }, { type: 'present_poi' }], + }) + }) }) + +describe('Encoding', () => { + test('encodeCollectData encodes POI data correctly', () => { + const allocationId = '0x1234567890123456789012345678901234567890' + const poiData = { + poi: '0x' + 'ab'.repeat(32), + publicPOI: '0x' + 'cd'.repeat(32), + blockNumber: 12345, + indexingStatus: IndexingStatusCode.Healthy, + } + const result = encodeCollectData(allocationId, poiData) + expect(result).toMatch(/^0x/) + expect(result.length).toBeGreaterThan(2) + }) +}) + describe('Types', () => { test('Fail to resolve chain id', () => { expect(() => resolveChainId('arbitrum')).toThrow( From ac9cec305411e10bde29973731a1b0992cf6ea3d Mon Sep 17 00:00:00 2001 From: MoonBoi9001 Date: Tue, 24 Feb 2026 18:15:49 -0500 Subject: [PATCH 03/14] fix(cli): use present-poi instead of present_poi for CLI command Use kebab-case for the CLI command name for consistency with other commands and sub-commands. The internal enum representation stays compatible with GraphQL (which doesn't allow hyphens in enum names). Co-Authored-By: Claude Opus 4.6 --- packages/indexer-cli/src/actions.ts | 8 +++++--- .../indexer-cli/src/commands/indexer/actions/queue.ts | 2 +- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/indexer-cli/src/actions.ts b/packages/indexer-cli/src/actions.ts index 69ac52738..c196de403 100644 --- a/packages/indexer-cli/src/actions.ts +++ b/packages/indexer-cli/src/actions.ts @@ -144,7 +144,7 @@ export async function buildActionInput( } } case ActionType.PRESENT_POI: { - // present_poi + // present-poi const { poi, publicPOI, poiBlockNumber } = normalizePOIParams( actionParams.param2, actionParams.param5, @@ -196,15 +196,17 @@ export async function validateActionInput( } export function validateActionType(input: string): ActionType { + // Normalize hyphens to underscores (CLI uses present-poi, enum key is PRESENT_POI) + const normalized = input.replace(/-/g, '_') const validVariants = Object.keys(ActionType).map(variant => variant.toLocaleLowerCase(), ) - if (!validVariants.includes(input.toLocaleLowerCase())) { + if (!validVariants.includes(normalized.toLocaleLowerCase())) { throw Error( `Invalid 'ActionType' "${input}", must be one of ['${validVariants.join(`', '`)}']`, ) } - return ActionType[input.toUpperCase() as keyof typeof ActionType] + return ActionType[normalized.toUpperCase() as keyof typeof ActionType] } export function validateActionStatus(input: string): ActionStatus { diff --git a/packages/indexer-cli/src/commands/indexer/actions/queue.ts b/packages/indexer-cli/src/commands/indexer/actions/queue.ts index 9c93d7eae..f8fe6892f 100644 --- a/packages/indexer-cli/src/commands/indexer/actions/queue.ts +++ b/packages/indexer-cli/src/commands/indexer/actions/queue.ts @@ -22,7 +22,7 @@ ${chalk.dim('Action Types:')} allocate unallocate [poi] [force] [blockNumber] [publicPOI] reallocate [poi] [force] [blockNumber] [publicPOI] - present_poi [poi] [force] [blockNumber] [publicPOI] + present-poi [poi] [force] [blockNumber] [publicPOI] ${chalk.dim('Options:')} From 9af747282d5c345cef7b854020518ed1dc304bfb Mon Sep 17 00:00:00 2001 From: MoonBoi9001 Date: Tue, 24 Feb 2026 19:54:48 -0500 Subject: [PATCH 04/14] feat(actions): add RESIZE action type for Horizon allocations Add a new action type that resizes an existing allocation by changing its staked amount without closing it. This enables adjusting stake on long-lived allocations without triggering POI submission or reward collection. Changes: - ActionType.RESIZE enum value - GraphQL resizeAllocation mutation and ResizeAllocationResult type - Database model validation and migration for RESIZE actions - AllocationManager methods (prepareResize, confirmResize, etc.) - CLI support: `graph indexer actions queue resize ` - Stake usage summary handling for resize operations - IE087 error code for resize failures - Unit tests for action validation and stake calculations Contract interface: - Calls SubgraphService.resizeAllocation(indexer, allocationId, tokens) - Parses AllocationResized event for confirmation Co-Authored-By: Claude Opus 4.6 --- .../migrations/23-actions-add-resize-type.ts | 49 ++++ packages/indexer-cli/src/actions.ts | 18 ++ .../src/commands/indexer/actions/queue.ts | 1 + .../src/__tests__/actions.test.ts | 169 +++++++++++++ packages/indexer-common/src/actions.ts | 16 +- packages/indexer-common/src/errors.ts | 2 + .../__tests__/allocations.test.ts | 22 +- .../src/indexer-management/__tests__/util.ts | 13 + .../src/indexer-management/allocations.ts | 225 ++++++++++++++++-- .../src/indexer-management/client.ts | 16 ++ .../src/indexer-management/models/action.ts | 12 + .../indexer-management/resolvers/actions.ts | 2 + .../resolvers/allocations.ts | 142 +++++++++++ .../src/indexer-management/types.ts | 11 + 14 files changed, 679 insertions(+), 19 deletions(-) create mode 100644 packages/indexer-agent/src/db/migrations/23-actions-add-resize-type.ts create mode 100644 packages/indexer-common/src/__tests__/actions.test.ts diff --git a/packages/indexer-agent/src/db/migrations/23-actions-add-resize-type.ts b/packages/indexer-agent/src/db/migrations/23-actions-add-resize-type.ts new file mode 100644 index 000000000..b245f74e8 --- /dev/null +++ b/packages/indexer-agent/src/db/migrations/23-actions-add-resize-type.ts @@ -0,0 +1,49 @@ +import { Logger } from '@graphprotocol/common-ts' +import { DataTypes, QueryInterface } from 'sequelize' + +interface MigrationContext { + queryInterface: QueryInterface + logger: Logger +} + +interface Context { + context: MigrationContext +} + +export async function up({ context }: Context): Promise { + const { queryInterface, logger } = context + + logger.debug(`Checking if 'Actions' table exists`) + const tables = await queryInterface.showAllTables() + if (!tables.includes('Actions')) { + logger.info(`Actions table does not exist, migration not necessary`) + return + } + + logger.debug(`Checking if 'Actions' table needs to be migrated`) + const table = await queryInterface.describeTable('Actions') + const typeColumn = table.type + if (typeColumn) { + logger.debug(`'type' column exists with type = ${typeColumn.type}`) + logger.info(`Update 'type' column to support variant 'resize' type`) + await queryInterface.changeColumn('Actions', 'type', { + type: DataTypes.ENUM( + 'allocate', + 'unallocate', + 'reallocate', + 'present_poi', + 'resize', + ), + allowNull: false, + }) + return + } +} + +export async function down({ context }: Context): Promise { + const { logger } = context + logger.info( + `No 'down' migration needed since the 'up' migration simply added a new type 'resize'`, + ) + return +} diff --git a/packages/indexer-cli/src/actions.ts b/packages/indexer-cli/src/actions.ts index c196de403..641ae7cee 100644 --- a/packages/indexer-cli/src/actions.ts +++ b/packages/indexer-cli/src/actions.ts @@ -166,6 +166,21 @@ export async function buildActionInput( isLegacy, } } + case ActionType.RESIZE: { + // resize + return { + deploymentID: actionParams.targetDeployment, + allocationID: actionParams.param1, + amount: actionParams.param2?.toString(), + type, + source, + reason, + status, + priority, + protocolNetwork, + isLegacy, + } + } } } @@ -187,6 +202,9 @@ export async function validateActionInput( case ActionType.PRESENT_POI: requiredFields = requiredFields.concat(['targetDeployment', 'param1']) break + case ActionType.RESIZE: + requiredFields = requiredFields.concat(['targetDeployment', 'param1', 'param2']) + break } return await validateRequiredParams( diff --git a/packages/indexer-cli/src/commands/indexer/actions/queue.ts b/packages/indexer-cli/src/commands/indexer/actions/queue.ts index f8fe6892f..69baf7a29 100644 --- a/packages/indexer-cli/src/commands/indexer/actions/queue.ts +++ b/packages/indexer-cli/src/commands/indexer/actions/queue.ts @@ -23,6 +23,7 @@ ${chalk.dim('Action Types:')} unallocate [poi] [force] [blockNumber] [publicPOI] reallocate [poi] [force] [blockNumber] [publicPOI] present-poi [poi] [force] [blockNumber] [publicPOI] + resize ${chalk.dim('Options:')} diff --git a/packages/indexer-common/src/__tests__/actions.test.ts b/packages/indexer-common/src/__tests__/actions.test.ts new file mode 100644 index 000000000..d8a2d8018 --- /dev/null +++ b/packages/indexer-common/src/__tests__/actions.test.ts @@ -0,0 +1,169 @@ +import { ActionInput, ActionStatus, ActionType, isValidActionInput } from '../actions' + +describe('Action Validation', () => { + describe('isValidActionInput', () => { + const baseAction: Partial = { + status: ActionStatus.QUEUED, + source: 'indexerAgent', + reason: 'test', + priority: 0, + protocolNetwork: 'eip155:421614', + } + + test('validates ALLOCATE action requires deploymentID and amount', () => { + const validAllocate: ActionInput = { + ...baseAction, + type: ActionType.ALLOCATE, + deploymentID: 'Qmtest', + amount: '10000', + } as ActionInput + + expect(isValidActionInput(validAllocate)).toBe(true) + + const missingAmount: ActionInput = { + ...baseAction, + type: ActionType.ALLOCATE, + deploymentID: 'Qmtest', + } as ActionInput + + expect(isValidActionInput(missingAmount)).toBe(false) + }) + + test('validates UNALLOCATE action requires deploymentID and allocationID', () => { + const validUnallocate: ActionInput = { + ...baseAction, + type: ActionType.UNALLOCATE, + deploymentID: 'Qmtest', + allocationID: '0x1234567890123456789012345678901234567890', + } as ActionInput + + expect(isValidActionInput(validUnallocate)).toBe(true) + + const missingAllocationID: ActionInput = { + ...baseAction, + type: ActionType.UNALLOCATE, + deploymentID: 'Qmtest', + } as ActionInput + + expect(isValidActionInput(missingAllocationID)).toBe(false) + }) + + test('validates REALLOCATE action requires deploymentID, allocationID, and amount', () => { + const validReallocate: ActionInput = { + ...baseAction, + type: ActionType.REALLOCATE, + deploymentID: 'Qmtest', + allocationID: '0x1234567890123456789012345678901234567890', + amount: '20000', + } as ActionInput + + expect(isValidActionInput(validReallocate)).toBe(true) + + const missingAmount: ActionInput = { + ...baseAction, + type: ActionType.REALLOCATE, + deploymentID: 'Qmtest', + allocationID: '0x1234567890123456789012345678901234567890', + } as ActionInput + + expect(isValidActionInput(missingAmount)).toBe(false) + }) + + test('validates RESIZE action requires deploymentID, allocationID, and amount', () => { + const validResize: ActionInput = { + ...baseAction, + type: ActionType.RESIZE, + deploymentID: 'Qmtest', + allocationID: '0x1234567890123456789012345678901234567890', + amount: '20000', + } as ActionInput + + expect(isValidActionInput(validResize)).toBe(true) + + // Missing amount + const missingAmount: ActionInput = { + ...baseAction, + type: ActionType.RESIZE, + deploymentID: 'Qmtest', + allocationID: '0x1234567890123456789012345678901234567890', + } as ActionInput + + expect(isValidActionInput(missingAmount)).toBe(false) + + // Missing allocationID + const missingAllocationID: ActionInput = { + ...baseAction, + type: ActionType.RESIZE, + deploymentID: 'Qmtest', + amount: '20000', + } as ActionInput + + expect(isValidActionInput(missingAllocationID)).toBe(false) + + // Missing deploymentID + const missingDeploymentID: ActionInput = { + ...baseAction, + type: ActionType.RESIZE, + allocationID: '0x1234567890123456789012345678901234567890', + amount: '20000', + } as ActionInput + + expect(isValidActionInput(missingDeploymentID)).toBe(false) + }) + + test('validates common required fields (source, reason, status, priority)', () => { + // Missing status + const missingStatus = { + type: ActionType.ALLOCATE, + deploymentID: 'Qmtest', + amount: '10000', + source: 'test', + reason: 'test', + priority: 0, + protocolNetwork: 'eip155:421614', + } + + expect(isValidActionInput(missingStatus)).toBe(false) + + // Missing source + const missingSource = { + type: ActionType.ALLOCATE, + status: ActionStatus.QUEUED, + deploymentID: 'Qmtest', + amount: '10000', + reason: 'test', + priority: 0, + protocolNetwork: 'eip155:421614', + } + + expect(isValidActionInput(missingSource)).toBe(false) + + // Missing priority + const missingPriority = { + type: ActionType.ALLOCATE, + status: ActionStatus.QUEUED, + deploymentID: 'Qmtest', + amount: '10000', + source: 'test', + reason: 'test', + protocolNetwork: 'eip155:421614', + } + + expect(isValidActionInput(missingPriority)).toBe(false) + }) + + test('rejects action without type field', () => { + const noType = { + status: ActionStatus.QUEUED, + deploymentID: 'Qmtest', + amount: '10000', + source: 'test', + reason: 'test', + priority: 0, + protocolNetwork: 'eip155:421614', + } + + expect(isValidActionInput(noType)).toBe(false) + }) + }) +}) diff --git a/packages/indexer-common/src/actions.ts b/packages/indexer-common/src/actions.ts index f0dcc9a69..8ef093283 100644 --- a/packages/indexer-common/src/actions.ts +++ b/packages/indexer-common/src/actions.ts @@ -94,6 +94,13 @@ export const isValidActionInput = ( 'publicPOI' in variableToCheck && 'poiBlockNumber' in variableToCheck } + break + case ActionType.RESIZE: + hasActionParams = + 'deploymentID' in variableToCheck && + 'allocationID' in variableToCheck && + 'amount' in variableToCheck + break } return ( hasActionParams && @@ -157,8 +164,12 @@ export const validateActionInputs = async ( ) } - // Unallocate & reallocate actions must target an active allocationID - if ([ActionType.UNALLOCATE, ActionType.REALLOCATE].includes(action.type)) { + // Unallocate, reallocate, and resize actions must target an active allocationID + if ( + [ActionType.UNALLOCATE, ActionType.REALLOCATE, ActionType.RESIZE].includes( + action.type, + ) + ) { // allocationID must belong to active allocation // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const allocation = await networkMonitor.allocation(action.allocationID!) @@ -228,6 +239,7 @@ export enum ActionType { UNALLOCATE = 'unallocate', REALLOCATE = 'reallocate', PRESENT_POI = 'present_poi', + RESIZE = 'resize', } export enum ActionStatus { diff --git a/packages/indexer-common/src/errors.ts b/packages/indexer-common/src/errors.ts index 3b29959a0..49e3c9b3e 100644 --- a/packages/indexer-common/src/errors.ts +++ b/packages/indexer-common/src/errors.ts @@ -97,6 +97,7 @@ export enum IndexerErrorCode { IE084 = 'IE084', IE085 = 'IE085', IE086 = 'IE086', + IE087 = 'IE087', } export const INDEXER_ERROR_MESSAGES: Record = { @@ -187,6 +188,7 @@ export const INDEXER_ERROR_MESSAGES: Record = { IE084: 'Could not resolve POI block number', IE085: 'Could not resolve public POI', IE086: 'Indexer not registered in the Subgraph Service', + IE087: 'Failed to resize allocation', } export type IndexerErrorCause = unknown diff --git a/packages/indexer-common/src/indexer-management/__tests__/allocations.test.ts b/packages/indexer-common/src/indexer-management/__tests__/allocations.test.ts index 25f5dca12..e6d2ce5a9 100644 --- a/packages/indexer-common/src/indexer-management/__tests__/allocations.test.ts +++ b/packages/indexer-common/src/indexer-management/__tests__/allocations.test.ts @@ -21,6 +21,7 @@ import { invalidReallocateAction, invalidUnallocateAction, queuedAllocateAction, + queuedResizeAction, testNetworkSpecification, } from './util' import { Sequelize } from 'sequelize' @@ -125,10 +126,20 @@ describe.skip('Allocation Manager', () => { amount: '10000', allocationID, } + const resizeAction = { + ...queuedResizeAction, + amount: '20000', // resizing from 10000 to 20000 + allocationID, + } // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore: Mocking the Action type for this test - const actions = [queuedAllocateAction, unallocateAction, reallocateAction] as Action[] + const actions = [ + queuedAllocateAction, + unallocateAction, + reallocateAction, + resizeAction, + ] as Action[] test('stakeUsageSummary() correctly calculates token balances for array of actions', async () => { const balances = await Promise.all( @@ -138,6 +149,7 @@ describe.skip('Allocation Manager', () => { const allocate = balances[0] const unallocate = balances[1] const reallocate = balances[2] + const resize = balances[3] // Allocate test action expect(allocate.action.type).toBe(ActionType.ALLOCATE) @@ -161,6 +173,14 @@ describe.skip('Allocation Manager', () => { expect(reallocate.rewards).toBe(0n) expect(reallocate.unallocates).toStrictEqual(parseGRT('10000')) expect(reallocate.balance).toStrictEqual(parseGRT('0')) + + // Resize test action: resizing from 10000 to 20000 should require 10000 additional stake + // balance = allocates (newAmount) - unallocates (currentAmount) - rewards (0) + expect(resize.action.type).toBe(ActionType.RESIZE) + expect(resize.allocates).toStrictEqual(parseGRT('20000')) + expect(resize.rewards).toBe(0n) // RESIZE doesn't collect rewards + expect(resize.unallocates).toStrictEqual(parseGRT('10000')) + expect(resize.balance).toStrictEqual(parseGRT('10000')) // delta: 20000 - 10000 = 10000 }) test('validateActionBatchFeasibility() validates and correctly sorts actions based on net token balance', async () => { diff --git a/packages/indexer-common/src/indexer-management/__tests__/util.ts b/packages/indexer-common/src/indexer-management/__tests__/util.ts index 83e6ee409..0c832674e 100644 --- a/packages/indexer-common/src/indexer-management/__tests__/util.ts +++ b/packages/indexer-common/src/indexer-management/__tests__/util.ts @@ -134,3 +134,16 @@ export const invalidReallocateAction = { priority: 0, protocolNetwork: 'arbitrum-sepolia', } as ActionInput + +export const queuedResizeAction = { + status: ActionStatus.QUEUED, + type: ActionType.RESIZE, + deploymentID: subgraphDeployment1, + allocationID: '0x8f63930129e585c69482b56390a09b6b176f4a4c', + amount: '20000', + force: false, + source: 'indexerAgent', + reason: 'indexingRule', + priority: 0, + protocolNetwork: 'arbitrum-sepolia', +} as ActionInput diff --git a/packages/indexer-common/src/indexer-management/allocations.ts b/packages/indexer-common/src/indexer-management/allocations.ts index f14f522cd..9511fd703 100644 --- a/packages/indexer-common/src/indexer-management/allocations.ts +++ b/packages/indexer-common/src/indexer-management/allocations.ts @@ -30,6 +30,7 @@ import { Network, ReallocateAllocationResult, PresentPOIResult, + ResizeAllocationResult, SubgraphIdentifierType, SubgraphStatus, uniqueAllocationID, @@ -97,6 +98,14 @@ export interface PresentPOITransactionParams { protocolNetwork: string } +export interface ResizeTransactionParams { + allocationID: string + newAmount: bigint + indexer: string + actionID: number + protocolNetwork: string +} + export interface ReallocateTransactionParams { closingAllocationID: string poi: POIData @@ -572,6 +581,21 @@ export class AllocationManager { action.allocationID!, receipts[0], ) + case ActionType.RESIZE: + if (receipts.length !== 1) { + this.logger.error('Invalid number of receipts for resize action', { + receipts, + }) + throw new Error('Invalid number of receipts for resize action') + } + return await this.confirmResize( + action.id, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + action.allocationID!, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + action.amount!, + receipts[0], + ) } } @@ -662,6 +686,16 @@ export class AllocationManager { action.id, action.protocolNetwork, ) + case ActionType.RESIZE: + return await this.prepareResize( + logger, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + action.allocationID!, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + parseGRT(action.amount!), + action.id, + action.protocolNetwork, + ) } } catch (error) { logger.error(`Failed to prepare tx call data`, { @@ -1413,6 +1447,154 @@ export class AllocationManager { } } + // ---- RESIZE (change allocation stake without closing) ---- + + async prepareResizeParams( + logger: Logger, + allocationID: string, + newAmount: bigint, + actionID: number, + protocolNetwork: string, + ): Promise { + logger.info('Preparing to resize allocation', { + allocationID, + newAmount: newAmount.toString(), + }) + + // Validate the allocation is still active on chain + const allocationData = + await this.network.contracts.SubgraphService.getAllocation(allocationID) + if (allocationData.closedAt !== 0n) { + throw indexerError(IndexerErrorCode.IE065, 'Allocation has already been closed') + } + + // Validate amount is positive + if (newAmount <= 0n) { + throw indexerError( + IndexerErrorCode.IE061, + `Invalid resize amount: ${newAmount.toString()}. Amount must be positive.`, + ) + } + + return { + protocolNetwork, + actionID, + allocationID, + newAmount, + indexer: allocationData.indexer, + } + } + + async populateResizeTransaction( + logger: Logger, + params: ResizeTransactionParams, + ): Promise { + logger.debug('Populating resize allocation transaction', { + allocationID: params.allocationID, + newAmount: params.newAmount.toString(), + }) + + try { + // Call SubgraphService.resizeAllocation(indexer, allocationId, tokens) + const tx = + await this.network.contracts.SubgraphService.resizeAllocation.populateTransaction( + params.indexer, + params.allocationID, + params.newAmount, + ) + + return { + protocolNetwork: params.protocolNetwork, + actionID: params.actionID, + ...tx, + } + } catch (error) { + logger.error('Failed to populate resize transaction', { + allocationID: params.allocationID, + newAmount: params.newAmount.toString(), + error, + }) + throw indexerError( + IndexerErrorCode.IE087, + `Failed to prepare resize transaction for allocation '${params.allocationID}': ${error}`, + ) + } + } + + async prepareResize( + logger: Logger, + allocationID: string, + newAmount: bigint, + actionID: number, + protocolNetwork: string, + ): Promise { + const params = await this.prepareResizeParams( + logger, + allocationID, + newAmount, + actionID, + protocolNetwork, + ) + return await this.populateResizeTransaction(logger, params) + } + + async confirmResize( + actionID: number, + allocationID: string, + newAmount: string, + receipt: TransactionReceipt | 'paused' | 'unauthorized', + ): Promise { + const logger = this.logger.child({ action: actionID }) + + logger.info('Confirming resize allocation transaction', { + allocationID, + }) + + if (receipt === 'paused' || receipt === 'unauthorized') { + throw indexerError( + IndexerErrorCode.IE062, + `Resize allocation '${allocationID}' failed: ${receipt}`, + ) + } + + // Look for AllocationResized event from SubgraphService + const resizeEventLogs = this.network.transactionManager.findEvent( + 'AllocationResized', + this.network.contracts.SubgraphService.interface, + 'allocationId', + allocationID, + receipt, + this.logger, + ) + + if (!resizeEventLogs) { + throw indexerError( + IndexerErrorCode.IE015, + 'Resize allocation transaction was never successfully mined', + ) + } + + const previousAmount = resizeEventLogs.oldTokens ?? 0n + const actualNewAmount = resizeEventLogs.newTokens ?? 0n + + logger.info('Successfully resized allocation', { + allocation: allocationID, + previousAmount: formatGRT(previousAmount), + newAmount: formatGRT(actualNewAmount), + transaction: receipt.hash, + }) + + return { + actionID, + type: 'resize', + transactionID: receipt.hash, + allocation: allocationID, + previousAmount: formatGRT(previousAmount), + newAmount: formatGRT(actualNewAmount), + protocolNetwork: this.network.specification.networkIdentifier, + } + } + async prepareReallocateParams( logger: Logger, context: TransactionPreparationContext, @@ -2043,38 +2225,49 @@ export class AllocationManager { // We intentionally don't check if the allocation is active now because it will be checked // later, when we prepare the transaction. - if (action.type === ActionType.UNALLOCATE || action.type === ActionType.REALLOCATE) { + if ( + action.type === ActionType.UNALLOCATE || + action.type === ActionType.REALLOCATE || + action.type === ActionType.RESIZE + ) { // Ensure this Action have a valid allocationID if (action.allocationID === null || action.allocationID === undefined) { throw Error( - `SHOULD BE UNREACHABLE: Unallocate or Reallocate action must have an allocationID field: ${action}`, + `SHOULD BE UNREACHABLE: Unallocate, Reallocate, or Resize action must have an allocationID field: ${action}`, ) } // Fetch the allocation on chain to inspect its amount const allocation = await this.network.networkMonitor.allocation(action.allocationID) - // Accrue rewards, except for zeroed POI - const isHorizon = await this.network.isHorizon.value() - const zeroHexString = hexlify(new Uint8Array(32).fill(0)) - if (action.poi === zeroHexString) { - rewards = 0n - } else { - if (isHorizon) { - rewards = await this.network.contracts.RewardsManager.getRewards( - this.network.contracts.HorizonStaking.target, - action.allocationID, - ) + // RESIZE doesn't close the allocation, so no rewards are collected + if (action.type !== ActionType.RESIZE) { + // Accrue rewards, except for zeroed POI + const isHorizon = await this.network.isHorizon.value() + const zeroHexString = hexlify(new Uint8Array(32).fill(0)) + if (action.poi === zeroHexString) { + rewards = 0n } else { - rewards = await this.network.contracts.LegacyRewardsManager.getRewards( - action.allocationID, - ) + if (isHorizon) { + rewards = await this.network.contracts.RewardsManager.getRewards( + this.network.contracts.HorizonStaking.target, + action.allocationID, + ) + } else { + rewards = await this.network.contracts.LegacyRewardsManager.getRewards( + action.allocationID, + ) + } } } unallocates = unallocates + allocation.allocatedTokens } + // Calculate stake delta: positive means net allocation, negative means net release. + // For RESIZE: balance = newAmount - currentAmount (negative when downsizing). + // For UNALLOCATE: balance = 0 - currentAmount - rewards (always negative). + // For REALLOCATE: balance = newAmount - currentAmount - rewards. const balance = allocates - unallocates - rewards return { action, diff --git a/packages/indexer-common/src/indexer-management/client.ts b/packages/indexer-common/src/indexer-management/client.ts index 6931c90bb..9707917c4 100644 --- a/packages/indexer-common/src/indexer-management/client.ts +++ b/packages/indexer-common/src/indexer-management/client.ts @@ -115,6 +115,16 @@ const SCHEMA_SDL = gql` protocolNetwork: String! } + type ResizeAllocationResult { + actionID: Int! + type: String! + transactionID: String + allocation: String! + previousAmount: String! + newAmount: String! + protocolNetwork: String! + } + enum ActionStatus { queued approved @@ -130,6 +140,7 @@ const SCHEMA_SDL = gql` unallocate reallocate present_poi + resize } type Action { @@ -527,6 +538,11 @@ const SCHEMA_SDL = gql` force: Boolean protocolNetwork: String! ): PresentPOIResult! + resizeAllocation( + allocation: String! + amount: String! + protocolNetwork: String! + ): ResizeAllocationResult! submitCollectReceiptsJob(allocation: String!, protocolNetwork: String!): Boolean! updateAction(action: ActionInput!): Action! diff --git a/packages/indexer-common/src/indexer-management/models/action.ts b/packages/indexer-common/src/indexer-management/models/action.ts index 3fb884e37..2a7447211 100644 --- a/packages/indexer-common/src/indexer-management/models/action.ts +++ b/packages/indexer-common/src/indexer-management/models/action.ts @@ -67,6 +67,7 @@ export const defineActionModels = (sequelize: Sequelize): ActionModels => { ActionType.UNALLOCATE, ActionType.REALLOCATE, ActionType.PRESENT_POI, + ActionType.RESIZE, ), allowNull: false, validate: { @@ -210,6 +211,17 @@ export const defineActionModels = (sequelize: Sequelize): ActionModels => { ) } break + case ActionType.RESIZE: + if ( + this.deploymentID === null || + this.allocationID === null || + this.amount === null + ) { + throw new Error( + `ActionType.RESIZE action must have required params: ['deploymentID','allocationID', 'amount']`, + ) + } + break } }, }, diff --git a/packages/indexer-common/src/indexer-management/resolvers/actions.ts b/packages/indexer-common/src/indexer-management/resolvers/actions.ts index 45ca09ba3..b0e36e4c0 100644 --- a/packages/indexer-common/src/indexer-management/resolvers/actions.ts +++ b/packages/indexer-common/src/indexer-management/resolvers/actions.ts @@ -418,5 +418,7 @@ function compareActions(enqueued: Action, proposed: ActionInput): boolean { return amount && poi && force case ActionType.PRESENT_POI: return poi && force + case ActionType.RESIZE: + return amount } } diff --git a/packages/indexer-common/src/indexer-management/resolvers/allocations.ts b/packages/indexer-common/src/indexer-management/resolvers/allocations.ts index 5e7f2a6e0..45a7cfe9a 100644 --- a/packages/indexer-common/src/indexer-management/resolvers/allocations.ts +++ b/packages/indexer-common/src/indexer-management/resolvers/allocations.ts @@ -2027,4 +2027,146 @@ export default { throw parsedError } }, + + resizeAllocation: async ( + { + allocation, + amount, + protocolNetwork, + }: { + allocation: string + amount: string + protocolNetwork: string + }, + { logger, multiNetworks }: IndexerManagementResolverContext, + ): Promise<{ + actionID: number + type: string + transactionID: string | undefined + allocation: string + previousAmount: string + newAmount: string + protocolNetwork: string + }> => { + logger = logger.child({ + component: 'resizeAllocationResolver', + }) + + logger.info('Resizing allocation', { + allocation, + newAmount: amount, + }) + + if (!multiNetworks) { + throw Error( + 'IndexerManagementClient must be in `network` mode to resize allocation', + ) + } + + const network = extractNetwork(protocolNetwork, multiNetworks) + const networkMonitor = network.networkMonitor + const allocationData = await networkMonitor.allocation(allocation) + + try { + const newAmount = parseGRT(amount) + const result = await resizeHorizonAllocation( + allocationData, + newAmount, + network, + logger, + ) + + return { + actionID: 0, + type: 'resize', + transactionID: result.txHash, + allocation: allocation, + previousAmount: formatGRT(result.previousAmount), + newAmount: formatGRT(result.actualNewAmount), + protocolNetwork: network.specification.networkIdentifier, + } + } catch (error) { + const parsedError = tryParseCustomError(error) + logger.error('Failed to resize allocation', { error: parsedError }) + throw parsedError + } + }, +} + +// Helper function to execute a resize allocation transaction on Horizon. +// Follows the same pattern as presentHorizonPOI and closeHorizonAllocation. +async function resizeHorizonAllocation( + allocation: Allocation, + newAmount: bigint, + network: Network, + logger: Logger, +): Promise<{ txHash: string; previousAmount: bigint; actualNewAmount: bigint }> { + const contracts = network.contracts + const transactionManager = network.transactionManager + + // Validate the allocation is still active on chain + const onChainAllocation = await contracts.SubgraphService.getAllocation(allocation.id) + if (onChainAllocation.closedAt !== 0n) { + throw indexerError(IndexerErrorCode.IE065, 'Allocation has already been closed') + } + + const previousAmount = onChainAllocation.tokens + + logger.debug('Executing resize allocation transaction', { + allocationID: allocation.id, + previousAmount: formatGRT(previousAmount), + newAmount: formatGRT(newAmount), + }) + + const receipt = await transactionManager.executeTransaction( + async () => + contracts.SubgraphService.resizeAllocation.estimateGas( + allocation.indexer, + allocation.id, + newAmount, + ), + async (gasLimit) => + contracts.SubgraphService.resizeAllocation( + allocation.indexer, + allocation.id, + newAmount, + { gasLimit }, + ), + logger, + ) + + if (receipt === 'paused' || receipt === 'unauthorized') { + throw indexerError( + IndexerErrorCode.IE062, + `Resize allocation '${allocation.id}' failed: ${receipt}`, + ) + } + + // Parse AllocationResized event to get actual values + const resizeEventLogs = transactionManager.findEvent( + 'AllocationResized', + contracts.SubgraphService.interface, + 'allocationId', + allocation.id, + receipt, + logger, + ) + + if (!resizeEventLogs) { + throw indexerError( + IndexerErrorCode.IE015, + 'Resize allocation transaction was never successfully mined', + ) + } + + const actualNewAmount = resizeEventLogs.newTokens ?? newAmount + + logger.info('Successfully resized allocation', { + allocation: allocation.id, + previousAmount: formatGRT(previousAmount), + newAmount: formatGRT(actualNewAmount), + transaction: receipt.hash, + }) + + return { txHash: receipt.hash, previousAmount, actualNewAmount } } diff --git a/packages/indexer-common/src/indexer-management/types.ts b/packages/indexer-common/src/indexer-management/types.ts index ccea31bb0..8ed00af5b 100644 --- a/packages/indexer-common/src/indexer-management/types.ts +++ b/packages/indexer-common/src/indexer-management/types.ts @@ -58,6 +58,16 @@ export interface PresentPOIResult { protocolNetwork: string } +export interface ResizeAllocationResult { + actionID: number + type: 'resize' + transactionID: string | undefined + allocation: string + previousAmount: string + newAmount: string + protocolNetwork: string +} + export interface ActionExecutionResult { actionID: number success: boolean @@ -105,6 +115,7 @@ export type AllocationResult = | CloseAllocationResult | ReallocateAllocationResult | PresentPOIResult + | ResizeAllocationResult | ActionFailure /* eslint-disable @typescript-eslint/no-explicit-any */ From f821b54d16e269b0144cd16dce5a9a80e30abee4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Migone?= Date: Fri, 6 Mar 2026 16:50:33 -0300 Subject: [PATCH 05/14] fix: ensure resize ops create indexing rules if needed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomás Migone --- .../src/indexer-management/allocations.ts | 21 ++++++++++++++++++ .../resolvers/allocations.ts | 22 ++++++++++++++++++- 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/packages/indexer-common/src/indexer-management/allocations.ts b/packages/indexer-common/src/indexer-management/allocations.ts index 9511fd703..fad7f2190 100644 --- a/packages/indexer-common/src/indexer-management/allocations.ts +++ b/packages/indexer-common/src/indexer-management/allocations.ts @@ -1584,6 +1584,27 @@ export class AllocationManager { transaction: receipt.hash, }) + const allocation = await this.network.networkMonitor.allocation(allocationID) + const subgraphDeploymentID = new SubgraphDeploymentID( + allocation.subgraphDeployment.id.ipfsHash, + ) + + // If there is not yet an indexingRule that deems this deployment worth allocating to, make one + if (!(await this.matchingRuleExists(logger, subgraphDeploymentID))) { + logger.debug( + `No matching indexing rule found; updating indexing rules so indexer-agent will now manage the active allocation`, + ) + const indexingRule = { + identifier: allocation.subgraphDeployment.id.ipfsHash, + allocationAmount: formatGRT(actualNewAmount), + identifierType: SubgraphIdentifierType.DEPLOYMENT, + decisionBasis: IndexingDecisionBasis.ALWAYS, + protocolNetwork: this.network.specification.networkIdentifier, + } as Partial + + await upsertIndexingRule(logger, this.models, indexingRule) + } + return { actionID, type: 'resize', diff --git a/packages/indexer-common/src/indexer-management/resolvers/allocations.ts b/packages/indexer-common/src/indexer-management/resolvers/allocations.ts index 45a7cfe9a..ed6c070f8 100644 --- a/packages/indexer-common/src/indexer-management/resolvers/allocations.ts +++ b/packages/indexer-common/src/indexer-management/resolvers/allocations.ts @@ -2038,7 +2038,7 @@ export default { amount: string protocolNetwork: string }, - { logger, multiNetworks }: IndexerManagementResolverContext, + { logger, models, multiNetworks }: IndexerManagementResolverContext, ): Promise<{ actionID: number type: string @@ -2076,6 +2076,26 @@ export default { logger, ) + logger.debug( + `Updating indexing rules, so indexer-agent will now manage the active allocation`, + ) + const indexingRule = { + identifier: allocationData.subgraphDeployment.id.ipfsHash, + allocationAmount: formatGRT(result.actualNewAmount), + identifierType: SubgraphIdentifierType.DEPLOYMENT, + decisionBasis: IndexingDecisionBasis.ALWAYS, + protocolNetwork, + } as Partial + + await models.IndexingRule.upsert(indexingRule) + + const updatedRule = await models.IndexingRule.findOne({ + where: { identifier: indexingRule.identifier }, + }) + logger.debug(`DecisionBasis.ALWAYS rule merged into indexing rules`, { + rule: updatedRule, + }) + return { actionID: 0, type: 'resize', From ff19a90c8ea2ed03b0ce2fba080ccd45d8b91dd4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Migone?= Date: Fri, 6 Mar 2026 17:22:25 -0300 Subject: [PATCH 06/14] chore: rename present poi to camelcase as graphql doesnt like snake case MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomás Migone --- ...-add-resize-type.ts => 23-actions-add-new-types.ts} | 6 +++--- packages/indexer-cli/src/actions.ts | 2 +- packages/indexer-common/src/actions.ts | 2 +- packages/indexer-common/src/errors.ts | 4 ++++ .../src/indexer-management/__tests__/helpers.test.ts | 2 +- .../src/indexer-management/allocations.ts | 2 +- .../indexer-common/src/indexer-management/client.ts | 2 +- .../src/indexer-management/resolvers/allocations.ts | 10 +++++----- .../indexer-common/src/indexer-management/types.ts | 2 +- 9 files changed, 18 insertions(+), 14 deletions(-) rename packages/indexer-agent/src/db/migrations/{23-actions-add-resize-type.ts => 23-actions-add-new-types.ts} (89%) diff --git a/packages/indexer-agent/src/db/migrations/23-actions-add-resize-type.ts b/packages/indexer-agent/src/db/migrations/23-actions-add-new-types.ts similarity index 89% rename from packages/indexer-agent/src/db/migrations/23-actions-add-resize-type.ts rename to packages/indexer-agent/src/db/migrations/23-actions-add-new-types.ts index b245f74e8..2f954f9a4 100644 --- a/packages/indexer-agent/src/db/migrations/23-actions-add-resize-type.ts +++ b/packages/indexer-agent/src/db/migrations/23-actions-add-new-types.ts @@ -25,13 +25,13 @@ export async function up({ context }: Context): Promise { const typeColumn = table.type if (typeColumn) { logger.debug(`'type' column exists with type = ${typeColumn.type}`) - logger.info(`Update 'type' column to support variant 'resize' type`) + logger.info(`Update 'type' column to support 'presentPoi' and 'resize' types`) await queryInterface.changeColumn('Actions', 'type', { type: DataTypes.ENUM( 'allocate', 'unallocate', 'reallocate', - 'present_poi', + 'presentPoi', 'resize', ), allowNull: false, @@ -43,7 +43,7 @@ export async function up({ context }: Context): Promise { export async function down({ context }: Context): Promise { const { logger } = context logger.info( - `No 'down' migration needed since the 'up' migration simply added a new type 'resize'`, + `No 'down' migration needed since the 'up' migration simply added new types 'presentPoi' and 'resize'`, ) return } diff --git a/packages/indexer-cli/src/actions.ts b/packages/indexer-cli/src/actions.ts index 641ae7cee..690266353 100644 --- a/packages/indexer-cli/src/actions.ts +++ b/packages/indexer-cli/src/actions.ts @@ -214,7 +214,7 @@ export async function validateActionInput( } export function validateActionType(input: string): ActionType { - // Normalize hyphens to underscores (CLI uses present-poi, enum key is PRESENT_POI) + // Normalize hyphens to underscores to match enum keys (e.g., 'present-poi' -> 'present_poi' -> 'PRESENT_POI') const normalized = input.replace(/-/g, '_') const validVariants = Object.keys(ActionType).map(variant => variant.toLocaleLowerCase(), diff --git a/packages/indexer-common/src/actions.ts b/packages/indexer-common/src/actions.ts index 8ef093283..2dfe9c95d 100644 --- a/packages/indexer-common/src/actions.ts +++ b/packages/indexer-common/src/actions.ts @@ -238,7 +238,7 @@ export enum ActionType { ALLOCATE = 'allocate', UNALLOCATE = 'unallocate', REALLOCATE = 'reallocate', - PRESENT_POI = 'present_poi', + PRESENT_POI = 'presentPoi', RESIZE = 'resize', } diff --git a/packages/indexer-common/src/errors.ts b/packages/indexer-common/src/errors.ts index 49e3c9b3e..7e312f0d9 100644 --- a/packages/indexer-common/src/errors.ts +++ b/packages/indexer-common/src/errors.ts @@ -98,6 +98,8 @@ export enum IndexerErrorCode { IE085 = 'IE085', IE086 = 'IE086', IE087 = 'IE087', + IE088 = 'IE088', + IE089 = 'IE089', } export const INDEXER_ERROR_MESSAGES: Record = { @@ -189,6 +191,8 @@ export const INDEXER_ERROR_MESSAGES: Record = { IE085: 'Could not resolve public POI', IE086: 'Indexer not registered in the Subgraph Service', IE087: 'Failed to resize allocation', + IE088: 'Failed to present POI', + IE089: 'Failed to collect indexing rewards' } export type IndexerErrorCause = unknown diff --git a/packages/indexer-common/src/indexer-management/__tests__/helpers.test.ts b/packages/indexer-common/src/indexer-management/__tests__/helpers.test.ts index cbd6908e0..6ca7a4a59 100644 --- a/packages/indexer-common/src/indexer-management/__tests__/helpers.test.ts +++ b/packages/indexer-common/src/indexer-management/__tests__/helpers.test.ts @@ -321,7 +321,7 @@ describe('Actions', () => { type: ActionType.PRESENT_POI, } expect(actionFilterToWhereOptions(filter)).toEqual({ - [Op.and]: [{ status: 'queued' }, { type: 'present_poi' }], + [Op.and]: [{ status: 'queued' }, { type: 'presentPoi' }], }) }) }) diff --git a/packages/indexer-common/src/indexer-management/allocations.ts b/packages/indexer-common/src/indexer-management/allocations.ts index fad7f2190..8bf1a5301 100644 --- a/packages/indexer-common/src/indexer-management/allocations.ts +++ b/packages/indexer-common/src/indexer-management/allocations.ts @@ -1439,7 +1439,7 @@ export class AllocationManager { return { actionID, - type: 'present_poi', + type: 'presentPoi', transactionID: receipt.hash, allocation: allocationID, indexingRewardsCollected: formatGRT(rewardsCollected), diff --git a/packages/indexer-common/src/indexer-management/client.ts b/packages/indexer-common/src/indexer-management/client.ts index 9707917c4..940633cd5 100644 --- a/packages/indexer-common/src/indexer-management/client.ts +++ b/packages/indexer-common/src/indexer-management/client.ts @@ -139,7 +139,7 @@ const SCHEMA_SDL = gql` allocate unallocate reallocate - present_poi + presentPoi resize } diff --git a/packages/indexer-common/src/indexer-management/resolvers/allocations.ts b/packages/indexer-common/src/indexer-management/resolvers/allocations.ts index ed6c070f8..499920a53 100644 --- a/packages/indexer-common/src/indexer-management/resolvers/allocations.ts +++ b/packages/indexer-common/src/indexer-management/resolvers/allocations.ts @@ -717,7 +717,7 @@ async function closeLegacyAllocation( } /** - * Execute collect transaction and extract rewards - shared helper for collect and close + * Execute collect transaction for indexing rewards */ async function executeCollectTransaction( network: Network, @@ -749,7 +749,7 @@ async function executeCollectTransaction( if (receipt === 'paused' || receipt === 'unauthorized') { throw indexerError( IndexerErrorCode.IE062, - `Collect for allocation '${allocationId}' failed: ${receipt}`, + `Collect indexing rewards for allocation '${allocationId}' failed: ${receipt}`, ) } @@ -764,8 +764,8 @@ async function executeCollectTransaction( if (!collectEventLogs) { throw indexerError( - IndexerErrorCode.IE015, - `Collect transaction was never successfully mined`, + IndexerErrorCode.IE089, + `Transaction was never successfully mined`, ) } @@ -2015,7 +2015,7 @@ export default { return { actionID: 0, - type: 'present_poi', + type: 'presentPoi', transactionID: result.txHash, allocation: allocation, indexingRewardsCollected: formatGRT(result.rewardsCollected), diff --git a/packages/indexer-common/src/indexer-management/types.ts b/packages/indexer-common/src/indexer-management/types.ts index 8ed00af5b..035e34064 100644 --- a/packages/indexer-common/src/indexer-management/types.ts +++ b/packages/indexer-common/src/indexer-management/types.ts @@ -51,7 +51,7 @@ export interface ReallocateAllocationResult { export interface PresentPOIResult { actionID: number - type: 'present_poi' + type: 'presentPoi' transactionID: string | undefined allocation: string indexingRewardsCollected: string From 3a249d70a3e4538a8f84cc7df7acea0d1d92bc8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Migone?= Date: Fri, 6 Mar 2026 17:42:47 -0300 Subject: [PATCH 07/14] chore: minor fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomás Migone --- .../src/indexer-management/resolvers/allocations.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/indexer-common/src/indexer-management/resolvers/allocations.ts b/packages/indexer-common/src/indexer-management/resolvers/allocations.ts index 499920a53..063ef1b34 100644 --- a/packages/indexer-common/src/indexer-management/resolvers/allocations.ts +++ b/packages/indexer-common/src/indexer-management/resolvers/allocations.ts @@ -1984,10 +1984,10 @@ export default { const networkMonitor = network.networkMonitor const allocationData = await networkMonitor.allocation(allocation) - // Present POI without closing only works for Horizon allocations + // Present POI only works for Horizon allocations if (allocationData.isLegacy) { throw new Error( - 'Cannot present POI (collect rewards) without closing for legacy allocations. Use closeAllocation instead.', + 'Cannot present POI for legacy allocations.', ) } @@ -2010,7 +2010,6 @@ export default { force, }) - // Use shared helper to present POI and collect rewards const result = await presentHorizonPOI(allocationData, poiData, network, logger) return { From ea7a65d06839d74d44931b08d64272b188448191 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Migone?= Date: Fri, 6 Mar 2026 18:02:54 -0300 Subject: [PATCH 08/14] feat: add missing commands and other tweaks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomás Migone --- README.md | 4 +- docs/action-queue.md | 27 ++++- docs/operation-modes.md | 2 + .../db/migrations/23-actions-add-new-types.ts | 8 +- packages/indexer-cli/README.md | 4 +- packages/indexer-cli/src/allocations.ts | 95 +++++++++++++++ .../indexer/allocations/present-poi.ts | 112 ++++++++++++++++++ .../commands/indexer/allocations/resize.ts | 103 ++++++++++++++++ packages/indexer-common/src/actions.ts | 2 +- packages/indexer-common/src/errors.ts | 2 +- .../__tests__/helpers.test.ts | 2 +- .../src/indexer-management/allocations.ts | 2 +- .../src/indexer-management/client.ts | 2 +- .../resolvers/allocations.ts | 11 +- .../src/indexer-management/types.ts | 2 +- 15 files changed, 357 insertions(+), 21 deletions(-) create mode 100644 packages/indexer-cli/src/commands/indexer/allocations/present-poi.ts create mode 100644 packages/indexer-cli/src/commands/indexer/allocations/resize.ts diff --git a/README.md b/README.md index b16f3f671..e731d538a 100644 --- a/README.md +++ b/README.md @@ -248,14 +248,16 @@ Manage indexer configuration indexer allocations collect Collect receipts for an allocation indexer allocations create Create an allocation indexer allocations get List one or more allocations + indexer allocations present-poi Present POI and collect rewards without closing (Horizon) indexer allocations reallocate Reallocate to subgraph deployment + indexer allocations resize Resize allocation stake without closing (Horizon) indexer actions Manage indexer actions indexer actions approve Approve an action item indexer actions cancel Cancel an item in the queue indexer actions delete Delete one or many actions in the queue indexer actions execute Execute approved items in the action queue indexer actions get List one or more actions - indexer actions queue Queue an action item + indexer actions queue Queue an action item (allocate, unallocate, reallocate, present-poi, resize) indexer actions update Update one or more actions ``` diff --git a/docs/action-queue.md b/docs/action-queue.md index e2591e599..32fcdfd7e 100644 --- a/docs/action-queue.md +++ b/docs/action-queue.md @@ -47,6 +47,12 @@ Local usage from source # Queue unallocate action (closeAllocation()) ./bin/graph-indexer indexer actions queue unallocate QmeqJ6hsdyk9dVbo1tvRgAxWrVS3rkERiEMsxzPShKLco6 0x4a58d33e27d3acbaecc92c15101fbc82f47c2ae +# Queue present-poi action (collect indexing rewards without closing - Horizon only) +./bin/graph-indexer indexer actions queue present-poi QmeqJ6hsdyk9dVbo1tvRgAxWrVS3rkERiEMsxzPShKLco6 0x4a58d33e27d3acbaecc92c15101fbc82f47c2ae5 + +# Queue resize action (change allocation stake without closing - Horizon only) +./bin/graph-indexer indexer actions queue resize QmeqJ6hsdyk9dVbo1tvRgAxWrVS3rkERiEMsxzPShKLco6 0x4a58d33e27d3acbaecc92c15101fbc82f47c2ae5 75000 + # Update all queued reallocate actions, setting force=true and poi=0x0... ./bin/graph-indexer indexer actions update --status queued --type reallocate force true poi 0 @@ -112,7 +118,23 @@ type Query { - amount - optional action params: - poi - - force (forces using the provided POI even if it doesn’t match what the graph-node provides) + - force (forces using the provided POI even if it doesn't match what the graph-node provides) + +`PresentPOI` - collect indexing rewards by presenting a POI without closing the allocation (Horizon only) +- required action params: + - allocationID + - deploymentID +- optional action params: + - poi + - force (forces using the provided POI even if it doesn't match what the graph-node provides) + - blockNumber + - publicPOI + +`Resize` - change the allocated stake amount without closing the allocation (Horizon only) +- required action params: + - allocationID + - deploymentID + - amount ## How to send actions to the queue? The queueActions mutation provides an interface for sending an array of actions (ActionInput) to the queue. It is recommended that actions are sent to the queue with status = queued, so the indexer will need to approve the actions before they will be executed by the indexer management server. @@ -201,7 +223,8 @@ enum ActionType { allocate unallocate reallocate - collect + presentPOI + resize } enum ActionParams { diff --git a/docs/operation-modes.md b/docs/operation-modes.md index 78e988c67..43746c333 100644 --- a/docs/operation-modes.md +++ b/docs/operation-modes.md @@ -115,6 +115,8 @@ Rules are primarily managed through: | **ALLOCATE** | Creates rule with `decisionBasis: ALWAYS` if no matching rule exists | | **UNALLOCATE** | **Always** sets rule to `decisionBasis: NEVER` | | **REALLOCATE** | Creates rule with `decisionBasis: ALWAYS` if no matching rule exists | +| **PRESENT_POI** | No rule modification (Horizon only) | +| **RESIZE** | Creates rule with `decisionBasis: ALWAYS` if no matching rule exists (Horizon only) | This means: - After unallocating, you must manually change the rule back to `ALWAYS` or `RULES` if you want to allocate again diff --git a/packages/indexer-agent/src/db/migrations/23-actions-add-new-types.ts b/packages/indexer-agent/src/db/migrations/23-actions-add-new-types.ts index 2f954f9a4..0ac6ca7ac 100644 --- a/packages/indexer-agent/src/db/migrations/23-actions-add-new-types.ts +++ b/packages/indexer-agent/src/db/migrations/23-actions-add-new-types.ts @@ -25,13 +25,15 @@ export async function up({ context }: Context): Promise { const typeColumn = table.type if (typeColumn) { logger.debug(`'type' column exists with type = ${typeColumn.type}`) - logger.info(`Update 'type' column to support 'presentPoi' and 'resize' types`) + logger.info( + `Update 'type' column to support 'presentPOI' and 'resize' types`, + ) await queryInterface.changeColumn('Actions', 'type', { type: DataTypes.ENUM( 'allocate', 'unallocate', 'reallocate', - 'presentPoi', + 'presentPOI', 'resize', ), allowNull: false, @@ -43,7 +45,7 @@ export async function up({ context }: Context): Promise { export async function down({ context }: Context): Promise { const { logger } = context logger.info( - `No 'down' migration needed since the 'up' migration simply added new types 'presentPoi' and 'resize'`, + `No 'down' migration needed since the 'up' migration simply added new types 'presentPOI' and 'resize'`, ) return } diff --git a/packages/indexer-cli/README.md b/packages/indexer-cli/README.md index ee7d357de..11742265e 100644 --- a/packages/indexer-cli/README.md +++ b/packages/indexer-cli/README.md @@ -48,14 +48,16 @@ Manage indexer configuration indexer allocations collect Collect receipts for an allocation indexer allocations create Create an allocation indexer allocations get List one or more allocations + indexer allocations present-poi Present POI and collect rewards without closing (Horizon) indexer allocations reallocate Reallocate to subgraph deployment + indexer allocations resize Resize allocation stake without closing (Horizon) indexer actions Manage indexer actions indexer actions approve Approve an action item indexer actions cancel Cancel an item in the queue indexer actions delete Delete one or many actions in the queue indexer actions execute Execute approved items in the action queue indexer actions get List one or more actions - indexer actions queue Queue an action item + indexer actions queue Queue an action item (allocate, unallocate, reallocate, present-poi, resize) indexer actions update Update one or more actions ``` diff --git a/packages/indexer-cli/src/allocations.ts b/packages/indexer-cli/src/allocations.ts index 445582059..d29542ec7 100644 --- a/packages/indexer-cli/src/allocations.ts +++ b/packages/indexer-cli/src/allocations.ts @@ -8,7 +8,9 @@ import gql from 'graphql-tag' import { CloseAllocationResult, CreateAllocationResult, + PresentPOIResult, ReallocateAllocationResult, + ResizeAllocationResult, resolveChainAlias, } from '@graphprotocol/indexer-common' @@ -368,3 +370,96 @@ export const submitCollectReceiptsJob = async ( throw result.error } } + +export const presentPOI = async ( + client: IndexerManagementClient, + allocationID: string, + poi: string | undefined, + blockNumber: number | undefined, + publicPOI: string | undefined, + force: boolean, + protocolNetwork: string, +): Promise => { + const result = await client + .mutation( + gql` + mutation presentPOI( + $allocation: String! + $poi: String + $blockNumber: Int + $publicPOI: String + $force: Boolean + $protocolNetwork: String! + ) { + presentPOI( + allocation: $allocation + poi: $poi + blockNumber: $blockNumber + publicPOI: $publicPOI + force: $force + protocolNetwork: $protocolNetwork + ) { + allocation + indexingRewardsCollected + protocolNetwork + } + } + `, + { + allocation: allocationID, + poi, + blockNumber, + publicPOI, + force, + protocolNetwork, + }, + ) + .toPromise() + + if (result.error) { + throw result.error + } + + return result.data.presentPOI +} + +export const resizeAllocation = async ( + client: IndexerManagementClient, + allocationID: string, + amount: bigint, + protocolNetwork: string, +): Promise => { + const result = await client + .mutation( + gql` + mutation resizeAllocation( + $allocation: String! + $amount: String! + $protocolNetwork: String! + ) { + resizeAllocation( + allocation: $allocation + amount: $amount + protocolNetwork: $protocolNetwork + ) { + allocation + previousAmount + newAmount + protocolNetwork + } + } + `, + { + allocation: allocationID, + amount: amount.toString(), + protocolNetwork, + }, + ) + .toPromise() + + if (result.error) { + throw result.error + } + + return result.data.resizeAllocation +} diff --git a/packages/indexer-cli/src/commands/indexer/allocations/present-poi.ts b/packages/indexer-cli/src/commands/indexer/allocations/present-poi.ts new file mode 100644 index 000000000..f80c4c9d0 --- /dev/null +++ b/packages/indexer-cli/src/commands/indexer/allocations/present-poi.ts @@ -0,0 +1,112 @@ +import { GluegunToolbox } from 'gluegun' +import chalk from 'chalk' + +import { loadValidatedConfig } from '../../../config' +import { createIndexerManagementClient } from '../../../client' +import { presentPOI } from '../../../allocations' +import { + extractProtocolNetworkOption, + printObjectOrArray, + validatePOI, +} from '../../../command-helpers' + +const HELP = ` +${chalk.bold( + 'graph indexer allocations present-poi', +)} [options] [poi] [blockNumber] [publicPOI] + +${chalk.dim('Options:')} + + -h, --help Show usage information + -n, --network The protocol network for this action (mainnet, arbitrum-one, sepolia, arbitrum-sepolia) + -f, --force Bypass POI accuracy checks and submit transaction with provided data + -o, --output table|json|yaml Choose the output format: table (default), JSON, or YAML + +${chalk.dim('Arguments:')} + + The allocation id to present POI for + [poi] (optional) The POI to submit + [blockNumber] (optional) The block number the POI was computed at + [publicPOI] (optional) The public POI to submit + +${chalk.dim('Note:')} + + This command is only available for Horizon allocations. It collects indexing + rewards without closing the allocation. +` + +module.exports = { + name: 'present-poi', + alias: [], + description: 'Present POI and collect rewards without closing (Horizon)', + run: async (toolbox: GluegunToolbox) => { + const { print, parameters } = toolbox + + const spinner = toolbox.print.spin('Processing inputs') + + const { h, help, f, force, o, output } = parameters.options + + const outputFormat = o || output || 'table' + const toHelp = help || h || undefined + const toForce = force || f || false + + if (toHelp) { + spinner.stopAndPersist({ symbol: '💁', text: HELP }) + return + } + + if (!['json', 'yaml', 'table'].includes(outputFormat)) { + spinner.fail(`Invalid output format "${outputFormat}"`) + process.exitCode = 1 + return + } + + const [id, poi, unformattedBlockNumber, publicPOI] = parameters.array || [] + + if (id === undefined) { + spinner.fail(`Missing required argument: 'id'`) + print.info(HELP) + process.exitCode = 1 + return + } + + try { + const protocolNetwork = extractProtocolNetworkOption(parameters.options, true) + + if (!protocolNetwork) { + throw new Error( + 'Must provide a network identifier' + `(network: '${protocolNetwork}')`, + ) + } + + validatePOI(poi) + validatePOI(publicPOI) + const config = loadValidatedConfig() + const client = await createIndexerManagementClient({ url: config.api }) + + spinner.text = `Presenting POI for allocation '${id}'` + const result = await presentPOI( + client, + id, + poi, + unformattedBlockNumber ? Number(unformattedBlockNumber) : undefined, + publicPOI, + toForce, + protocolNetwork, + ) + + spinner.succeed('POI presented and rewards collected') + printObjectOrArray( + print, + outputFormat, + [result], + ['allocation', 'indexingRewardsCollected', 'protocolNetwork'], + 0, + ) + } catch (error) { + spinner.fail(error.toString()) + process.exitCode = 1 + return + } + }, +} diff --git a/packages/indexer-cli/src/commands/indexer/allocations/resize.ts b/packages/indexer-cli/src/commands/indexer/allocations/resize.ts new file mode 100644 index 000000000..0c7037ed2 --- /dev/null +++ b/packages/indexer-cli/src/commands/indexer/allocations/resize.ts @@ -0,0 +1,103 @@ +import { GluegunToolbox } from 'gluegun' +import chalk from 'chalk' + +import { loadValidatedConfig } from '../../../config' +import { createIndexerManagementClient } from '../../../client' +import { resizeAllocation } from '../../../allocations' +import { + extractProtocolNetworkOption, + printObjectOrArray, +} from '../../../command-helpers' + +const HELP = ` +${chalk.bold('graph indexer allocations resize')} [options] + +${chalk.dim('Options:')} + + -h, --help Show usage information + -n, --network The protocol network for this action (mainnet, arbitrum-one, sepolia, arbitrum-sepolia) + -o, --output table|json|yaml Choose the output format: table (default), JSON, or YAML + +${chalk.dim('Arguments:')} + + The allocation id to resize + The new amount of GRT for the allocation + +${chalk.dim('Note:')} + + This command is only available for Horizon allocations. It changes the + allocated stake without closing the allocation. +` + +module.exports = { + name: 'resize', + alias: [], + description: 'Resize allocation stake without closing (Horizon)', + run: async (toolbox: GluegunToolbox) => { + const { print, parameters } = toolbox + + const spinner = toolbox.print.spin('Processing inputs') + + const { h, help, o, output } = parameters.options + + const outputFormat = o || output || 'table' + const toHelp = help || h || undefined + + if (toHelp) { + spinner.stopAndPersist({ symbol: '💁', text: HELP }) + return + } + + if (!['json', 'yaml', 'table'].includes(outputFormat)) { + spinner.fail(`Invalid output format "${outputFormat}"`) + process.exitCode = 1 + return + } + + const [id, amount] = parameters.array || [] + + if (id === undefined) { + spinner.fail(`Missing required argument: 'id'`) + print.info(HELP) + process.exitCode = 1 + return + } + + if (amount === undefined) { + spinner.fail(`Missing required argument: 'amount'`) + print.info(HELP) + process.exitCode = 1 + return + } + + try { + const protocolNetwork = extractProtocolNetworkOption(parameters.options, true) + + if (!protocolNetwork) { + throw new Error( + 'Must provide a network identifier' + `(network: '${protocolNetwork}')`, + ) + } + + const allocationAmount = BigInt(amount) + const config = loadValidatedConfig() + const client = await createIndexerManagementClient({ url: config.api }) + + spinner.text = `Resizing allocation '${id}'` + const result = await resizeAllocation(client, id, allocationAmount, protocolNetwork) + + spinner.succeed('Allocation resized') + printObjectOrArray( + print, + outputFormat, + [result], + ['allocation', 'previousAmount', 'newAmount', 'protocolNetwork'], + 0, + ) + } catch (error) { + spinner.fail(error.toString()) + process.exitCode = 1 + return + } + }, +} diff --git a/packages/indexer-common/src/actions.ts b/packages/indexer-common/src/actions.ts index 2dfe9c95d..6a8bf9bf3 100644 --- a/packages/indexer-common/src/actions.ts +++ b/packages/indexer-common/src/actions.ts @@ -238,7 +238,7 @@ export enum ActionType { ALLOCATE = 'allocate', UNALLOCATE = 'unallocate', REALLOCATE = 'reallocate', - PRESENT_POI = 'presentPoi', + PRESENT_POI = 'presentPOI', RESIZE = 'resize', } diff --git a/packages/indexer-common/src/errors.ts b/packages/indexer-common/src/errors.ts index 7e312f0d9..0b1c0ddbd 100644 --- a/packages/indexer-common/src/errors.ts +++ b/packages/indexer-common/src/errors.ts @@ -192,7 +192,7 @@ export const INDEXER_ERROR_MESSAGES: Record = { IE086: 'Indexer not registered in the Subgraph Service', IE087: 'Failed to resize allocation', IE088: 'Failed to present POI', - IE089: 'Failed to collect indexing rewards' + IE089: 'Failed to collect indexing rewards', } export type IndexerErrorCause = unknown diff --git a/packages/indexer-common/src/indexer-management/__tests__/helpers.test.ts b/packages/indexer-common/src/indexer-management/__tests__/helpers.test.ts index 6ca7a4a59..565a996b7 100644 --- a/packages/indexer-common/src/indexer-management/__tests__/helpers.test.ts +++ b/packages/indexer-common/src/indexer-management/__tests__/helpers.test.ts @@ -321,7 +321,7 @@ describe('Actions', () => { type: ActionType.PRESENT_POI, } expect(actionFilterToWhereOptions(filter)).toEqual({ - [Op.and]: [{ status: 'queued' }, { type: 'presentPoi' }], + [Op.and]: [{ status: 'queued' }, { type: 'presentPOI' }], }) }) }) diff --git a/packages/indexer-common/src/indexer-management/allocations.ts b/packages/indexer-common/src/indexer-management/allocations.ts index 8bf1a5301..5a363ceba 100644 --- a/packages/indexer-common/src/indexer-management/allocations.ts +++ b/packages/indexer-common/src/indexer-management/allocations.ts @@ -1439,7 +1439,7 @@ export class AllocationManager { return { actionID, - type: 'presentPoi', + type: 'presentPOI', transactionID: receipt.hash, allocation: allocationID, indexingRewardsCollected: formatGRT(rewardsCollected), diff --git a/packages/indexer-common/src/indexer-management/client.ts b/packages/indexer-common/src/indexer-management/client.ts index 940633cd5..e6ad67708 100644 --- a/packages/indexer-common/src/indexer-management/client.ts +++ b/packages/indexer-common/src/indexer-management/client.ts @@ -139,7 +139,7 @@ const SCHEMA_SDL = gql` allocate unallocate reallocate - presentPoi + presentPOI resize } diff --git a/packages/indexer-common/src/indexer-management/resolvers/allocations.ts b/packages/indexer-common/src/indexer-management/resolvers/allocations.ts index 063ef1b34..b35e81cc5 100644 --- a/packages/indexer-common/src/indexer-management/resolvers/allocations.ts +++ b/packages/indexer-common/src/indexer-management/resolvers/allocations.ts @@ -763,10 +763,7 @@ async function executeCollectTransaction( ) if (!collectEventLogs) { - throw indexerError( - IndexerErrorCode.IE089, - `Transaction was never successfully mined`, - ) + throw indexerError(IndexerErrorCode.IE089, `Transaction was never successfully mined`) } const rewardsCollected = collectEventLogs.tokens ?? 0n @@ -1986,9 +1983,7 @@ export default { // Present POI only works for Horizon allocations if (allocationData.isLegacy) { - throw new Error( - 'Cannot present POI for legacy allocations.', - ) + throw new Error('Cannot present POI for legacy allocations.') } try { @@ -2014,7 +2009,7 @@ export default { return { actionID: 0, - type: 'presentPoi', + type: 'presentPOI', transactionID: result.txHash, allocation: allocation, indexingRewardsCollected: formatGRT(result.rewardsCollected), diff --git a/packages/indexer-common/src/indexer-management/types.ts b/packages/indexer-common/src/indexer-management/types.ts index 035e34064..673fe1e57 100644 --- a/packages/indexer-common/src/indexer-management/types.ts +++ b/packages/indexer-common/src/indexer-management/types.ts @@ -51,7 +51,7 @@ export interface ReallocateAllocationResult { export interface PresentPOIResult { actionID: number - type: 'presentPoi' + type: 'presentPOI' transactionID: string | undefined allocation: string indexingRewardsCollected: string From 68e8a56fdd50f3c4eaf7cd7e5ff1cc20c3dd2f00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Migone?= Date: Mon, 9 Mar 2026 10:07:47 -0300 Subject: [PATCH 09/14] fix: present-poi bad input sanitization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomás Migone --- .../commands/indexer/allocations/present-poi.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/indexer-cli/src/commands/indexer/allocations/present-poi.ts b/packages/indexer-cli/src/commands/indexer/allocations/present-poi.ts index f80c4c9d0..3a7f38ce6 100644 --- a/packages/indexer-cli/src/commands/indexer/allocations/present-poi.ts +++ b/packages/indexer-cli/src/commands/indexer/allocations/present-poi.ts @@ -61,7 +61,7 @@ module.exports = { return } - const [id, poi, unformattedBlockNumber, publicPOI] = parameters.array || [] + const [id, unformattedPoi, unformattedBlockNumber, unformattedPublicPOI] = parameters.array || [] if (id === undefined) { spinner.fail(`Missing required argument: 'id'`) @@ -70,6 +70,9 @@ module.exports = { return } + let poi: string | undefined + let blockNumber: number | undefined + let publicPOI: string | undefined try { const protocolNetwork = extractProtocolNetworkOption(parameters.options, true) @@ -79,8 +82,11 @@ module.exports = { ) } - validatePOI(poi) - validatePOI(publicPOI) + poi = validatePOI(unformattedPoi) + publicPOI = validatePOI(unformattedPublicPOI) + blockNumber = + unformattedBlockNumber === undefined ? undefined : Number(unformattedBlockNumber) + const config = loadValidatedConfig() const client = await createIndexerManagementClient({ url: config.api }) @@ -89,7 +95,7 @@ module.exports = { client, id, poi, - unformattedBlockNumber ? Number(unformattedBlockNumber) : undefined, + blockNumber, publicPOI, toForce, protocolNetwork, From 9cb2cb0ab0b9ba4a38fc20750804d6c788c6cac9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Migone?= Date: Mon, 9 Mar 2026 11:41:01 -0300 Subject: [PATCH 10/14] docs: refactor allocation management docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomás Migone --- docs/action-queue.md | 285 --------------------- docs/allocation-management/README.md | 73 ++++++ docs/allocation-management/action-queue.md | 242 +++++++++++++++++ docs/allocation-management/direct.md | 149 +++++++++++ docs/allocation-management/rules.md | 119 +++++++++ docs/operation-modes.md | 174 ++++++------- 6 files changed, 655 insertions(+), 387 deletions(-) delete mode 100644 docs/action-queue.md create mode 100644 docs/allocation-management/README.md create mode 100644 docs/allocation-management/action-queue.md create mode 100644 docs/allocation-management/direct.md create mode 100644 docs/allocation-management/rules.md diff --git a/docs/action-queue.md b/docs/action-queue.md deleted file mode 100644 index 32fcdfd7e..000000000 --- a/docs/action-queue.md +++ /dev/null @@ -1,285 +0,0 @@ -# Background -In the legacy paradigm the indexer-agent was the sole decision maker in the recommended indexer stack. It receives direction from the indexer in the form of indexing rules and uses that direction to take allocation management actions, sending transactions to Ethereum Mainnet to execute them. It uses the indexer management server as the source of indexer specific information (indexing rules, indexing deployments, cost models,...), directly queries the network subgraph on the graph-node for network information, and sends transactions directly to the Mainnet chain. - -The action queue decouples action execution from decision-making, provide oversight of decisions to indexers, and provide a more clear delineation of concerns in the software. The indexer management server handles external interactions (data fetching and executing actions) while the indexer agent will be focused on managing the reconciliation loop decision-making process, and ongoing management of allocations. By moving transaction execution and data fetching to the indexer management server, providing the option to turn off the agent's allocation management, and providing an allocation management interface hosted by the indexer management server we open up the design space for 3rd party decision-making software to replace or supplement the agent. - -# Usage -The action execution worker will only grab items from the action queue to execute if they have `ActionStatus` = `approved`. In the recommended path actions are added to the queue with `ActionStatus` = `queued`, so they must then be approved in order to be executed on-chain. The indexer-agent now has 3 management modes (set using `--allocation-management` or `INDEXER_AGENT_ALLOCATION_MANAGEMENT`): `auto`, `manual`, and `oversight`. - -## Allocation management modes: -- `auto`: The indexer-agent will act similarly to the legacy paradigm. When it identifies allocation actions it will add them to the queue with ActionStatus = `approved`; the execution worker process will pick up the approved actions within 30 seconds and execute them. -- `manual`: The indexer-agent will not add any items to the action queue in this mode. It will spin up an indexer-management server which can be interacted with manually or integrated with 3rd party tools to add actions to the action queue and execute them. -- `oversight`: The indexer-agent will add run its reconciliation loop to make allocation decisions and when actions are identified it will queue them. These actions will then require approval before they can be executed. - -## Actions CLI -The indexer-cli provides an `actions` module for manually working with the action queue. It uses the #Graphql API hosted by the indexer management server to interact with the actions queue. - -```bash -Manage indexer actions - - indexer actions update Update one or more actions - indexer actions queue Queue an action item - indexer actions get List one or more actions - indexer actions execute Execute approved items in the action queue - indexer actions delete Delete one or many actions in the queue - indexer actions cancel Cancel an item in the queue - indexer actions approve Approve an action item - indexer actions Manage indexer actions -``` - -Local usage from source -```bash -# Fetch all actions in the queue -./bin/graph-indexer indexer actions get all - -# Fetch actions by status -./bin/graph-indexer indexer actions get --status queued - -# Specify ordering criteria when fetching actions -./bin/graph-indexer indexer actions get --orderBy allocationAmount --orderDirection desc - -# Queue allocate action (allocateFrom()) -./bin/graph-indexer indexer actions queue allocate QmeqJ6hsdyk9dVbo1tvRgAxWrVS3rkERiEMsxzPShKLco6 5000 - -# Queue reallocate action (close and allocate using multicall()) -./bin/graph-indexer indexer actions queue reallocate QmeqJ6hsdyk9dVbo1tvRgAxWrVS3rkERiEMsxzPShKLco6 0x4a58d33e27d3acbaecc92c15101fbc82f47c2ae5 55000 - -# Queue unallocate action (closeAllocation()) -./bin/graph-indexer indexer actions queue unallocate QmeqJ6hsdyk9dVbo1tvRgAxWrVS3rkERiEMsxzPShKLco6 0x4a58d33e27d3acbaecc92c15101fbc82f47c2ae - -# Queue present-poi action (collect indexing rewards without closing - Horizon only) -./bin/graph-indexer indexer actions queue present-poi QmeqJ6hsdyk9dVbo1tvRgAxWrVS3rkERiEMsxzPShKLco6 0x4a58d33e27d3acbaecc92c15101fbc82f47c2ae5 - -# Queue resize action (change allocation stake without closing - Horizon only) -./bin/graph-indexer indexer actions queue resize QmeqJ6hsdyk9dVbo1tvRgAxWrVS3rkERiEMsxzPShKLco6 0x4a58d33e27d3acbaecc92c15101fbc82f47c2ae5 75000 - -# Update all queued reallocate actions, setting force=true and poi=0x0... -./bin/graph-indexer indexer actions update --status queued --type reallocate force true poi 0 - -# Cancel action in the queue -./bin/graph-indexer indexer actions cancel - -# Approve multiple actions for execution -./bin/graph-indexer indexer actions approve 1 3 5 - -# Approve all queued actions -./bin/graph-indexer indexer actions approve queued - -# Force the worker to execute approved actions immediately -./bin/graph-indexer indexer actions execute approve -``` - -# GraphQL API -The indexer management server has a graphQL endpoint (defaults to port 18000) that provides an interface for indexer components to fetch or modify indexer control data. It now supports several queries and mutations for interacting with the action queue/worker that allow simple integration with other allocation decision-making tools. 3rd party allocation optimizers can queue or apply actions for the indexer by sending action items to the action queue via the indexer management server. - - - -Action Schema (shortened for focus; the endpoint also includes other methods specified elsewhere) -```graphql -type Query { - action(actionID: String!): Action - actions( - filter: ActionFilter - orderBy: ActionParams - orderDirection: OrderDirection - first: Int - ): [Action]! - } - - type Mutation { - updateAction(action: ActionInput!): Action! - updateActions(filter: ActionFilter!, action: ActionUpdateInput!): [Action]! - queueActions(actions: [ActionInput!]!): [Action]! - cancelActions(actionIDs: [String!]!): [Action]! - deleteActions(actionIDs: [String!]!): Int! - approveActions(actionIDs: [String!]!): [Action]! - executeApprovedActions: [ActionResult!]! - } -``` - -## Supported action types for allocation management -`Allocate` - allocate stake to a specific subgraph deployment -- required action params: - - deploymentID - - amount - -`Unallocate` - close allocation, freeing up the stake to reallocate elsewhere -- required action params: - - allocationID - - deploymentID -- optional action params: - - poi - - force (forces using the provided POI even if it doesn’t match what the graph-node provides) - -`Reallocate` - atomically close allocation and open a fresh allocation for the same subgraph deployment -- required action params: - - allocationID - - deploymentID - - amount -- optional action params: - - poi - - force (forces using the provided POI even if it doesn't match what the graph-node provides) - -`PresentPOI` - collect indexing rewards by presenting a POI without closing the allocation (Horizon only) -- required action params: - - allocationID - - deploymentID -- optional action params: - - poi - - force (forces using the provided POI even if it doesn't match what the graph-node provides) - - blockNumber - - publicPOI - -`Resize` - change the allocated stake amount without closing the allocation (Horizon only) -- required action params: - - allocationID - - deploymentID - - amount - -## How to send actions to the queue? -The queueActions mutation provides an interface for sending an array of actions (ActionInput) to the queue. It is recommended that actions are sent to the queue with status = queued, so the indexer will need to approve the actions before they will be executed by the indexer management server. - -Queue actions schema -```graphql -queueActions(actions: [ActionInput!]!): [Action]! - -input ActionInput { - status: ActionStatus! - type: ActionType! - deploymentID: String - allocationID: String - amount: String - poi: String - force: Boolean - source: String! - reason: String! - priority: Int! -} - -input ActionUpdateInput { - id: Int - deploymentID: String - allocationID: String - amount: Int - poi: String - force: Boolean - type: ActionType - status: ActionStatus - reason: String -} - -type Action { - id: Int! - status: ActionStatus! - type: ActionType! - deploymentID: String - allocationID: String - amount: String - poi: String - force: Boolean - priority: Int! - source: String! - reason: String! - transaction: String - createdAt: BigInt! - updatedAt: BigInt -} - -type ActionResult { - id: Int! - type: ActionType! - deploymentID: String - allocationID: String - amount: String - poi: String - force: Boolean - source: String! - reason: String! - status: String! - transaction: String - failureReason: String - priority: Int -} - -input ActionFilter { - id: Int - type: ActionType - status: String - source: String - reason: String -} - -enum ActionStatus { - queued - approved - pending - deploying - success - failed - canceled -} - -enum ActionType { - allocate - unallocate - reallocate - presentPOI - resize -} - -enum ActionParams { - id - status - type - deploymentID - allocationID - transaction - amount - poi - force - source - reason - priority - createdAt - updatedAt -} -``` - -Example usage -```graphql -mutation queueActions($actions: [ActionInput!]!) { - queueActions(actions: $actions) { - id - type - deploymentID - allocationID - amount - poi - force - source - reason - priority - status - } -} -``` - -## What happens once actions are added to the queue? -The action execution worker will only grab items from the queue to execute if they have ActionStatus = approved. In the recommended path actions are added to the queue with ActionStatus = queued, so they must then be approved in order to be executed on-chain. So the flow in summary will look like: -- Action added to the queue by the 3rd party optimizer tool or indexer-cli user -- Indexer can use the `indexer-cli` to view all queued actions -- Indexer (or other software) can approve or cancel actions in the queue using the `indexer-cli`. The approve and cancel commands take an array of action ids as input. - - ```bash - graph-indexer indexer actions approve ... - graph-indexer indexer actions cancel ... - ``` - - example approve command - - ```bash - graph-indexer indexer actions approve 64 5 76 8 - ``` -- The execution worker regularly polls the queue for approved actions. It will grab the `approved` actions from the queue, attempt to execute them, and update the values in the db depending on the status of execution. For example: if an action’s execution is successful it will update its `ActionStatus` to `success` and populate the `transaction` field with the transaction id. -- If an action is successful the worker will ensure that there is an indexing rule present that tells the agent how to manage the allocation moving forward, useful when taking manual actions while the agent is in `auto` or `oversight` mode. -- The indexer can monitor the action queue to see a history of action execution and if needed re-approve and update action items if they failed execution. The action queue provides a history of all actions queued and taken. diff --git a/docs/allocation-management/README.md b/docs/allocation-management/README.md new file mode 100644 index 000000000..8b33aa461 --- /dev/null +++ b/docs/allocation-management/README.md @@ -0,0 +1,73 @@ +# Allocation Management + +There are three ways to manage allocations, ranging from fully automated to fully manual: + +| Method | How it works | When to use | +|--------|--------------|-------------| +| **[Indexing Rules](./rules.md)** | Set rules, let the agent decide | Automated or semi automated management in AUTO/OVERSIGHT mode | +| **[Action Queue](./action-queue.md)** | Queue actions, approve, execute | Batching, 3rd party tools | +| **[Direct Commands](./direct.md)** | Execute immediately on-chain | One-off operations, debugging, immediate control | + +## Indexing Rules + +You define rules that specify which deployments to allocate to and how much stake to use. The agent's [reconciliation loop](../operation-modes.md) evaluates these rules and queues the necessary actions automatically using the [action queue](./action-queue.md). + +```bash +graph indexer rules set QmXYZ... decisionBasis always allocationAmount 10000 +``` + +**Best for:** Hands-off operation where you want the agent to manage allocations based on criteria like signal, stake thresholds, or explicit deployment lists. + +**Requires:** AUTO or OVERSIGHT mode. In MANUAL mode, the reconciliation loop is skipped and rules have no effect. + +→ [Full documentation](./rules.md) + +## Action Queue + +Actions are queued, reviewed, approved, and then executed by a background worker. This gives you oversight and control over what gets executed on-chain. + +```bash +graph indexer actions queue allocate QmXYZ... 10000 +graph indexer actions approve 1 +``` + +**Best for:** +- Reviewing actions before execution (especially in OVERSIGHT mode) +- Batching multiple operations +- Integrating with 3rd party allocation optimizers +- Maintaining a history of all allocation actions + +**Works in all modes.** In AUTO/OVERSIGHT, the agent also queues actions here. In MANUAL, you queue everything yourself. + +→ [Full documentation](./action-queue.md) + +## Direct Commands + +Execute allocation operations immediately on the blockchain, bypassing the action queue entirely. + +```bash +graph indexer allocations create QmXYZ... 10000 --network arbitrum-one +``` + +**Best for:** One-off operations, debugging, or when you need immediate execution without waiting for the queue cycle. + +**Works in all modes.** These commands always execute immediately regardless of operation mode. + +→ [Full documentation](./direct.md) + +--- + +## Automatic Rule Updates + +When allocation actions execute, via manually queuing actions or running direct commands, indexing rules are automatically updated to keep the agent in sync: + +| Action | Rule Update | +|--------|-------------| +| `allocate` | Sets `decisionBasis: ALWAYS` | +| `unallocate` | Sets `decisionBasis: OFFCHAIN` | +| `reallocate` | Sets `decisionBasis: ALWAYS` | +| `resize` | Sets `decisionBasis: ALWAYS` | +| `present-poi` | No change | +| `collect` | No change | + +This prevents the agent from fighting your manual changes in AUTO/OVERSIGHT mode. diff --git a/docs/allocation-management/action-queue.md b/docs/allocation-management/action-queue.md new file mode 100644 index 000000000..99acca02e --- /dev/null +++ b/docs/allocation-management/action-queue.md @@ -0,0 +1,242 @@ +# Action Queue + +The action queue provides a staged approach to allocation management. Actions are queued, reviewed, approved, and then executed by a background worker. This enables oversight, batching, and integration with third-party tools. + +## How It Works + +1. Actions are added to the queue (by agent, CLI, or GraphQL API) +2. Actions sit in `queued` status until approved +3. Approved actions are picked up by the execution worker +4. Worker executes actions on-chain and updates status + +The execution worker polls for approved actions every ~30 seconds. + +## CLI Commands + +```bash +# View actions +graph indexer actions get all +graph indexer actions get --status queued +graph indexer actions get --status approved +graph indexer actions get --orderBy createdAt --orderDirection desc + +# Queue actions +graph indexer actions queue allocate +graph indexer actions queue unallocate +graph indexer actions queue reallocate +graph indexer actions queue present-poi # Horizon only +graph indexer actions queue resize # Horizon only + +# Approve actions +graph indexer actions approve [ ...] +graph indexer actions approve queued # Approve all queued actions + +# Cancel/delete actions +graph indexer actions cancel [ ...] +graph indexer actions delete [ ...] + +# Update action parameters +graph indexer actions update --status queued --type reallocate force true poi 0x... + +# Force immediate execution of approved actions +graph indexer actions execute approved +``` + +## Action Types + +### allocate +Allocate stake to a subgraph deployment. + +Required parameters: +- `deploymentID` - The subgraph deployment ID +- `amount` - Amount of GRT to allocate + +### unallocate +Close an existing allocation. + +Required parameters: +- `deploymentID` - The subgraph deployment ID +- `allocationID` - The allocation to close + +Optional parameters: +- `poi` - Proof of indexing to submit +- `force` - Force using provided POI even if it doesn't match graph-node + +### reallocate +Atomically close an allocation and open a new one for the same deployment. + +Required parameters: +- `deploymentID` - The subgraph deployment ID +- `allocationID` - The allocation to close +- `amount` - Amount of GRT for the new allocation + +Optional parameters: +- `poi` - Proof of indexing to submit +- `force` - Force using provided POI + +### present-poi (Horizon only) +Collect indexing rewards by presenting a POI without closing the allocation. + +Required parameters: +- `deploymentID` - The subgraph deployment ID +- `allocationID` - The allocation + +Optional parameters: +- `poi` - Proof of indexing +- `blockNumber` - Block number the POI was computed at +- `publicPOI` - Public POI (must be same block height as POI) +- `force` - Force using provided POI + +### resize (Horizon only) +Change the allocated stake amount without closing the allocation. + +Required parameters: +- `deploymentID` - The subgraph deployment ID +- `allocationID` - The allocation +- `amount` - New allocation amount + +## Action Statuses + +| Status | Description | +|--------|-------------| +| `queued` | Waiting for approval | +| `approved` | Ready for execution | +| `pending` | Being executed | +| `success` | Executed successfully | +| `failed` | Execution failed | +| `canceled` | Canceled before execution | + +## GraphQL API + +The indexer management server exposes a GraphQL endpoint (default port 18000) for programmatic access. + +### Queries + +```graphql +type Query { + action(actionID: String!): Action + actions( + filter: ActionFilter + orderBy: ActionParams + orderDirection: OrderDirection + first: Int + ): [Action]! +} +``` + +### Mutations + +```graphql +type Mutation { + queueActions(actions: [ActionInput!]!): [Action]! + approveActions(actionIDs: [String!]!): [Action]! + cancelActions(actionIDs: [String!]!): [Action]! + deleteActions(actionIDs: [String!]!): Int! + updateAction(action: ActionInput!): Action! + updateActions(filter: ActionFilter!, action: ActionUpdateInput!): [Action]! + executeApprovedActions: [ActionResult!]! +} +``` + +### Types + +```graphql +input ActionInput { + status: ActionStatus! + type: ActionType! + deploymentID: String + allocationID: String + amount: String + poi: String + force: Boolean + source: String! + reason: String! + priority: Int! +} + +type Action { + id: Int! + status: ActionStatus! + type: ActionType! + deploymentID: String + allocationID: String + amount: String + poi: String + force: Boolean + priority: Int! + source: String! + reason: String! + transaction: String + createdAt: BigInt! + updatedAt: BigInt +} + +enum ActionStatus { + queued + approved + pending + success + failed + canceled +} + +enum ActionType { + allocate + unallocate + reallocate + presentPOI + resize +} +``` + +### Example: Queue Actions via GraphQL + +```graphql +mutation queueActions($actions: [ActionInput!]!) { + queueActions(actions: $actions) { + id + type + deploymentID + allocationID + amount + status + } +} +``` + +Variables: +```json +{ + "actions": [ + { + "status": "queued", + "type": "allocate", + "deploymentID": "QmXYZ...", + "amount": "10000", + "source": "my-optimizer", + "reason": "High signal deployment", + "priority": 0 + } + ] +} +``` + +## Integration with Operation Modes + +| Mode | Agent Behavior | Your Actions | +|------|---------------|--------------| +| **AUTO** | Queues actions as `approved` | Can still queue your own actions | +| **OVERSIGHT** | Queues actions as `queued` | Must approve before execution | +| **MANUAL** | Doesn't queue anything | Queue and approve manually | + +## Third-Party Integration + +The action queue enables integration with external allocation optimization tools: + +1. Optimizer analyzes network state and identifies optimal allocations +2. Optimizer queues actions via GraphQL API with `status: queued` +3. Indexer reviews queued actions +4. Indexer approves actions via CLI or API +5. Execution worker processes approved actions + +This workflow provides human oversight while enabling sophisticated automated decision-making. diff --git a/docs/allocation-management/direct.md b/docs/allocation-management/direct.md new file mode 100644 index 000000000..932c0a216 --- /dev/null +++ b/docs/allocation-management/direct.md @@ -0,0 +1,149 @@ +# Direct Commands + +Direct commands execute allocation operations immediately on the blockchain, bypassing the action queue and the reconciliation loop. Use these when you need instant execution without waiting for the queue cycle. + +**Note:** Rules only drive automatic allocation decisions in **AUTO** and **OVERSIGHT** modes. In **MANUAL** mode, the reconciliation loop is skipped entirely. + +## Commands + +### Get Allocations + +View current allocations: + +```bash +graph indexer allocations get --network +graph indexer allocations get --status active --network +graph indexer allocations get --status closed --network +graph indexer allocations get --allocation --network +``` + +Options: +- `-n, --network` - Protocol network (mainnet, arbitrum-one, sepolia, arbitrum-sepolia) **required** +- `--status` - Filter by status: `active` or `closed` +- `--allocation` - Get a specific allocation by ID +- `-o, --output` - Output format: `table` (default), `json`, or `yaml` + +### Create Allocation + +Open a new allocation to a subgraph deployment: + +```bash +graph indexer allocations create [index-node] --network +``` + +Arguments: +- `deployment-id` - Subgraph deployment ID (bytes32 or IPFS hash) +- `amount` - Amount of GRT to allocate +- `index-node` - (optional) Specific index node to use + +Options: +- `-n, --network` - Protocol network **required** +- `-o, --output` - Output format + +Example: +```bash +graph indexer allocations create QmXa1b2c3d4e5f... 10000 --network arbitrum-one +``` + +### Close Allocation + +Close an existing allocation: + +```bash +graph indexer allocations close [poi] [block-number] [public-poi] --network +``` + +Arguments: +- `allocation-id` - The allocation ID to close +- `poi` - (optional) Proof of indexing +- `block-number` - (optional, Horizon only) Block number the POI was computed at +- `public-poi` - (optional, Horizon only) Public POI at the same block height + +Options: +- `-n, --network` - Protocol network **required** +- `-f, --force` - Bypass POI accuracy checks +- `-o, --output` - Output format + +Example: +```bash +graph indexer allocations close 0x1234...abcd --network arbitrum-one +graph indexer allocations close 0x1234...abcd 0xpoi... --force --network arbitrum-one +``` + +### Reallocate + +Atomically close an allocation and open a new one: + +```bash +graph indexer allocations reallocate [poi] [block-number] [public-poi] --network +``` + +Arguments: +- `allocation-id` - The allocation to close +- `amount` - Amount of GRT for the new allocation +- `poi` - (optional) Proof of indexing +- `block-number` - (optional, Horizon only) Block number +- `public-poi` - (optional, Horizon only) Public POI + +Options: +- `-n, --network` - Protocol network **required** +- `-f, --force` - Bypass POI accuracy checks +- `-o, --output` - Output format + +Example: +```bash +graph indexer allocations reallocate 0x1234...abcd 15000 --network arbitrum-one +``` + +### Present POI (Horizon Only) + +Collect indexing rewards by presenting a POI without closing the allocation: + +```bash +graph indexer allocations present-poi [poi] [block-number] [public-poi] --network +``` + +Arguments: +- `allocation-id` - The allocation +- `poi` - (optional) Proof of indexing +- `block-number` - (optional) Block number the POI was computed at +- `public-poi` - (optional) Public POI + +Options: +- `-n, --network` - Protocol network **required** +- `-f, --force` - Bypass POI accuracy checks +- `-o, --output` - Output format + +Example: +```bash +graph indexer allocations present-poi 0x1234...abcd --network arbitrum-one +``` + +### Resize (Horizon Only) + +Change the allocated stake without closing the allocation: + +```bash +graph indexer allocations resize --network +``` + +Arguments: +- `allocation-id` - The allocation to resize +- `amount` - New allocation amount + +Options: +- `-n, --network` - Protocol network **required** +- `-o, --output` - Output format + +Example: +```bash +graph indexer allocations resize 0x1234...abcd 20000 --network arbitrum-one +``` + +### Collect Query Fees + +Trigger query fee collection for an allocation: + +```bash +graph indexer allocations collect --network +``` diff --git a/docs/allocation-management/rules.md b/docs/allocation-management/rules.md new file mode 100644 index 000000000..8007285f0 --- /dev/null +++ b/docs/allocation-management/rules.md @@ -0,0 +1,119 @@ +# Indexing Rules + +Indexing rules tell the agent which subgraph deployments to allocate to and how much stake to allocate. The agent's reconciliation loop evaluates these rules and queues allocation actions accordingly using the action queue. For details on the action queue internals read [Action Queue](./action-queue.md). + +**Note:** Rules only drive automatic allocation decisions in **AUTO** and **OVERSIGHT** modes. In **MANUAL** mode, the reconciliation loop is skipped entirely. + +## CLI Commands + +```bash +# Set a rule for a specific deployment +graph indexer rules set [ ...] + +# Set the global (default) rule +graph indexer rules set global [ ...] + +# Get rules +graph indexer rules get all +graph indexer rules get +graph indexer rules get global + +# Delete a rule +graph indexer rules delete + +# Clear all rules (keeps global) +graph indexer rules clear + +# Shorthand commands +graph indexer rules start # Set decisionBasis to 'always' +graph indexer rules stop # Set decisionBasis to 'never' +graph indexer rules maybe # Set decisionBasis to 'rules' +graph indexer rules prepare # Set decisionBasis to 'offchain' +``` + +## Rule Parameters + +### Decision Basis + +The `decisionBasis` field determines whether the agent should allocate to a deployment: + +| Value | Behavior | +|-------|----------| +| `always` | Always allocate to this deployment | +| `never` | Never allocate to this deployment | +| `offchain` | Index the deployment but don't allocate (no on-chain stake) | +| `rules` | Evaluate against threshold parameters (see below) | + +### Threshold Parameters + +When `decisionBasis` is set to `rules`, these thresholds determine allocation eligibility: + +| Parameter | Description | +|-----------|-------------| +| `minStake` | Minimum total stake on the deployment | +| `minSignal` | Minimum curation signal on the deployment | +| `minAverageQueryFees` | Minimum average query fees | +| `maxSignal` | Maximum signal (to avoid over-allocated deployments) | +| `maxAllocationPercentage` | Maximum percentage of indexer's stake to allocate | + +### Allocation Parameters + +| Parameter | Description | +|-----------|-------------| +| `allocationAmount` | Amount of GRT to allocate | +| `allocationLifetime` | Number of epochs before reallocating | +| `parallelAllocations` | Number of parallel allocations to maintain | + +### Safety Parameters + +| Parameter | Description | +|-----------|-------------| +| `safety` | Enable safety checks (won't allocate to failed deployments) | +| `requireSupported` | Only allocate if deployment is on a supported network | +| `autoRenewal` | Automatically reallocate when allocation expires | + +## Global vs Deployment-Specific Rules + +- **Global rule**: Default values applied to all deployments +- **Deployment-specific rule**: Overrides global values for a specific deployment + +The agent merges rules, with deployment-specific values taking precedence over global defaults. + +## Example Usage + +```bash +# Set global defaults +graph indexer rules set global \ + decisionBasis rules \ + minSignal 100 \ + minStake 1000 \ + allocationAmount 10000 \ + safety true + +# Always allocate to a specific high-value deployment +graph indexer rules set QmXYZ... \ + decisionBasis always \ + allocationAmount 50000 + +# Index but don't allocate to a deployment (offchain indexing) +graph indexer rules set QmABC... \ + decisionBasis offchain + +# Stop allocating to a deployment +graph indexer rules stop QmDEF... +``` + +## How Rules Drive the Reconciliation Loop + +1. Agent fetches all deployments from the network subgraph +2. For each deployment, finds the applicable rule (deployment-specific or global) +3. Evaluates the `decisionBasis`: + - `always` → should allocate + - `never` → should not allocate + - `offchain` → should not allocate (but index locally) + - `rules` → evaluate against thresholds +4. Compares desired state against current allocations +5. Queues actions to reconcile differences: + - No allocation but should have one → queue `allocate` + - Has allocation but shouldn't → queue `unallocate` + - Allocation expiring → queue `reallocate` \ No newline at end of file diff --git a/docs/operation-modes.md b/docs/operation-modes.md index 43746c333..e268b58e8 100644 --- a/docs/operation-modes.md +++ b/docs/operation-modes.md @@ -1,74 +1,32 @@ # Indexer Agent Operation Modes -This document explains the internal workings of the indexer agent, focusing on allocation management, the reconciliation loop, and how indexing rules interact with allocation actions. +The indexer agent can automatically manage your allocations through a **reconciliation loop** - a background process that continuously monitors the network and adjusts your allocations based on your indexing rules. -## Allocation Management Modes +The **operation mode** controls whether and how this reconciliation loop runs. -The agent supports three allocation management modes, configured via `--allocation-management`: +## Operation Modes -| Mode | Behavior | -|------|----------| -| **AUTO** (default) | Allocation decisions are automatically approved and executed | -| **MANUAL** | Reconciliation is completely skipped; all actions must be manually created via CLI | -| **OVERSIGHT** | Actions are queued but require manual approval before execution | - -## The Reconciliation Loop - -The reconciliation loop runs continuously with two polling intervals: - -- **Small interval**: `pollingInterval` - for frequently changing data -- **Large interval**: `pollingInterval * 5` - for stable data - -### Data Streams - -The loop fetches and maintains: - -1. **Current epoch number** (large interval) -2. **Max allocation duration** (large interval) -3. **Indexing rules** (small interval) -4. **Active deployments on Graph Node** (large interval, AUTO mode only) -5. **Network deployments** (small interval) -6. **Active allocations** (small interval) -7. **Recently closed allocations** (small interval) - -### Two-Phase Process +Configure the mode via `--allocation-management` or `INDEXER_AGENT_ALLOCATION_MANAGEMENT`: -The reconciliation consists of two distinct phases: +| Mode | Reconciliation Loop | Behavior | Best For | +|------|---------------------|----------|----------| +| **AUTO** (default) | Runs | Agent decides, auto-approves, executes | Hands-off operation | +| **OVERSIGHT** | Runs | Agent decides, queues for your approval | Review before execution | +| **MANUAL** | Skipped | You manage allocations via CLI | Full manual control, 3rd party tools | -#### Phase 1: Deployment Evaluation +For information on how to manage allocations (rules, action queue, direct commands), see the [Allocation Management](./allocation-management/README.md) guide. -**What it does**: Determines which deployments the indexer *should* be allocated to based on indexing rules. +--- -- Takes all network deployments from the network subgraph -- Matches each deployment against indexing rules (deployment-specific or global) -- Outputs allocation decisions with `toAllocate: true/false` for each deployment - -**When it runs**: Every `pollingInterval * 5`, but **only in AUTO/OVERSIGHT mode**. Skipped entirely in MANUAL mode. - -**Decision basis options**: -- `always` - allocate -- `never` - don't allocate -- `offchain` - index but don't allocate -- `rules` - evaluate against thresholds (`minStake`, `minSignal`, `minAverageQueryFees`) - -#### Phase 2: Allocation Reconciliation - -**What it does**: Compares desired state (from deployment evaluation) against actual on-chain state and queues actions to resolve differences. - -| Condition | Action Queued | -|-----------|---------------| -| `toAllocate=true` + no active allocation | ALLOCATE | -| `toAllocate=false` + active allocation exists | UNALLOCATE | -| `toAllocate=true` + allocation expiring | REALLOCATE | - -**Expiration check**: `currentEpoch >= createdAtEpoch + allocationLifetime` +## What is the Reconciliation Loop? -**When it runs**: After deployment evaluation, **only in AUTO/OVERSIGHT mode**. Skipped in MANUAL mode. +The reconciliation loop is the agent's core automation mechanism. It periodically: -### Visual Flow +1. **Evaluates** which deployments you *should* be allocated to (based on your indexing rules) +2. **Compares** that desired state against your actual on-chain allocations +3. **Queues actions** to reconcile the difference (allocate, unallocate, reallocate) ``` -AUTO/OVERSIGHT MODE: ┌─────────────────────┐ ┌──────────────────────────┐ ┌─────────────┐ │ Deployment │ --> │ Allocation │ --> │ Action │ │ Evaluation │ │ Reconciliation │ │ Executor │ @@ -76,67 +34,79 @@ AUTO/OVERSIGHT MODE: │ "What SHOULD we │ │ "What do we need to DO │ │ Execute │ │ allocate to?" │ │ to match desired state?"│ │ on-chain │ └─────────────────────┘ └──────────────────────────┘ └─────────────┘ - -MANUAL MODE: -┌─────────────────────┐ ┌──────────────────────────┐ ┌─────────────┐ -│ Deployment │ │ Allocation │ │ Action │ -│ Evaluation │ │ Reconciliation │ │ Executor │ -│ │ │ │ │ │ -│ SKIPPED │ │ SKIPPED │ │ Still runs! │ -└─────────────────────┘ └──────────────────────────┘ └─────────────┘ - ^ - | - Manual CLI actions - (graph indexer actions queue) ``` -## Indexing Rules +In **AUTO** mode, actions are auto-approved and execute immediately. +In **OVERSIGHT** mode, actions are queued for your approval first. +In **MANUAL** mode, the loop doesn't run at all - you queue actions yourself. + +--- + +## Reconciliation Loop Details + +### Polling Intervals + +The loop runs on two intervals: + +- **Small interval**: `pollingInterval` - for frequently changing data +- **Large interval**: `pollingInterval * 5` - for stable data + +### Data Streams + +The loop fetches and maintains: -### Purpose +| Data | Interval | +|------|----------| +| Current epoch number | Large | +| Max allocation duration | Large | +| Indexing rules | Small | +| Active deployments on Graph Node | Large (AUTO mode only) | +| Network deployments | Small | +| Active allocations | Small | +| Recently closed allocations | Small | -Rules serve two purposes: +### Phase 1: Deployment Evaluation -1. **Allocation decisions** (AUTO/OVERSIGHT only): Determine whether to allocate via `decisionBasis` and threshold fields -2. **Deployment management** (ALL modes): Control what to index on Graph Node, provide defaults for manual actions +Determines which deployments you *should* be allocated to: -### Management +- Fetches all deployments from the network subgraph +- Matches each against your indexing rules (deployment-specific or global) +- Outputs `toAllocate: true/false` for each deployment -Rules are primarily managed through: +**Decision basis options** (from your rules): +- `always` - allocate +- `never` - don't allocate +- `offchain` - index locally but don't allocate on-chain +- `rules` - evaluate against thresholds (`minStake`, `minSignal`, `minAverageQueryFees`) -- **CLI**: `graph indexer rules set/delete/clear/get` -- **GraphQL API**: Direct mutations to the indexer management server +### Phase 2: Allocation Reconciliation -### Automatic Rule Modifications +Compares desired state against actual allocations and queues actions: -**Important**: Allocation actions automatically modify rules to maintain consistency: +| Condition | Action Queued | +|-----------|---------------| +| Should allocate + no active allocation | ALLOCATE | +| Shouldn't allocate + has active allocation | UNALLOCATE | +| Should allocate + allocation expiring | REALLOCATE | -| Action | Rule Modification | -|--------|------------------| -| **ALLOCATE** | Creates rule with `decisionBasis: ALWAYS` if no matching rule exists | -| **UNALLOCATE** | **Always** sets rule to `decisionBasis: NEVER` | -| **REALLOCATE** | Creates rule with `decisionBasis: ALWAYS` if no matching rule exists | -| **PRESENT_POI** | No rule modification (Horizon only) | -| **RESIZE** | Creates rule with `decisionBasis: ALWAYS` if no matching rule exists (Horizon only) | +**Expiration check**: `currentEpoch >= createdAtEpoch + allocationLifetime` -This means: -- After unallocating, you must manually change the rule back to `ALWAYS` or `RULES` if you want to allocate again -- The agent won't fight against manual allocation actions (it creates rules to match) +--- ## Safety Mechanisms -The agent includes several safety features: +The agent includes safety features to prevent problematic allocations: -1. **Health check**: Won't allocate to deployments with "failed" health status if rule has `safety=true` -2. **Zero POI safety**: Won't reallocate if previous allocation closed with zero POI and safety is enabled -3. **Approved actions check**: Skips reconciliation if there are already pending approved actions (prevents conflicts) +1. **Health check**: Won't allocate to deployments with "failed" health status (if `safety=true` in rule) +2. **Zero POI safety**: Won't reallocate if previous allocation closed with zero POI +3. **Approved actions check**: Skips reconciliation if pending approved actions exist (prevents conflicts) 4. **Network subgraph protection**: Never auto-allocated unless explicitly enabled via `--allocate-on-network-subgraph` -## Action Execution - -Actions flow through the action queue regardless of mode: +--- -1. **AUTO mode**: Actions are queued with `APPROVED` status and executed automatically -2. **OVERSIGHT mode**: Actions are queued with `QUEUED` status, require manual approval -3. **MANUAL mode**: No automatic actions; user queues actions via CLI with desired status +## Related Documentation -The action executor runs in all modes - in MANUAL mode it simply has no auto-generated actions to process. +- [Allocation Management Overview](./allocation-management/README.md) - How to manage allocations +- [Indexing Rules](./allocation-management/rules.md) - Configure what the agent should allocate to +- [Action Queue](./allocation-management/action-queue.md) - Queue, approve, and execute allocation actions +- [Direct Commands](./allocation-management/direct.md) - Execute allocation operations immediately From 6dbe9c8a86075d9f24939ccd38a010f32ad0f993 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Migone?= Date: Mon, 9 Mar 2026 15:10:42 -0300 Subject: [PATCH 11/14] chore: documentation fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomás Migone --- README.md | 462 +++++-------------- docs/allocation-management/README.md | 1 - docs/allocation-management/direct.md | 4 +- packages/indexer-agent/src/commands/start.ts | 2 +- 4 files changed, 128 insertions(+), 341 deletions(-) diff --git a/README.md b/README.md index e731d538a..e6a030f17 100644 --- a/README.md +++ b/README.md @@ -3,399 +3,185 @@ ![CI](https://github.com/graphprotocol/indexer/workflows/CI/badge.svg) [![Docker Image: Indexer Agent](https://github.com/graphprotocol/indexer/workflows/Indexer%20Agent%20Image/badge.svg)](https://github.com/orgs/graphprotocol/packages/container/package/indexer-agent) -**NOTE: THIS PROJECT IS BETA SOFTWARE.** +This repository contains the indexer agent and CLI for participating in The Graph Network as an indexer. -## The Graph Network vs. Testnet +## Components -For configuration details for The Graph Network and the testnet, see the -[Mainnet and Testnet Configuration docs](./docs/networks.md). +| Package | Description | +|---------|-------------| +| **[indexer-agent](./packages/indexer-agent)** | Autonomous agent that manages allocations, collects query fees, and submits proofs of indexing | +| **[indexer-cli](./packages/indexer-cli)** | CLI for managing indexer operations (`graph indexer ...`) | +| **[indexer-common](./packages/indexer-common)** | Shared library used by agent and CLI | -## Horizon Support +## Documentation -The indexer agent now supports Graph Horizon, the next-generation architecture for The Graph Protocol. Horizon introduces provision management, improved allocation management, enhanced payment processing via TAP v2, and support for the new set of protocol contracts. The indexer automatically detects Horizon-enabled networks and manages the transition seamlessly. +| Topic | Description | +|-------|-------------| +| [Network Configuration](./docs/networks.md) | Mainnet and testnet setup | +| [Operation Modes](./docs/operation-modes.md) | AUTO, OVERSIGHT, and MANUAL modes | +| [Allocation Management](./docs/allocation-management/) | How to manage allocations (rules, action queue, direct commands) | +| [Provision Management](./docs/provision-management.md) | Managing stake provisions (Horizon) | -## Running from NPM packages +## Quick Start -The indexer service, agent and CLI can be installed as NPM packages, using +### Installation ```sh npm install -g @graphprotocol/indexer-agent -# Indexer CLI is a plugin for Graph CLI, so both need to be installed: +# CLI is a plugin for graph-cli npm install -g @graphprotocol/graph-cli npm install -g @graphprotocol/indexer-cli ``` -After that, they can be run with the following commands: +### Running ```sh -# Indexer agent -graph-indexer-agent start ... - -# Indexer CLI -graph indexer ... -``` - -## Usage - -### Indexer agent - -```sh -$ graph-indexer-agent start --help - -Start the agent - -Indexer Infrastructure - --indexer-management-port Port to serve the indexer management API at - [number] [default: 8000] - --metrics-port Port to serve Prometheus metrics at - [number] [default: 7300] - --syncing-port Port to serve the network subgraph and other - syncing data for indexer service at - [number] [default: 8002] - --log-level Log level [string] [default: "debug"] - --polling-interval Polling interval for data collection - [number] [default: 120000] - --ipfs-endpoint IPFS endpoint for querying manifests. - [string] [required] [default: "https://ipfs.network.thegraph.com"] - --enable-auto-graft Automatically deploy and sync graft - dependencies for subgraphs - [boolean] [default: false] - --graph-node-query-endpoint Graph Node endpoint for querying subgraphs - [string] [required] - --graph-node-status-endpoint Graph Node endpoint for indexing statuses - etc. [string] [required] - --graph-node-admin-endpoint Graph Node endpoint for applying and - updating subgraph deployments - [string] [required] - --enable-auto-migration-support Auto migrate allocations from L1 to L2 - (multi-network mode must be enabled) - [boolean] [default: false] - --deployment-management Subgraph deployments management mode - [choices: "auto", "manual"] [default: "auto"] - --public-indexer-url Indexer endpoint for receiving requests from - the network [string] [required] - --indexer-geo-coordinates Coordinates describing the Indexer's - location using latitude and longitude - [string] [default: ["31.780715","-41.179504"]] - --restake-rewards Restake claimed indexer rewards, if set to - 'false' rewards will be returned to the - wallet [boolean] [default: true] - --allocation-management Indexer agent allocation management - automation mode (auto|manual) - [string] [default: "auto"] - --auto-allocation-min-batch-size Minimum number of allocation transactions - inside a batch for auto allocation - management. No obvious upperbound, with - default of 1 [number] [default: 1] - -Postgres - --postgres-host Postgres host [string] [required] - --postgres-port Postgres port [number] [default: 5432] - --postgres-username Postgres username [string] [default: "postgres"] - --postgres-password Postgres password [string] [default: ""] - --postgres-sslenabled Postgres SSL Enabled [boolean] [default: "false"] - --postgres-database Postgres database name [string] [required] - --postgres-pool-size Postgres maximum connection pool size - [number] [default: 50] - -Ethereum - --network-provider, --ethereum Ethereum node or provider URL - [string] [required] - --ethereum-polling-interval Polling interval for the Ethereum provider - (ms) [number] [default: 4000] - --gas-increase-timeout Time (in seconds) after which transactions - will be resubmitted with a higher gas price - [number] [default: 240] - --gas-increase-factor Factor by which gas prices are increased when - resubmitting transactions - [number] [default: 1.2] - --gas-price-max The maximum gas price (gwei) to use for - transactions - [deprecated] [number] [default: 100] - --base-fee-per-gas-max The maximum base fee per gas (gwei) to use for - transactions, for legacy transactions this - will be treated as the max gas price [number] - --transaction-attempts The maximum number of transaction attempts - (Use 0 for unlimited) [number] [default: 0] - --confirmation-blocks The number of blocks to wait for a transaction - to be confirmed [number] [default: 3] - --mnemonic Mnemonic for the operator wallet - [string] [required] - --indexer-address Ethereum address of the indexer - [string] [required] - --payments-destination Address where payments are sent to. If not - provided payments will be restaked. [string] - -Network Subgraph - --network-subgraph-deployment Network subgraph deployment (for local - hosting) [string] - --network-subgraph-endpoint Endpoint to query the network subgraph from - [string] - --allocate-on-network-subgraph Whether to allocate to the network subgraph - [boolean] [default: false] - --epoch-subgraph-deployment Epoch subgraph deployment (for local hosting) - [string] - -TAP Subgraph - --tap-subgraph-deployment TAP subgraph deployment (for local hosting)[string] - --tap-subgraph-endpoint Endpoint to query the tap subgraph from [string] - -Protocol - --epoch-subgraph-endpoint Endpoint to query the epoch block - oracle subgraph from - [string] [required] - --subgraph-max-block-distance How many blocks subgraphs are allowed - to stay behind chain head - [number] [default: 1000] - --subgraph-freshness-sleep-milliseconds How long to wait before retrying - subgraph query if it is not fresh - [number] [default: 5000] - --default-allocation-amount Default amount of GRT to allocate to - a subgraph deployment - [number] [default: 0.01] - --register Whether to register the indexer on - chain [boolean] [default: true] - --max-provision-initial-size The maximum number of tokens for the - initial Subgraph Service provision - [number] [default: 0] - -Query Fees - --rebate-claim-threshold Minimum value of rebate for a single - allocation (in GRT) in order for it - to be included in a batch rebate - claim on-chain [number] [default: 1] - --rebate-claim-batch-threshold Minimum total value of all rebates - in an batch (in GRT) before the - batch is claimed on-chain - [number] [default: 5] - --rebate-claim-max-batch-size Maximum number of rebates inside a - batch. Upper bound is constrained by - available system memory, and by the - block gas limit - [number] [default: 100] - --voucher-redemption-threshold Minimum value of rebate for a single - allocation (in GRT) in order for it - to be included in a batch rebate - claim on-chain [number] [default: 1] - --voucher-redemption-batch-threshold Minimum total value of all rebates - in an batch (in GRT) before the - batch is claimed on-chain - [number] [default: 5] - --voucher-redemption-max-batch-size Maximum number of rebates inside a - batch. Upper bound is constrained by - available system memory, and by the - block gas limit - [number] [default: 100] - --gateway-endpoint, Gateway endpoint base URL - --collect-receipts-endpoint [string] [required] - -Disputes - --poi-disputable-epochs The number of epochs in the past to look for - potential POI disputes [number] [default: 1] - --poi-dispute-monitoring Monitor the network for potential POI disputes - [boolean] [default: false] - -Options: - --version Show version number [boolean] - --help Show help [boolean] - --offchain-subgraphs Subgraphs to index that are not on chain - (comma-separated) [array] [default: []] - --horizon-address-book Graph Horizon contracts address book file - path [string] - --subgraph-service-address-book Subgraph Service contracts address book file - path [string] - --tap-address-book TAP contracts address book file path [string] - --chain-finalize-time The time in seconds that the chain finalizes - blocks [number] [default: 3600] +# Start the agent +graph-indexer-agent start \ + --network-provider \ + --graph-node-query-endpoint /subgraphs \ + --graph-node-status-endpoint /graphql \ + --graph-node-admin-endpoint /deploy \ + --mnemonic \ + --indexer-address \ + --postgres-host \ + --postgres-database \ + --network-subgraph-endpoint \ + --epoch-subgraph-endpoint \ + --gateway-endpoint \ + --public-indexer-url + +# Use the CLI +graph indexer rules set global decisionBasis always allocationAmount 1000 +graph indexer allocations get --network arbitrum-one +graph indexer actions get all ``` -### Indexer CLI - -Since indexer CLI is a plugin for `@graphprotocol/graph-cli`, once installed it is invoked -simply by running `graph indexer`. +### Docker ```sh -$ graph indexer --help -Manage indexer configuration - - indexer Manage indexer configuration - indexer status Check the status of an indexer - indexer rules Configure indexing rules - indexer rules clear (reset) Clear one or more indexing rules - indexer rules delete Remove one or many indexing rules - indexer rules get Get one or more indexing rules - indexer rules maybe Index a deployment based on rules - indexer rules prepare (offchain) Offchain index a deployment (and start indexing it if necessary) - indexer rules set Set one or more indexing rules - indexer rules start (always) Always index a deployment (and start indexing it if necessary) - indexer rules stop (never) Never index a deployment (and stop indexing it if necessary) - indexer provision Manage indexer's provision - indexer provision add Add stake to the indexer's provision - indexer provision get List indexer provision details - indexer provision list-thaw List thaw requests for the indexer's provision - indexer provision remove Remove thawed stake from the indexer's provision - indexer provision thaw Thaw stake from the indexer's provision - indexer disputes Configure allocation POI monitoring - indexer disputes get Cross-check POIs submitted in the network - indexer cost Manage costing for subgraphs - indexer cost set model Update a cost model - indexer cost delete Remove one or many cost models - indexer cost get Get cost models for one or all subgraphs - indexer connect Connect to indexer management API - indexer allocations Manage indexer allocations - indexer allocations close Close an allocation - indexer allocations collect Collect receipts for an allocation - indexer allocations create Create an allocation - indexer allocations get List one or more allocations - indexer allocations present-poi Present POI and collect rewards without closing (Horizon) - indexer allocations reallocate Reallocate to subgraph deployment - indexer allocations resize Resize allocation stake without closing (Horizon) - indexer actions Manage indexer actions - indexer actions approve Approve an action item - indexer actions cancel Cancel an item in the queue - indexer actions delete Delete one or many actions in the queue - indexer actions execute Execute approved items in the action queue - indexer actions get List one or more actions - indexer actions queue Queue an action item (allocate, unallocate, reallocate, present-poi, resize) - indexer actions update Update one or more actions +docker pull ghcr.io/graphprotocol/indexer-agent:latest +docker run -p 8000:8000 indexer-agent:latest start ... ``` -## Running from source +## Development -Run the following at the root of this repository to install dependencies and -build the packages: +### Building from Source ```sh -yarn +yarn # Install dependencies +yarn bootstrap # Bootstrap packages +yarn compile # Compile TypeScript ``` -After this, the agent can be run with: +### Running Tests ```sh -# Indexer agent -cd packages/indexer-agent -./bin/graph-indexer-agent start ... +# Create .env with test credentials (see .env.example) +bash scripts/run-tests.sh ``` -## Docker images +### Project Structure -The easiest way to run the indexer agent is by using Docker. Docker -images can either be pulled via - -```sh -docker pull ghcr.io/graphprotocol/indexer-agent:latest ``` - -or built locally with - -```sh -# Indexer agent -docker build \ - -f Dockerfile.indexer-agent \ - -t indexer-agent:latest \ - . +packages/ +├── indexer-agent/ # Main agent +├── indexer-cli/ # CLI tool +└── indexer-common/ # Shared library +docs/ # Documentation +k8s/ # Kubernetes configs +terraform/ # GKE deployment ``` -After this, the indexer agent can be run as follows: +## CLI Reference -1. Indexer Agent +
+Indexer Agent options - ```sh - docker run -p 18000:8000 -it indexer-agent:latest ... - ``` - - This starts the indexer agent and serves the so-called indexer management API - on the host at port 18000. - -## Terraform & Kubernetes - -The [terraform/](./terraform/) and [k8s/](./k8s) directories provide a -complete example setup for running an indexer on the Google Cloud Kubernetes -Engine (GKE). This setup was also used as the reference setup in the Mission -Control testnet and can be a good starting point for those looking to run the -indexer in a virtualized environment. - -Check out the [terraform README](./terraform/README.md) for details on how to -get started. - -## Releasing - -This repository is managed using [Lerna](https://lerna.js.org/) and [Yarn -workspaces](https://classic.yarnpkg.com/en/docs/workspaces/). +``` +graph-indexer-agent start --help -[chan](https://github.com/geut/chan/tree/master/packages/chan) is -used to maintain the following changelogs: +Indexer Infrastructure + --indexer-management-port Port for management API [default: 8000] + --metrics-port Port for Prometheus metrics [default: 7300] + --allocation-management Mode: auto|manual|oversight [default: auto] + --polling-interval Polling interval in ms [default: 120000] -- [indexer-agent](packages/indexer-agent/CHANGELOG.md) -- [indexer-cli](packages/indexer-cli/CHANGELOG.md) -- [indexer-common](packages/indexer-common/CHANGELOG.md) +Ethereum + --network-provider Ethereum RPC URL [required] + --mnemonic Operator wallet mnemonic [required] + --indexer-address Indexer address [required] -Creating a new release involves the following steps: +Postgres + --postgres-host Database host [required] + --postgres-database Database name [required] -1. Update all changelogs: +Network Subgraph + --network-subgraph-endpoint Network subgraph URL + --epoch-subgraph-endpoint Epoch subgraph URL [required] - ```sh - pushd packages/indexer-agent - chan added ... - chan fixed ... - chan changed ... - popd +See --help for all options. +``` - pushd packages/indexer-cli - ... - popd +
- pushd packages/indexer-common - ... - popd +
+Indexer CLI commands - ``` +``` +graph indexer --help + +indexer status Check indexer status +indexer connect Connect to management API + +indexer rules Manage indexing rules +indexer rules set Set indexing rules +indexer rules get Get indexing rules +indexer rules start Always index a deployment +indexer rules stop Never index a deployment + +indexer allocations Manage allocations +indexer allocations get List allocations +indexer allocations create Create allocation +indexer allocations close Close allocation +indexer allocations reallocate Reallocate +indexer allocations present-poi Present POI (Horizon) +indexer allocations resize Resize allocation (Horizon) + +indexer actions Manage action queue +indexer actions get List actions +indexer actions queue Queue an action +indexer actions approve Approve actions +indexer actions execute Execute approved actions + +indexer provision Manage provisions (Horizon) +indexer cost Manage cost models +indexer disputes Monitor POI disputes +``` -2. Publish the release. This includes committing the changelogs, tagging the - new version and publishing packages on npmjs.com. +
- ```sh - yarn release - ``` +## Infrastructure Deployment -## Running tests locally +For production deployments, see: +- [Terraform setup for GKE](./terraform/README.md) +- [Kubernetes configurations](./k8s/) -To run the tests locally, you'll need: -1. Docker installed and running -2. Node.js and Yarn -3. An Arbitrum Sepolia testnet RPC provider (e.g., Infura, Alchemy) -4. An API key from The Graph Studio for querying subgraphs +## Releasing -### Setup +This repository uses [Lerna](https://lerna.js.org/) with Yarn workspaces. -1. Create a `.env` file in the root directory with your credentials. You can copy the example file as a template: ```sh -cp .env.example .env -``` - -Then edit `.env` with your credentials: -```plaintext -# Your Arbitrum Sepolia testnet RPC endpoint -INDEXER_TEST_JRPC_PROVIDER_URL=https://sepolia.infura.io/v3/your-project-id +# Update changelogs with chan +pushd packages/indexer-agent && chan added "..." && popd -# Your API key from The Graph Studio (https://thegraph.com/studio/) -INDEXER_TEST_API_KEY=your-graph-api-key-here +# Publish release +yarn release ``` -2. Run the tests: -```sh -bash scripts/run-tests.sh -``` - -The script will: -- Start a PostgreSQL container with the required test configuration -- Load your credentials from the `.env` file -- Run the test suite -- Clean up the PostgreSQL container when done - -# Copyright +## License -Copyright © 2020-2021 The Graph Foundation +Copyright © 2020-2026 The Graph Foundation Licensed under the [MIT license](LICENSE). diff --git a/docs/allocation-management/README.md b/docs/allocation-management/README.md index 8b33aa461..7e4e5c52c 100644 --- a/docs/allocation-management/README.md +++ b/docs/allocation-management/README.md @@ -68,6 +68,5 @@ When allocation actions execute, via manually queuing actions or running direct | `reallocate` | Sets `decisionBasis: ALWAYS` | | `resize` | Sets `decisionBasis: ALWAYS` | | `present-poi` | No change | -| `collect` | No change | This prevents the agent from fighting your manual changes in AUTO/OVERSIGHT mode. diff --git a/docs/allocation-management/direct.md b/docs/allocation-management/direct.md index 932c0a216..956e459e1 100644 --- a/docs/allocation-management/direct.md +++ b/docs/allocation-management/direct.md @@ -142,7 +142,9 @@ graph indexer allocations resize 0x1234...abcd 20000 --network arbitrum-one ### Collect Query Fees -Trigger query fee collection for an allocation: +Trigger TAP (Timeline Aggregation Protocol) receipt collection for an allocation. This is a separate operation from allocation management - it triggers the collection of query fee receipts, not an allocation action. + +**Note:** Unlike other commands on this page, `collect` cannot be queued via the action queue. It always executes immediately. ```bash graph indexer allocations collect --network diff --git a/packages/indexer-agent/src/commands/start.ts b/packages/indexer-agent/src/commands/start.ts index 0d197e927..db10615f3 100644 --- a/packages/indexer-agent/src/commands/start.ts +++ b/packages/indexer-agent/src/commands/start.ts @@ -336,7 +336,7 @@ export const start = { }) .option('allocation-management', { description: - 'Indexer agent allocation management automation mode (auto|manual) ', + 'Indexer agent allocation management automation mode (auto|manual|oversight)', type: 'string', required: false, default: 'auto', From d96e083748e17effce4de959e57875472e994dc6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Migone?= Date: Mon, 9 Mar 2026 15:18:47 -0300 Subject: [PATCH 12/14] ci: format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomás Migone --- .../src/commands/indexer/allocations/present-poi.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/indexer-cli/src/commands/indexer/allocations/present-poi.ts b/packages/indexer-cli/src/commands/indexer/allocations/present-poi.ts index 3a7f38ce6..ef1a3ae80 100644 --- a/packages/indexer-cli/src/commands/indexer/allocations/present-poi.ts +++ b/packages/indexer-cli/src/commands/indexer/allocations/present-poi.ts @@ -61,7 +61,8 @@ module.exports = { return } - const [id, unformattedPoi, unformattedBlockNumber, unformattedPublicPOI] = parameters.array || [] + const [id, unformattedPoi, unformattedBlockNumber, unformattedPublicPOI] = + parameters.array || [] if (id === undefined) { spinner.fail(`Missing required argument: 'id'`) From 73c5878146802fbe7d288404783d22408b71c3bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Migone?= Date: Mon, 9 Mar 2026 15:19:52 -0300 Subject: [PATCH 13/14] test: fix cli tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomás Migone --- .../src/__tests__/references/indexer-help.stdout | 14 ++++++++------ .../src/__tests__/references/indexer.stdout | 13 ++++++++----- 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/packages/indexer-cli/src/__tests__/references/indexer-help.stdout b/packages/indexer-cli/src/__tests__/references/indexer-help.stdout index 8b6f5671d..730c4fd05 100644 --- a/packages/indexer-cli/src/__tests__/references/indexer-help.stdout +++ b/packages/indexer-cli/src/__tests__/references/indexer-help.stdout @@ -22,12 +22,14 @@ Manage indexer configuration indexer cost get Get cost models for one or all subgraphs indexer cost delete Remove one or many cost models indexer cost Manage costing for subgraphs - indexer connect Connect to indexer management API - indexer allocations reallocate Reallocate to subgraph deployment - indexer allocations get List one or more allocations - indexer allocations create Create an allocation - indexer allocations collect Collect receipts for an allocation - indexer allocations close Close an allocation + indexer connect Connect to indexer management API + indexer allocations resize Resize allocation stake without closing (Horizon) + indexer allocations reallocate Reallocate to subgraph deployment + indexer allocations present-poi Present POI and collect rewards without closing (Horizon) + indexer allocations get List one or more allocations + indexer allocations create Create an allocation + indexer allocations collect Collect receipts for an allocation + indexer allocations close Close an allocation indexer allocations Manage indexer allocations indexer actions update Update one or more actions indexer actions queue Queue an action item diff --git a/packages/indexer-cli/src/__tests__/references/indexer.stdout b/packages/indexer-cli/src/__tests__/references/indexer.stdout index 4be8ef97d..b37c44fcf 100644 --- a/packages/indexer-cli/src/__tests__/references/indexer.stdout +++ b/packages/indexer-cli/src/__tests__/references/indexer.stdout @@ -22,10 +22,13 @@ Manage indexer configuration indexer cost get Get cost models and/or variables for one or all subgraphs indexer cost Manage costing for subgraphs indexer cost delete Remove one or many cost models - indexer connect Connect to indexer management API - indexer allocations reallocate Reallocate to subgraph deployment - indexer allocations get List one or more allocations - indexer allocations create Create an allocation - indexer allocations close Close an allocation + indexer connect Connect to indexer management API + indexer allocations resize Resize allocation stake without closing (Horizon) + indexer allocations reallocate Reallocate to subgraph deployment + indexer allocations present-poi Present POI and collect rewards without closing (Horizon) + indexer allocations get List one or more allocations + indexer allocations create Create an allocation + indexer allocations collect Collect receipts for an allocation + indexer allocations close Close an allocation indexer allocations Manage indexer allocations indexer Manage indexer configuration From 6b7955ef24c0e2af738e5024bd46b5cf352d5c8d Mon Sep 17 00:00:00 2001 From: MoonBoi9001 Date: Mon, 9 Mar 2026 14:44:30 -0500 Subject: [PATCH 14/14] fix: pad CLI test reference files to match help formatter output Co-Authored-By: Claude Opus 4.6 --- .../src/__tests__/references/indexer-help.stdout | 16 ++++++++-------- .../src/__tests__/references/indexer.stdout | 16 ++++++++-------- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/packages/indexer-cli/src/__tests__/references/indexer-help.stdout b/packages/indexer-cli/src/__tests__/references/indexer-help.stdout index 730c4fd05..c81f45f38 100644 --- a/packages/indexer-cli/src/__tests__/references/indexer-help.stdout +++ b/packages/indexer-cli/src/__tests__/references/indexer-help.stdout @@ -22,14 +22,14 @@ Manage indexer configuration indexer cost get Get cost models for one or all subgraphs indexer cost delete Remove one or many cost models indexer cost Manage costing for subgraphs - indexer connect Connect to indexer management API - indexer allocations resize Resize allocation stake without closing (Horizon) - indexer allocations reallocate Reallocate to subgraph deployment - indexer allocations present-poi Present POI and collect rewards without closing (Horizon) - indexer allocations get List one or more allocations - indexer allocations create Create an allocation - indexer allocations collect Collect receipts for an allocation - indexer allocations close Close an allocation + indexer connect Connect to indexer management API + indexer allocations resize Resize allocation stake without closing (Horizon) + indexer allocations reallocate Reallocate to subgraph deployment + indexer allocations present-poi Present POI and collect rewards without closing (Horizon) + indexer allocations get List one or more allocations + indexer allocations create Create an allocation + indexer allocations collect Collect receipts for an allocation + indexer allocations close Close an allocation indexer allocations Manage indexer allocations indexer actions update Update one or more actions indexer actions queue Queue an action item diff --git a/packages/indexer-cli/src/__tests__/references/indexer.stdout b/packages/indexer-cli/src/__tests__/references/indexer.stdout index b37c44fcf..27cd37e03 100644 --- a/packages/indexer-cli/src/__tests__/references/indexer.stdout +++ b/packages/indexer-cli/src/__tests__/references/indexer.stdout @@ -22,13 +22,13 @@ Manage indexer configuration indexer cost get Get cost models and/or variables for one or all subgraphs indexer cost Manage costing for subgraphs indexer cost delete Remove one or many cost models - indexer connect Connect to indexer management API - indexer allocations resize Resize allocation stake without closing (Horizon) - indexer allocations reallocate Reallocate to subgraph deployment - indexer allocations present-poi Present POI and collect rewards without closing (Horizon) - indexer allocations get List one or more allocations - indexer allocations create Create an allocation - indexer allocations collect Collect receipts for an allocation - indexer allocations close Close an allocation + indexer connect Connect to indexer management API + indexer allocations resize Resize allocation stake without closing (Horizon) + indexer allocations reallocate Reallocate to subgraph deployment + indexer allocations present-poi Present POI and collect rewards without closing (Horizon) + indexer allocations get List one or more allocations + indexer allocations create Create an allocation + indexer allocations collect Collect receipts for an allocation + indexer allocations close Close an allocation indexer allocations Manage indexer allocations indexer Manage indexer configuration