From e740bd9cbee5082f11d360223a87487bbd42ef64 Mon Sep 17 00:00:00 2001 From: MoonBoi9001 Date: Wed, 3 Jun 2026 21:20:34 +1200 Subject: [PATCH] fix(agent): collect indexing rewards at most once per epoch An open allocation's indexing rewards were collected about every fifteen minutes, held back only by a wall-clock window. Rewards accrue continuously and the next collection sweeps up everything earned since the last, so a single collection per epoch keeps the same earnings at far less gas. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/indexer-agent/src/__tests__/agent.ts | 1 + packages/indexer-agent/src/agent.ts | 59 +++++++++++++++++++ .../src/__tests__/actions.test.ts | 22 ++++++- .../__tests__/present-poi-scheduling.test.ts | 6 +- packages/indexer-common/src/actions.ts | 16 +++++ packages/indexer-common/src/operator.ts | 5 +- 6 files changed, 104 insertions(+), 5 deletions(-) diff --git a/packages/indexer-agent/src/__tests__/agent.ts b/packages/indexer-agent/src/__tests__/agent.ts index 7c2ac17f5..4ec693103 100644 --- a/packages/indexer-agent/src/__tests__/agent.ts +++ b/packages/indexer-agent/src/__tests__/agent.ts @@ -442,6 +442,7 @@ describe('reconcileDeploymentAllocationAction', () => { expect.anything(), [activeAllocations[0]], network, + 10, ) }) diff --git a/packages/indexer-agent/src/agent.ts b/packages/indexer-agent/src/agent.ts index c5589796c..4c95670a1 100644 --- a/packages/indexer-agent/src/agent.ts +++ b/packages/indexer-agent/src/agent.ts @@ -8,7 +8,9 @@ import { timer, } from '@graphprotocol/common-ts' import { + ActionManager, ActionStatus, + ActionType, Allocation, AllocationManagementMode, allocationRewardsPool, @@ -20,6 +22,7 @@ import { IndexingRuleAttributes, Network, POIDisputeAttributes, + presentPOIReasonEpoch, RewardsPool, Subgraph, SubgraphDeployment, @@ -1023,9 +1026,64 @@ export class Agent { } }, ) + + // Rewards accrue per block, so a second present-POI the same epoch collects only + // the little earned since the last and mostly wastes gas. Skip allocations already + // harvested this epoch. + const harvestedThisEpoch = await this.allocationsHarvestedInEpoch( + deploymentAllocationDecision.deployment.ipfsHash, + network.specification.networkIdentifier, + epoch, + ) + expiredAllocations = expiredAllocations.filter(allocation => { + if (harvestedThisEpoch.has(allocation.id)) { + logger.debug( + 'Allocation already harvested this epoch, skipping presentPOI', + { + allocation: allocation.id, + epoch, + }, + ) + return false + } + return true + }) + return expiredAllocations } + // Returns the set of allocation ids that already had a successful present-POI + // scheduled for the current epoch, read from the actions table. + async allocationsHarvestedInEpoch( + deploymentID: string, + protocolNetwork: string, + epoch: number, + ): Promise> { + const actionManager = this.indexerManagement.actionManager + if (!actionManager) { + return new Set() + } + const recentPresentPOIActions = await ActionManager.fetchActions( + actionManager.models, + null, + { + type: ActionType.PRESENT_POI, + status: ActionStatus.SUCCESS, + protocolNetwork, + }, + ) + return new Set( + recentPresentPOIActions + .filter( + action => + action.deploymentID === deploymentID && + action.allocationID !== null && + presentPOIReasonEpoch(action.reason) === epoch, + ) + .map(action => action.allocationID as string), + ) + } + async reconcileDeploymentAllocationAction( deploymentAllocationDecision: AllocationDecision, activeAllocations: Allocation[], @@ -1125,6 +1183,7 @@ export class Agent { logger, expiringAllocations, network, + epoch, ) } } diff --git a/packages/indexer-common/src/__tests__/actions.test.ts b/packages/indexer-common/src/__tests__/actions.test.ts index 9269d856b..ecea88ac1 100644 --- a/packages/indexer-common/src/__tests__/actions.test.ts +++ b/packages/indexer-common/src/__tests__/actions.test.ts @@ -1,4 +1,11 @@ -import { ActionInput, ActionStatus, ActionType, isValidActionInput } from '../actions' +import { + ActionInput, + ActionStatus, + ActionType, + isValidActionInput, + presentPOIReason, + presentPOIReasonEpoch, +} from '../actions' describe('Action Validation', () => { describe('isValidActionInput', () => { @@ -246,3 +253,16 @@ describe('Action Validation', () => { }) }) }) + +describe('present-POI reason encoding', () => { + test('round-trips the epoch through the reason string', () => { + expect(presentPOIReasonEpoch(presentPOIReason(853))).toBe(853) + expect(presentPOIReasonEpoch(presentPOIReason(0))).toBe(0) + }) + + test('returns undefined for a reason without an epoch', () => { + expect(presentPOIReasonEpoch('presentPOI:staleness-prevention')).toBeUndefined() + expect(presentPOIReasonEpoch('some-other-reason')).toBeUndefined() + expect(presentPOIReasonEpoch(null)).toBeUndefined() + }) +}) diff --git a/packages/indexer-common/src/__tests__/present-poi-scheduling.test.ts b/packages/indexer-common/src/__tests__/present-poi-scheduling.test.ts index a1000e699..e9744e414 100644 --- a/packages/indexer-common/src/__tests__/present-poi-scheduling.test.ts +++ b/packages/indexer-common/src/__tests__/present-poi-scheduling.test.ts @@ -61,7 +61,7 @@ describe('presentPOIForAllocations', () => { id: '0x0000000000000000000000000000000000000002', }) - await operator.presentPOIForAllocations(mockLogger, [alloc1, alloc2], network) + await operator.presentPOIForAllocations(mockLogger, [alloc1, alloc2], network, 853) expect(queueActionSpy).toHaveBeenCalledTimes(2) expect(queueActionSpy).toHaveBeenCalledWith( @@ -71,7 +71,7 @@ describe('presentPOIForAllocations', () => { allocationID: alloc1.id, deploymentID: deployment.ipfsHash, }), - reason: 'presentPOI:staleness-prevention', + reason: 'presentPOI:staleness-prevention:epoch=853', protocolNetwork: 'eip155:421614', }), false, @@ -87,7 +87,7 @@ describe('presentPOIForAllocations', () => { }) it('should not queue anything when no allocations are passed', async () => { - await operator.presentPOIForAllocations(mockLogger, [], network) + await operator.presentPOIForAllocations(mockLogger, [], network, 853) expect(queueActionSpy).not.toHaveBeenCalled() }) }) diff --git a/packages/indexer-common/src/actions.ts b/packages/indexer-common/src/actions.ts index 98d1e9e06..b49ec4775 100644 --- a/packages/indexer-common/src/actions.ts +++ b/packages/indexer-common/src/actions.ts @@ -256,6 +256,22 @@ export enum ActionType { RESIZE = 'resize', } +// Rewards accrue per block, so a present-POI mid-epoch collects only what accrued +// since the last and mostly wastes gas; one per epoch suffices. We record the epoch +// in the action `reason` so the agent can skip allocations already harvested this epoch. +const PRESENT_POI_REASON_PREFIX = 'presentPOI:staleness-prevention' + +export function presentPOIReason(epoch: number): string { + return `${PRESENT_POI_REASON_PREFIX}:epoch=${epoch}` +} + +// Returns the epoch encoded in a present-POI action `reason`, or undefined if +// the reason carries no epoch (e.g. an action queued before this field existed). +export function presentPOIReasonEpoch(reason: string | null): number | undefined { + const match = reason?.match(/:epoch=(\d+)$/) + return match ? Number(match[1]) : undefined +} + export enum ActionStatus { QUEUED = 'queued', APPROVED = 'approved', diff --git a/packages/indexer-common/src/operator.ts b/packages/indexer-common/src/operator.ts index 0e90614e1..ebf81446d 100644 --- a/packages/indexer-common/src/operator.ts +++ b/packages/indexer-common/src/operator.ts @@ -17,6 +17,7 @@ import { Action, POIDisputeAttributes, DipsManager, + presentPOIReason, } from '@graphprotocol/indexer-common' import { Logger, formatGRT } from '@graphprotocol/common-ts' import { hexlify } from 'ethers' @@ -569,11 +570,13 @@ export class Operator { network: { specification: { networkIdentifier: string } }, + epoch: number, ): Promise { for (const allocation of expiringAllocations) { logger.info('Scheduling presentPOI for Horizon allocation', { allocationId: allocation.id, deployment: allocation.subgraphDeployment.id.ipfsHash, + epoch, }) await this.queueAction( @@ -584,7 +587,7 @@ export class Operator { poi: undefined, }, type: ActionType.PRESENT_POI, - reason: 'presentPOI:staleness-prevention', + reason: presentPOIReason(epoch), protocolNetwork: network.specification.networkIdentifier, }, false,