Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { NetworkMonitor } from '../indexer-management/monitor'

const ALLOCATION_ID = '0x1234567890123456789012345678901234567890'
const AGREEMENT_ID = '0xabcdef000000000000000000000000ab'

const createLogger = () =>
({
warn: jest.fn(),
info: jest.fn(),
debug: jest.fn(),
error: jest.fn(),
trace: jest.fn(),
child: jest.fn().mockReturnThis(),
}) as any

const createMonitor = (opts: {
agreements?: { id: string; state: string }[] | null
getCollectionInfo?: jest.Mock
}) => {
// agreements === null models "no indexing-payments subgraph configured".
const indexingPaymentsSubgraph =
opts.agreements === null
? undefined
: ({
checkedQuery: jest
.fn()
.mockResolvedValue({ data: { indexingAgreements: opts.agreements } }),
} as any)
const contracts = {
RecurringCollector: {
getCollectionInfo:
opts.getCollectionInfo ?? jest.fn().mockResolvedValue([false, 0n, 0]),
},
} as any
return new NetworkMonitor(
'eip155:421614',
contracts,
{} as any, // indexerOptions
createLogger(),
{} as any, // graphNode
{} as any, // networkSubgraph
{} as any, // ethereum provider
{} as any, // epochSubgraph
indexingPaymentsSubgraph,
)
}

describe('NetworkMonitor.hasActiveDipsAgreement', () => {
it('returns false when no indexing-payments subgraph is configured', async () => {
const monitor = createMonitor({ agreements: null })
expect(await monitor.hasActiveDipsAgreement(ALLOCATION_ID)).toBe(false)
})

it('protects an Accepted agreement without consulting the collector', async () => {
const getCollectionInfo = jest.fn()
const monitor = createMonitor({
agreements: [{ id: AGREEMENT_ID, state: 'Accepted' }],
getCollectionInfo,
})
expect(await monitor.hasActiveDipsAgreement(ALLOCATION_ID)).toBe(true)
expect(getCollectionInfo).not.toHaveBeenCalled()
})

it('protects a payer-canceled agreement while the collector reports collectable fees', async () => {
const getCollectionInfo = jest.fn().mockResolvedValue([true, 120n, 0])
const monitor = createMonitor({
agreements: [{ id: AGREEMENT_ID, state: 'CanceledByPayer' }],
getCollectionInfo,
})
expect(await monitor.hasActiveDipsAgreement(ALLOCATION_ID)).toBe(true)
expect(getCollectionInfo).toHaveBeenCalledWith(AGREEMENT_ID)
})

it('releases a payer-canceled agreement once the collector reports it fully drained', async () => {
const getCollectionInfo = jest.fn().mockResolvedValue([false, 0n, 1])
const monitor = createMonitor({
agreements: [{ id: AGREEMENT_ID, state: 'CanceledByPayer' }],
getCollectionInfo,
})
expect(await monitor.hasActiveDipsAgreement(ALLOCATION_ID)).toBe(false)
})

it('keeps protecting when the collector call fails, to avoid stranding fees', async () => {
const getCollectionInfo = jest.fn().mockRejectedValue(new Error('rpc down'))
const monitor = createMonitor({
agreements: [{ id: AGREEMENT_ID, state: 'CanceledByPayer' }],
getCollectionInfo,
})
expect(await monitor.hasActiveDipsAgreement(ALLOCATION_ID)).toBe(true)
})

it('returns false when there are no protecting agreements for the allocation', async () => {
const monitor = createMonitor({ agreements: [] })
expect(await monitor.hasActiveDipsAgreement(ALLOCATION_ID)).toBe(false)
})
})
42 changes: 40 additions & 2 deletions packages/indexer-common/src/indexer-management/monitor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,20 +62,58 @@ export class NetworkMonitor {
if (!this.indexingPaymentsSubgraph) {
return false
}
// An allocation is protected from closing while it still owes a DIPS payment.
// Closing it makes SubgraphService.collect revert (collect requires the
// allocation to be open), so a canceled agreement must keep its allocation
// alive until its final fees have been collected. Two cases protect:
// - an Accepted agreement: still live, closing would cancel it on-chain;
// - a payer-canceled agreement whose fees aren't fully collected yet.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: this entire comment feels out of place, maybe we can move to where hasActiveDipsAgreement is called.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in follow up commits, also tightened this block

const result = await this.indexingPaymentsSubgraph.checkedQuery(
gql`
query indexingAgreements($allocationId: Bytes!) {
indexingAgreements(
where: { allocationId: $allocationId, state_in: [Accepted, CanceledByPayer] }
first: 1
) {
id
state
}
}
`,
{ allocationId: allocationId.toLowerCase() },
)
return (result.data?.indexingAgreements?.length ?? 0) > 0
const agreements: { id: string; state: string }[] =
result.data?.indexingAgreements ?? []

// Any still-active agreement protects the allocation outright.
if (agreements.some((agreement) => agreement.state === 'Accepted')) {
return true
}

// For payer-canceled agreements, defer to the on-chain collector: it reports
// fees as collectable until the collection window is fully drained. Protect
// while anything remains; release once drained so the allocation can close.
for (const agreement of agreements.filter(
(agreement) => agreement.state === 'CanceledByPayer',
)) {
try {
const [isCollectable] = await this.contracts.RecurringCollector.getCollectionInfo(
agreement.id,
)
if (isCollectable) {
return true
}
} catch (err) {
// Can't confirm the agreement is drained → keep protecting (fail safe);
// closing now would risk stranding uncollected fees.
this.logger.warn(
'Could not read DIPS collection info; keeping allocation protected',
{ allocationId, agreementId: agreement.id, err },
)
return true
}
}

return false
}

poiDisputeMonitoringEnabled(): boolean {
Expand Down
Loading