Skip to content

Commit 2b6eff5

Browse files
authored
chore: remove reallocate step and add continuous RAV collection for long lived allocation (#1181)
1 parent 02d4dcd commit 2b6eff5

11 files changed

Lines changed: 678 additions & 36 deletions

File tree

packages/indexer-agent/src/__tests__/agent.ts

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
11
import {
2+
Agent,
23
convertSubgraphBasedRulesToDeploymentBased,
34
consolidateAllocationDecisions,
45
resolveTargetDeployments,
56
} from '../agent'
67
import {
8+
ActivationCriteria,
9+
Allocation,
10+
AllocationDecision,
11+
AllocationStatus,
712
INDEXING_RULE_GLOBAL,
813
IndexingDecisionBasis,
914
IndexingRuleAttributes,
@@ -328,3 +333,142 @@ describe('resolveTargetDeployments function', () => {
328333
)
329334
})
330335
})
336+
337+
describe('reconcileDeploymentAllocationAction', () => {
338+
const deployment = new SubgraphDeploymentID(
339+
'QmXZiV6S13ha6QXq4dmaM3TB4CHcDxBMvGexSNu9Kc28EH',
340+
)
341+
342+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
343+
const mockLogger: any = {
344+
child: jest.fn().mockReturnThis(),
345+
info: jest.fn(),
346+
warn: jest.fn(),
347+
error: jest.fn(),
348+
debug: jest.fn(),
349+
trace: jest.fn(),
350+
}
351+
352+
const activeAllocations: Allocation[] = [
353+
{
354+
id: '0x0000000000000000000000000000000000000001',
355+
status: AllocationStatus.ACTIVE,
356+
isLegacy: false,
357+
subgraphDeployment: {
358+
id: deployment,
359+
ipfsHash: deployment.ipfsHash,
360+
},
361+
indexer: '0x0000000000000000000000000000000000000000',
362+
allocatedTokens: BigInt(1000),
363+
createdAt: 0,
364+
createdAtEpoch: 1,
365+
createdAtBlockHash: '0x0',
366+
closedAt: 0,
367+
closedAtEpoch: 0,
368+
closedAtEpochStartBlockHash: undefined,
369+
previousEpochStartBlockHash: undefined,
370+
closedAtBlockHash: '0x0',
371+
poi: undefined,
372+
queryFeeRebates: undefined,
373+
queryFeesCollected: undefined,
374+
} as unknown as Allocation,
375+
]
376+
377+
const decision = new AllocationDecision(
378+
deployment,
379+
{
380+
identifier: deployment.ipfsHash,
381+
identifierType: SubgraphIdentifierType.DEPLOYMENT,
382+
allocationAmount: '1000',
383+
decisionBasis: IndexingDecisionBasis.RULES,
384+
} as IndexingRuleAttributes,
385+
true,
386+
ActivationCriteria.SIGNAL_THRESHOLD,
387+
'eip155:42161',
388+
)
389+
390+
function createAgent() {
391+
const agent = Object.create(Agent.prototype)
392+
agent.logger = mockLogger
393+
agent.graphNode = {
394+
indexingStatus: jest.fn().mockResolvedValue([
395+
{
396+
subgraphDeployment: { ipfsHash: deployment.ipfsHash },
397+
health: 'healthy',
398+
},
399+
]),
400+
}
401+
agent.identifyExpiringAllocations = jest
402+
.fn()
403+
.mockResolvedValue([activeAllocations[0]])
404+
return agent
405+
}
406+
407+
function createOperator() {
408+
return {
409+
closeEligibleAllocations: jest.fn(),
410+
createAllocation: jest.fn(),
411+
refreshExpiredAllocations: jest.fn(),
412+
presentPOIForAllocations: jest.fn(),
413+
}
414+
}
415+
416+
function createNetwork(isHorizon: boolean) {
417+
return {
418+
isHorizon: { value: jest.fn().mockResolvedValue(isHorizon) },
419+
specification: { networkIdentifier: 'eip155:42161' },
420+
networkMonitor: {
421+
closedAllocations: jest.fn().mockResolvedValue([]),
422+
},
423+
}
424+
}
425+
426+
it('should call presentPOIForAllocations instead of refreshExpiredAllocations for Horizon allocations', async () => {
427+
const agent = createAgent()
428+
const operator = createOperator()
429+
const network = createNetwork(true)
430+
431+
await agent.reconcileDeploymentAllocationAction(
432+
decision,
433+
activeAllocations,
434+
10,
435+
{ value: jest.fn().mockResolvedValue(28) },
436+
network,
437+
operator,
438+
false,
439+
)
440+
441+
expect(agent.identifyExpiringAllocations).toHaveBeenCalled()
442+
expect(operator.refreshExpiredAllocations).not.toHaveBeenCalled()
443+
expect(operator.presentPOIForAllocations).toHaveBeenCalledWith(
444+
expect.anything(),
445+
[activeAllocations[0]],
446+
network,
447+
)
448+
})
449+
450+
it('should call refreshExpiredAllocations for legacy allocations', async () => {
451+
const agent = createAgent()
452+
const operator = createOperator()
453+
const network = createNetwork(false)
454+
455+
await agent.reconcileDeploymentAllocationAction(
456+
decision,
457+
activeAllocations,
458+
10,
459+
{ value: jest.fn().mockResolvedValue(28) },
460+
network,
461+
operator,
462+
false,
463+
)
464+
465+
expect(agent.identifyExpiringAllocations).toHaveBeenCalled()
466+
expect(operator.refreshExpiredAllocations).toHaveBeenCalledWith(
467+
expect.anything(),
468+
decision,
469+
[activeAllocations[0]],
470+
false,
471+
)
472+
expect(operator.presentPOIForAllocations).not.toHaveBeenCalled()
473+
})
474+
})

packages/indexer-agent/src/agent.ts

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1130,7 +1130,6 @@ export class Agent {
11301130
forceAction,
11311131
)
11321132
} else {
1133-
// Refresh any expiring allocations
11341133
const expiringAllocations = await this.identifyExpiringAllocations(
11351134
logger,
11361135
activeDeploymentAllocations,
@@ -1140,12 +1139,22 @@ export class Agent {
11401139
network,
11411140
)
11421141
if (expiringAllocations.length > 0) {
1143-
await operator.refreshExpiredAllocations(
1144-
logger,
1145-
deploymentAllocationDecision,
1146-
expiringAllocations,
1147-
forceAction,
1148-
)
1142+
if (isHorizon) {
1143+
// Horizon allocations don't need the close/reopen cycle.
1144+
// Indexing rewards are collected via presentPOI instead.
1145+
await operator.presentPOIForAllocations(
1146+
logger,
1147+
expiringAllocations,
1148+
network,
1149+
)
1150+
} else {
1151+
await operator.refreshExpiredAllocations(
1152+
logger,
1153+
deploymentAllocationDecision,
1154+
expiringAllocations,
1155+
forceAction,
1156+
)
1157+
}
11491158
}
11501159
}
11511160
}

packages/indexer-agent/src/commands/start.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -288,6 +288,19 @@ export const start = {
288288
default: 50,
289289
group: 'Query Fees',
290290
})
291+
.option('rav-collection-interval', {
292+
description:
293+
'Minimum time in seconds between periodic RAV collections per active allocation',
294+
type: 'number',
295+
default: 14400,
296+
group: 'Query Fees',
297+
})
298+
.option('rav-check-interval', {
299+
description: 'How often the RAV processing loop runs, in seconds',
300+
type: 'number',
301+
default: 900,
302+
group: 'Query Fees',
303+
})
291304
.option('horizon-address-book', {
292305
description: 'Graph Horizon contracts address book file path',
293306
type: 'string',
@@ -455,6 +468,8 @@ export async function createNetworkSpecification(
455468
enableDips: argv.enableDips,
456469
dipperEndpoint: argv.dipperEndpoint,
457470
dipsAllocationAmount: argv.dipsAllocationAmount,
471+
ravCollectionInterval: argv.ravCollectionInterval,
472+
ravCheckInterval: argv.ravCheckInterval,
458473
dipsEpochsMargin: argv.dipsEpochsMargin,
459474
}
460475

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
/* eslint-disable @typescript-eslint/no-explicit-any */
2+
import { ActionInput, ActionStatus, ActionType, validateActionInputs } from '../actions'
3+
import { AllocationStatus } from '../allocations'
4+
5+
const mockAllocation = {
6+
status: AllocationStatus.ACTIVE,
7+
subgraphDeployment: { id: { ipfsHash: 'QmTest' } },
8+
}
9+
10+
const createMockNetworkMonitor = (hasAgreement: boolean) => ({
11+
hasActiveDipsAgreement: jest.fn().mockResolvedValue(hasAgreement),
12+
allocation: jest.fn().mockResolvedValue(mockAllocation),
13+
subgraphDeployment: jest.fn().mockResolvedValue({}),
14+
})
15+
16+
const createMockLogger = () => ({
17+
warn: jest.fn(),
18+
info: jest.fn(),
19+
debug: jest.fn(),
20+
error: jest.fn(),
21+
child: jest.fn().mockReturnThis(),
22+
trace: jest.fn(),
23+
})
24+
25+
const baseAction: ActionInput = {
26+
type: ActionType.UNALLOCATE,
27+
deploymentID: 'QmTest',
28+
allocationID: '0x1234567890123456789012345678901234567890',
29+
source: 'test',
30+
reason: 'test',
31+
status: ActionStatus.QUEUED,
32+
priority: 0,
33+
protocolNetwork: 'eip155:421614',
34+
force: false,
35+
isLegacy: false,
36+
}
37+
38+
describe('validateActionInputs DIPS agreement protection', () => {
39+
it('should reject UNALLOCATE with active DIPS agreement when force is not set', async () => {
40+
const monitor = createMockNetworkMonitor(true)
41+
const logger = createMockLogger()
42+
43+
await expect(
44+
validateActionInputs([baseAction], monitor as any, logger as any),
45+
).rejects.toThrow(/active DIPS agreement/)
46+
})
47+
48+
it('should allow UNALLOCATE with active DIPS agreement when force is true', async () => {
49+
const monitor = createMockNetworkMonitor(true)
50+
const logger = createMockLogger()
51+
52+
const action = { ...baseAction, force: true }
53+
54+
await expect(
55+
validateActionInputs([action], monitor as any, logger as any),
56+
).resolves.toBeUndefined()
57+
58+
expect(logger.warn).toHaveBeenCalledWith(
59+
'Force-closing allocation with active DIPS agreement',
60+
expect.objectContaining({ allocationId: action.allocationID }),
61+
)
62+
})
63+
64+
it('should allow UNALLOCATE with no active DIPS agreement', async () => {
65+
const monitor = createMockNetworkMonitor(false)
66+
const logger = createMockLogger()
67+
68+
await expect(
69+
validateActionInputs([baseAction], monitor as any, logger as any),
70+
).resolves.toBeUndefined()
71+
})
72+
73+
it('should not check agreement for ALLOCATE actions', async () => {
74+
const monitor = createMockNetworkMonitor(true)
75+
const logger = createMockLogger()
76+
77+
const action: ActionInput = {
78+
...baseAction,
79+
type: ActionType.ALLOCATE,
80+
amount: '10000',
81+
allocationID: undefined,
82+
}
83+
84+
await expect(
85+
validateActionInputs([action], monitor as any, logger as any),
86+
).resolves.toBeUndefined()
87+
88+
expect(monitor.hasActiveDipsAgreement).not.toHaveBeenCalled()
89+
})
90+
91+
it('should not check DIPS agreement for REALLOCATE actions', async () => {
92+
const monitor = createMockNetworkMonitor(true)
93+
const logger = createMockLogger()
94+
95+
const action: ActionInput = {
96+
...baseAction,
97+
type: ActionType.REALLOCATE,
98+
amount: '10000',
99+
}
100+
101+
// REALLOCATE still validates but doesn't check DIPS agreements
102+
// (REALLOCATE itself is deprecated and will be removed)
103+
await expect(
104+
validateActionInputs([action], monitor as any, logger as any),
105+
).resolves.not.toThrow()
106+
})
107+
})

0 commit comments

Comments
 (0)