Skip to content

Commit 47a3fae

Browse files
committed
feat: implement on-chain collect for DIPs agreements
1 parent e559039 commit 47a3fae

12 files changed

Lines changed: 780 additions & 6 deletions

File tree

packages/indexer-agent/src/agent.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -727,6 +727,8 @@ export class Agent {
727727
await operator.dipsManager.matchAgreementAllocations(
728728
activeAllocations,
729729
)
730+
731+
await operator.dipsManager.collectAgreementPayments()
730732
}
731733
},
732734
)

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -395,6 +395,15 @@ export const start = {
395395
required: false,
396396
group: 'Indexing Fees ("DIPs")',
397397
})
398+
.option('dips-collection-target', {
399+
description:
400+
'Target collection point within the agreement window as a percentage (1-90). ' +
401+
'Lower values collect sooner (safer), higher values collect later (fewer txs).',
402+
type: 'number',
403+
default: 50,
404+
required: false,
405+
group: 'Indexing Fees ("DIPs")',
406+
})
398407
.check(argv => {
399408
if (
400409
!argv['network-subgraph-endpoint'] &&
@@ -471,6 +480,7 @@ export async function createNetworkSpecification(
471480
ravCollectionInterval: argv.ravCollectionInterval,
472481
ravCheckInterval: argv.ravCheckInterval,
473482
dipsEpochsMargin: argv.dipsEpochsMargin,
483+
dipsCollectionTarget: argv.dipsCollectionTarget,
474484
}
475485

476486
const transactionMonitoring = {

packages/indexer-common/src/indexer-management/allocations.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,7 @@ export class AllocationManager {
174174
this.logger,
175175
this.models,
176176
this.network,
177+
this.graphNode,
177178
this,
178179
this.pendingRcaModel,
179180
)

packages/indexer-common/src/indexing-fees/__tests__/accept-proposals.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,7 @@ function createDipsManager(
168168
models: IndexerManagementModels,
169169
consumer: PendingRcaConsumer,
170170
): DipsManager {
171-
const dm = new DipsManager(logger, models, network, null)
171+
const dm = new DipsManager(logger, models, network, {} as any, null)
172172
// eslint-disable-next-line @typescript-eslint/no-explicit-any
173173
;(dm as any).pendingRcaConsumer = consumer
174174
return dm
@@ -246,7 +246,7 @@ describe('DipsManager.acceptPendingProposals', () => {
246246
test('returns early when pendingRcaConsumer is null', async () => {
247247
const models = createMockModels()
248248
const network = createMockNetwork()
249-
const dm = new DipsManager(logger, models, network, null)
249+
const dm = new DipsManager(logger, models, network, {} as any, null)
250250

251251
// Should not throw
252252
await dm.acceptPendingProposals([])
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
/* eslint-disable @typescript-eslint/no-explicit-any,@typescript-eslint/no-unused-vars */
2+
import {
3+
fetchCollectableAgreements,
4+
SubgraphIndexingAgreement,
5+
} from '../agreement-monitor'
6+
7+
const mockQuery = jest.fn()
8+
const mockNetworkSubgraph = { query: mockQuery } as any
9+
10+
const INDEXER_ADDRESS = '0x1234567890abcdef1234567890abcdef12345678'
11+
12+
describe('fetchCollectableAgreements', () => {
13+
beforeEach(() => {
14+
jest.clearAllMocks()
15+
})
16+
17+
test('returns agreements in Accepted and CanceledByPayer states', async () => {
18+
mockQuery.mockResolvedValueOnce({
19+
data: {
20+
indexingAgreements: [
21+
{
22+
id: '0x00000000000000000000000000000001',
23+
allocationId: '0xaaaa',
24+
subgraphDeploymentId: '0xbbbb',
25+
state: 1,
26+
lastCollectionAt: '1000',
27+
endsAt: '9999999999',
28+
maxInitialTokens: '1000000',
29+
maxOngoingTokensPerSecond: '100',
30+
tokensPerSecond: '50',
31+
tokensPerEntityPerSecond: '10',
32+
minSecondsPerCollection: 3600,
33+
maxSecondsPerCollection: 86400,
34+
canceledAt: '0',
35+
},
36+
],
37+
},
38+
})
39+
40+
const result = await fetchCollectableAgreements(mockNetworkSubgraph, INDEXER_ADDRESS)
41+
42+
expect(result).toHaveLength(1)
43+
expect(result[0].id).toBe('0x00000000000000000000000000000001')
44+
expect(result[0].state).toBe(1)
45+
expect(mockQuery).toHaveBeenCalledTimes(1)
46+
})
47+
48+
test('returns empty array when no agreements exist', async () => {
49+
mockQuery.mockResolvedValueOnce({
50+
data: { indexingAgreements: [] },
51+
})
52+
53+
const result = await fetchCollectableAgreements(mockNetworkSubgraph, INDEXER_ADDRESS)
54+
55+
expect(result).toHaveLength(0)
56+
})
57+
58+
test('paginates through large result sets', async () => {
59+
// First page: 1000 results
60+
const page1 = Array.from({ length: 1000 }, (_, i) => ({
61+
id: `0x${i.toString(16).padStart(32, '0')}`,
62+
allocationId: '0xaaaa',
63+
subgraphDeploymentId: '0xbbbb',
64+
state: 1,
65+
lastCollectionAt: '1000',
66+
endsAt: '9999999999',
67+
maxInitialTokens: '1000000',
68+
maxOngoingTokensPerSecond: '100',
69+
tokensPerSecond: '50',
70+
tokensPerEntityPerSecond: '10',
71+
minSecondsPerCollection: 3600,
72+
maxSecondsPerCollection: 86400,
73+
canceledAt: '0',
74+
}))
75+
// Second page: 1 result
76+
const page2 = [
77+
{
78+
id: '0x' + 'f'.repeat(32),
79+
allocationId: '0xaaaa',
80+
subgraphDeploymentId: '0xbbbb',
81+
state: 1,
82+
lastCollectionAt: '1000',
83+
endsAt: '9999999999',
84+
maxInitialTokens: '1000000',
85+
maxOngoingTokensPerSecond: '100',
86+
tokensPerSecond: '50',
87+
tokensPerEntityPerSecond: '10',
88+
minSecondsPerCollection: 3600,
89+
maxSecondsPerCollection: 86400,
90+
canceledAt: '0',
91+
},
92+
]
93+
94+
mockQuery
95+
.mockResolvedValueOnce({ data: { indexingAgreements: page1 } })
96+
.mockResolvedValueOnce({ data: { indexingAgreements: page2 } })
97+
98+
const result = await fetchCollectableAgreements(mockNetworkSubgraph, INDEXER_ADDRESS)
99+
100+
expect(result).toHaveLength(1001)
101+
expect(mockQuery).toHaveBeenCalledTimes(2)
102+
})
103+
})
Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
/* eslint-disable @typescript-eslint/no-explicit-any */
2+
import { Logger } from '@graphprotocol/common-ts'
3+
import { DipsManager } from '../dips'
4+
5+
const logger = {
6+
child: jest.fn().mockReturnThis(),
7+
info: jest.fn(),
8+
debug: jest.fn(),
9+
warn: jest.fn(),
10+
error: jest.fn(),
11+
trace: jest.fn(),
12+
} as unknown as Logger
13+
14+
const mockQuery = jest.fn()
15+
const mockNetworkSubgraph = { query: mockQuery } as any
16+
17+
const mockGetCollectionInfo = jest.fn()
18+
const mockCollectEstimateGas = jest.fn()
19+
const mockCollect = jest.fn()
20+
const mockGetAgreement = jest.fn()
21+
22+
const mockContracts = {
23+
RecurringCollector: {
24+
getCollectionInfo: mockGetCollectionInfo,
25+
getAgreement: mockGetAgreement,
26+
},
27+
SubgraphService: {
28+
collect: Object.assign(mockCollect, {
29+
estimateGas: mockCollectEstimateGas,
30+
}),
31+
},
32+
} as any
33+
34+
const mockExecuteTransaction = jest.fn()
35+
const mockTransactionManager = {
36+
executeTransaction: mockExecuteTransaction,
37+
} as any
38+
39+
const mockGraphNode = {
40+
entityCount: jest.fn(),
41+
proofOfIndexing: jest.fn(),
42+
blockHashFromNumber: jest.fn(),
43+
} as any
44+
45+
const mockNetwork = {
46+
contracts: mockContracts,
47+
networkSubgraph: mockNetworkSubgraph,
48+
transactionManager: mockTransactionManager,
49+
specification: {
50+
indexerOptions: {
51+
address: '0x1234567890abcdef1234567890abcdef12345678',
52+
dipperEndpoint: undefined,
53+
dipsCollectionTarget: 50,
54+
},
55+
networkIdentifier: 'eip155:421614',
56+
},
57+
networkProvider: {
58+
getBlockNumber: jest.fn().mockResolvedValue(1000),
59+
getBlock: jest.fn().mockResolvedValue({ timestamp: Math.floor(Date.now() / 1000) }),
60+
},
61+
} as any
62+
63+
const mockModels = {} as any
64+
65+
function createDipsManager(): DipsManager {
66+
return new DipsManager(logger, mockModels, mockNetwork, mockGraphNode, null)
67+
}
68+
69+
// Helper: agreement that was last collected long ago (ready to collect)
70+
function makeReadyAgreement(id = '0x00000000000000000000000000000001') {
71+
return {
72+
id,
73+
allocationId: '0xaaaa',
74+
subgraphDeploymentId:
75+
'0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
76+
state: 1,
77+
lastCollectionAt: '0', // never collected → always ready
78+
endsAt: '9999999999',
79+
maxInitialTokens: '1000000',
80+
maxOngoingTokensPerSecond: '100',
81+
tokensPerSecond: '50',
82+
tokensPerEntityPerSecond: '10',
83+
minSecondsPerCollection: 3600,
84+
maxSecondsPerCollection: 86400,
85+
canceledAt: '0',
86+
}
87+
}
88+
89+
function makeAgreementData() {
90+
return {
91+
dataService: '0x0000',
92+
payer: '0x0000',
93+
serviceProvider: '0x0000',
94+
acceptedAt: 1000n,
95+
lastCollectionAt: 0n,
96+
endsAt: 9999999999n,
97+
maxInitialTokens: 1000000n,
98+
maxOngoingTokensPerSecond: 100n,
99+
minSecondsPerCollection: 3600,
100+
maxSecondsPerCollection: 86400,
101+
updateNonce: 0,
102+
canceledAt: 0n,
103+
state: 1,
104+
}
105+
}
106+
107+
describe('DipsManager.collectAgreementPayments', () => {
108+
beforeEach(() => {
109+
jest.clearAllMocks()
110+
})
111+
112+
test('skips when no collectable agreements found', async () => {
113+
mockQuery.mockResolvedValueOnce({
114+
data: { indexingAgreements: [] },
115+
})
116+
117+
const dm = createDipsManager()
118+
await dm.collectAgreementPayments()
119+
120+
expect(mockExecuteTransaction).not.toHaveBeenCalled()
121+
})
122+
123+
test('skips agreement when tracker says not ready yet', async () => {
124+
const recentlyCollected = makeReadyAgreement()
125+
// Collected very recently — min is 3600, target at 50% is ~45000s
126+
recentlyCollected.lastCollectionAt = String(Math.floor(Date.now() / 1000) - 100)
127+
128+
mockQuery.mockResolvedValueOnce({
129+
data: { indexingAgreements: [recentlyCollected] },
130+
})
131+
132+
const dm = createDipsManager()
133+
await dm.collectAgreementPayments()
134+
135+
// Should not even call getAgreement since tracker skips it
136+
expect(mockGetAgreement).not.toHaveBeenCalled()
137+
expect(mockExecuteTransaction).not.toHaveBeenCalled()
138+
})
139+
140+
test('skips agreement when getCollectionInfo says not collectable', async () => {
141+
mockQuery.mockResolvedValueOnce({
142+
data: { indexingAgreements: [makeReadyAgreement()] },
143+
})
144+
145+
mockGetAgreement.mockResolvedValueOnce(makeAgreementData())
146+
mockGetCollectionInfo.mockResolvedValueOnce([false, 0n, 1])
147+
148+
const dm = createDipsManager()
149+
await dm.collectAgreementPayments()
150+
151+
expect(mockExecuteTransaction).not.toHaveBeenCalled()
152+
})
153+
154+
test('collects payment when agreement is ready and collectable', async () => {
155+
mockQuery.mockResolvedValueOnce({
156+
data: { indexingAgreements: [makeReadyAgreement()] },
157+
})
158+
159+
mockGetAgreement.mockResolvedValueOnce(makeAgreementData())
160+
mockGetCollectionInfo.mockResolvedValueOnce([true, 7200n, 0])
161+
mockGraphNode.entityCount.mockResolvedValueOnce([500])
162+
mockGraphNode.blockHashFromNumber.mockResolvedValueOnce('0x' + 'ab'.repeat(32))
163+
mockGraphNode.proofOfIndexing.mockResolvedValueOnce('0x' + 'cd'.repeat(32))
164+
mockExecuteTransaction.mockResolvedValueOnce({ hash: '0xtxhash', status: 1 })
165+
166+
const dm = createDipsManager()
167+
await dm.collectAgreementPayments()
168+
169+
expect(mockExecuteTransaction).toHaveBeenCalledTimes(1)
170+
})
171+
172+
test('updates tracker after successful collection', async () => {
173+
mockQuery
174+
.mockResolvedValueOnce({ data: { indexingAgreements: [makeReadyAgreement()] } })
175+
.mockResolvedValueOnce({ data: { indexingAgreements: [makeReadyAgreement()] } })
176+
177+
mockGetAgreement.mockResolvedValue(makeAgreementData())
178+
mockGetCollectionInfo.mockResolvedValue([true, 7200n, 0])
179+
mockGraphNode.entityCount.mockResolvedValue([500])
180+
mockGraphNode.blockHashFromNumber.mockResolvedValue('0x' + 'ab'.repeat(32))
181+
mockGraphNode.proofOfIndexing.mockResolvedValue('0x' + 'cd'.repeat(32))
182+
mockExecuteTransaction.mockResolvedValue({ hash: '0xtxhash', status: 1 })
183+
184+
const dm = createDipsManager()
185+
186+
// First call: collects
187+
await dm.collectAgreementPayments()
188+
expect(mockExecuteTransaction).toHaveBeenCalledTimes(1)
189+
190+
// Second call: tracker should skip (just collected)
191+
await dm.collectAgreementPayments()
192+
// Still only 1 call — second was skipped by tracker
193+
expect(mockExecuteTransaction).toHaveBeenCalledTimes(1)
194+
})
195+
196+
test('still attempts collection when POI is unavailable (best effort)', async () => {
197+
mockQuery.mockResolvedValueOnce({
198+
data: { indexingAgreements: [makeReadyAgreement()] },
199+
})
200+
201+
mockGetAgreement.mockResolvedValueOnce(makeAgreementData())
202+
mockGetCollectionInfo.mockResolvedValueOnce([true, 7200n, 0])
203+
mockGraphNode.entityCount.mockResolvedValueOnce([500])
204+
mockGraphNode.blockHashFromNumber.mockResolvedValueOnce('0x' + 'ab'.repeat(32))
205+
mockGraphNode.proofOfIndexing.mockResolvedValueOnce(null) // POI unavailable
206+
mockExecuteTransaction.mockResolvedValueOnce({ hash: '0xtxhash', status: 1 })
207+
208+
const dm = createDipsManager()
209+
await dm.collectAgreementPayments()
210+
211+
// Should log warning but still attempt collection with zero POI
212+
expect(logger.warn).toHaveBeenCalled()
213+
expect(mockExecuteTransaction).toHaveBeenCalledTimes(1)
214+
})
215+
216+
test('handles deterministic error gracefully', async () => {
217+
mockQuery.mockResolvedValueOnce({
218+
data: { indexingAgreements: [makeReadyAgreement()] },
219+
})
220+
221+
mockGetAgreement.mockResolvedValueOnce(makeAgreementData())
222+
mockGetCollectionInfo.mockResolvedValueOnce([true, 7200n, 0])
223+
mockGraphNode.entityCount.mockResolvedValueOnce([500])
224+
mockGraphNode.blockHashFromNumber.mockResolvedValueOnce('0x' + 'ab'.repeat(32))
225+
mockGraphNode.proofOfIndexing.mockResolvedValueOnce('0x' + 'cd'.repeat(32))
226+
mockExecuteTransaction.mockRejectedValueOnce(
227+
Object.assign(new Error('revert'), { code: 'CALL_EXCEPTION' }),
228+
)
229+
230+
const dm = createDipsManager()
231+
await dm.collectAgreementPayments()
232+
233+
expect(logger.warn).toHaveBeenCalled()
234+
})
235+
})

0 commit comments

Comments
 (0)