Skip to content

Commit 2126bac

Browse files
committed
test: dips manager basic tests
1 parent a2eacd5 commit 2126bac

File tree

3 files changed

+277
-11
lines changed

3 files changed

+277
-11
lines changed

packages/indexer-common/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,4 @@ export * from './utils'
1717
export * from './parsers'
1818
export * as specification from './network-specification'
1919
export * from './sequential-timer'
20+
export * from './indexing-fees'
Lines changed: 270 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,270 @@
1+
import { connectDatabase } from '@graphprotocol/common-ts/dist/database'
2+
import { createLogger } from '@graphprotocol/common-ts/dist/logging'
3+
import { createMetrics } from '@graphprotocol/common-ts/dist/metrics'
4+
import { GraphNode } from 'indexer-common/src/graph-node'
5+
import { testNetworkSpecification } from 'indexer-common/src/indexer-management/__tests__/util'
6+
import { defineIndexerManagementModels } from 'indexer-common/src/indexer-management/models'
7+
import { Network } from 'indexer-common/src/network'
8+
import { defineQueryFeeModels } from 'indexer-common/src/query-fees/models'
9+
import { Sequelize } from 'sequelize'
10+
import { Logger, Metrics, parseGRT } from '@graphprotocol/common-ts'
11+
import {
12+
IndexerManagementModels,
13+
QueryFeeModels,
14+
DipsManager,
15+
SubgraphIdentifierType,
16+
IndexingDecisionBasis,
17+
} from '@graphprotocol/indexer-common'
18+
import { CollectPaymentStatus } from '@graphprotocol/dips-proto/generated/gateway'
19+
20+
// Make global Jest variables available
21+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
22+
declare const __DATABASE__: any
23+
declare const __LOG_LEVEL__: never
24+
25+
// Add these type declarations after the existing imports
26+
let sequelize: Sequelize
27+
let logger: Logger
28+
let metrics: Metrics
29+
let graphNode: GraphNode
30+
let managementModels: IndexerManagementModels
31+
let queryFeeModels: QueryFeeModels
32+
let network: Network
33+
34+
// Add mock implementation
35+
jest.mock('../gateway-dips-service-client', () => ({
36+
...jest.requireActual('../gateway-dips-service-client'),
37+
createGatewayDipsServiceClient: jest.fn(() => ({
38+
CancelAgreement: jest.fn().mockResolvedValue({}),
39+
CollectPayment: jest.fn().mockResolvedValue({
40+
status: CollectPaymentStatus.ACCEPT,
41+
tapReceipt: new Uint8Array(), // Mock tap receipt
42+
}),
43+
})),
44+
}))
45+
46+
const setup = async () => {
47+
logger = createLogger({
48+
name: 'Indexer API Client',
49+
async: false,
50+
level: __LOG_LEVEL__ ?? 'error',
51+
})
52+
metrics = createMetrics()
53+
// Clearing the registry prevents duplicate metric registration in the default registry.
54+
metrics.registry.clear()
55+
56+
graphNode = new GraphNode(
57+
logger,
58+
'https://test-admin-endpoint.xyz',
59+
'https://test-query-endpoint.xyz',
60+
'https://test-status-endpoint.xyz',
61+
)
62+
63+
sequelize = await connectDatabase(__DATABASE__)
64+
managementModels = defineIndexerManagementModels(sequelize)
65+
queryFeeModels = defineQueryFeeModels(sequelize)
66+
sequelize = await sequelize.sync({ force: true })
67+
68+
// Enable DIPs with all related configuration
69+
const networkSpecWithDips = {
70+
...testNetworkSpecification,
71+
indexerOptions: {
72+
...testNetworkSpecification.indexerOptions,
73+
enableDips: true,
74+
dipperEndpoint: 'https://test-dipper-endpoint.xyz',
75+
dipsAllocationAmount: parseGRT('1.0'), // Amount of GRT to allocate for DIPs
76+
dipsEpochsMargin: 1, // Optional: Number of epochs margin for DIPs
77+
},
78+
}
79+
80+
network = await Network.create(
81+
logger,
82+
networkSpecWithDips,
83+
managementModels,
84+
queryFeeModels,
85+
graphNode,
86+
metrics,
87+
)
88+
}
89+
90+
const setupEach = async () => {
91+
sequelize = await sequelize.sync({ force: true })
92+
}
93+
94+
const teardownEach = async () => {
95+
// Clear out query fee model tables
96+
await queryFeeModels.allocationReceipts.truncate({ cascade: true })
97+
await queryFeeModels.vouchers.truncate({ cascade: true })
98+
await queryFeeModels.transferReceipts.truncate({ cascade: true })
99+
await queryFeeModels.transfers.truncate({ cascade: true })
100+
await queryFeeModels.allocationSummaries.truncate({ cascade: true })
101+
102+
// Clear out indexer management models
103+
await managementModels.Action.truncate({ cascade: true })
104+
await managementModels.CostModel.truncate({ cascade: true })
105+
await managementModels.IndexingRule.truncate({ cascade: true })
106+
await managementModels.POIDispute.truncate({ cascade: true })
107+
}
108+
109+
const teardownAll = async () => {
110+
await sequelize.drop({})
111+
}
112+
113+
describe('DipsManager', () => {
114+
beforeAll(setup)
115+
beforeEach(setupEach)
116+
afterEach(teardownEach)
117+
afterAll(teardownAll)
118+
119+
// We have been rate-limited on CI as this test uses RPC providers,
120+
// so we set its timeout to a higher value than usual.
121+
jest.setTimeout(30_000)
122+
123+
describe('initialization', () => {
124+
test('creates DipsManager when dipperEndpoint is configured', () => {
125+
const dipsManager = new DipsManager(logger, managementModels, network, null)
126+
expect(dipsManager).toBeDefined()
127+
})
128+
129+
test('throws error when dipperEndpoint is not configured', async () => {
130+
const specWithoutDipper = {
131+
...testNetworkSpecification,
132+
indexerOptions: {
133+
...testNetworkSpecification.indexerOptions,
134+
dipperEndpoint: undefined,
135+
},
136+
}
137+
const networkWithoutDipper = await Network.create(
138+
logger,
139+
specWithoutDipper,
140+
managementModels,
141+
queryFeeModels,
142+
graphNode,
143+
metrics,
144+
)
145+
expect(
146+
() => new DipsManager(logger, managementModels, networkWithoutDipper, null),
147+
).toThrow('dipperEndpoint is not set')
148+
})
149+
})
150+
151+
describe('agreement management', () => {
152+
let dipsManager: DipsManager
153+
const testDeploymentId = 'QmTest'
154+
const testAllocationId = '0x1234'
155+
const testAgreementId = 'agreement-1'
156+
157+
beforeEach(async () => {
158+
// Clear mock calls between tests
159+
jest.clearAllMocks()
160+
161+
dipsManager = new DipsManager(logger, managementModels, network, null)
162+
163+
// Create a test agreement
164+
await managementModels.IndexingAgreement.create({
165+
id: testAgreementId,
166+
subgraph_deployment_id: testDeploymentId,
167+
current_allocation_id: testAllocationId,
168+
last_allocation_id: null,
169+
last_payment_collected_at: null,
170+
cancelled_at: null,
171+
min_epochs_per_collection: BigInt(1),
172+
max_epochs_per_collection: BigInt(5),
173+
payer: '0xabcd',
174+
signature: Buffer.from('1234', 'hex'),
175+
signed_payload: Buffer.from('5678', 'hex'),
176+
protocol_network: 'test',
177+
chain_id: '1',
178+
base_price_per_epoch: '100',
179+
price_per_entity: '1',
180+
service: '0xdeadbeef',
181+
payee: '0xdef0',
182+
deadline: new Date(Date.now() + 86400000), // 1 day from now
183+
duration_epochs: BigInt(10),
184+
max_initial_amount: '1000',
185+
max_ongoing_amount_per_epoch: '100',
186+
created_at: new Date(),
187+
updated_at: new Date(),
188+
signed_cancellation_payload: null,
189+
})
190+
})
191+
192+
test('cancels agreement when allocation is closed', async () => {
193+
const mockClient = dipsManager.gatewayDipsServiceClient
194+
195+
await dipsManager.tryCancelAgreement(testAllocationId)
196+
197+
// Verify the client was called with correct parameters
198+
expect(mockClient.CancelAgreement).toHaveBeenCalledTimes(1)
199+
// TODO: Check the signed cancellation payload
200+
expect(mockClient.CancelAgreement).toHaveBeenCalledWith({
201+
version: 1,
202+
signedCancellation: expect.any(Uint8Array),
203+
})
204+
205+
const agreement = await managementModels.IndexingAgreement.findOne({
206+
where: { id: testAgreementId },
207+
})
208+
expect(agreement?.cancelled_at).toBeDefined()
209+
})
210+
211+
test('handles errors when cancelling agreement', async () => {
212+
const mockClient = dipsManager.gatewayDipsServiceClient
213+
;(mockClient.CancelAgreement as jest.Mock).mockRejectedValueOnce(
214+
new Error('Failed to cancel'),
215+
)
216+
217+
await dipsManager.tryCancelAgreement(testAllocationId)
218+
219+
const agreement = await managementModels.IndexingAgreement.findOne({
220+
where: { id: testAgreementId },
221+
})
222+
expect(agreement?.cancelled_at).toBeNull()
223+
})
224+
225+
test('updates agreement allocation IDs during reallocation', async () => {
226+
const newAllocationId = '0x5678'
227+
228+
await dipsManager.tryUpdateAgreementAllocation(
229+
testDeploymentId,
230+
testAllocationId,
231+
newAllocationId,
232+
)
233+
234+
const agreement = await managementModels.IndexingAgreement.findOne({
235+
where: { id: testAgreementId },
236+
})
237+
expect(agreement?.current_allocation_id).toBe(newAllocationId)
238+
expect(agreement?.last_allocation_id).toBe(testAllocationId)
239+
expect(agreement?.last_payment_collected_at).toBeNull()
240+
})
241+
242+
test('creates indexing rules for active agreements', async () => {
243+
await dipsManager.ensureAgreementRules()
244+
245+
const rules = await managementModels.IndexingRule.findAll({
246+
where: {
247+
identifier: testDeploymentId,
248+
},
249+
})
250+
251+
expect(rules).toHaveLength(1)
252+
expect(rules[0]).toMatchObject({
253+
identifier: testDeploymentId,
254+
identifierType: SubgraphIdentifierType.DEPLOYMENT,
255+
decisionBasis: IndexingDecisionBasis.ALWAYS,
256+
allocationAmount:
257+
network.specification.indexerOptions.dipsAllocationAmount.toString(),
258+
autoRenewal: true,
259+
allocationLifetime: 4, // max_epochs_per_collection - dipsEpochsMargin
260+
})
261+
})
262+
263+
test('returns active DIPs deployments', async () => {
264+
const deployments = await dipsManager.getActiveDipsDeployments()
265+
266+
expect(deployments).toHaveLength(1)
267+
expect(deployments[0].ipfsHash).toBe(testDeploymentId)
268+
})
269+
})
270+
})

packages/indexer-common/src/indexing-fees/dips.ts

Lines changed: 6 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ import { Wallet } from 'ethers'
3232
const DIPS_COLLECTION_INTERVAL = 60_000
3333

3434
export class DipsManager {
35-
private gatewayDipsServiceClient: GatewayDipsServiceClientImpl
35+
declare gatewayDipsServiceClient: GatewayDipsServiceClientImpl
3636

3737
constructor(
3838
private logger: Logger,
@@ -68,6 +68,7 @@ export class DipsManager {
6868

6969
// Mark the agreement as cancelled
7070
agreement.cancelled_at = new Date()
71+
agreement.updated_at = new Date()
7172
await agreement.save()
7273
} catch (error) {
7374
this.logger.error(`Error cancelling agreement ${agreement.id}`, { error })
@@ -89,6 +90,7 @@ export class DipsManager {
8990
agreement.current_allocation_id = newAllocationId
9091
agreement.last_allocation_id = oldAllocationId
9192
agreement.last_payment_collected_at = null
93+
agreement.updated_at = new Date()
9294
await agreement.save()
9395
}
9496
}
@@ -268,16 +270,9 @@ export class DipsCollector {
268270
}
269271
await this.queryFeeModels.scalarTapReceipts.create(tapReceipt)
270272
// Mark the agreement as having had a payment collected
271-
await this.managementModels.IndexingAgreement.update(
272-
{
273-
last_payment_collected_at: new Date(),
274-
},
275-
{
276-
where: {
277-
id: agreement.id,
278-
},
279-
},
280-
)
273+
agreement.last_payment_collected_at = new Date()
274+
agreement.updated_at = new Date()
275+
await agreement.save()
281276
} else {
282277
throw new Error(`Payment request not accepted: ${response.status}`)
283278
}

0 commit comments

Comments
 (0)