Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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.hasCollectableDipsAgreement', () => {
it('returns false when no indexing-payments subgraph is configured', async () => {
const monitor = createMonitor({ agreements: null })
expect(await monitor.hasCollectableDipsAgreement(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.hasCollectableDipsAgreement(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.hasCollectableDipsAgreement(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.hasCollectableDipsAgreement(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.hasCollectableDipsAgreement(ALLOCATION_ID)).toBe(true)
})

it('returns false when there are no protecting agreements for the allocation', async () => {
const monitor = createMonitor({ agreements: [] })
expect(await monitor.hasCollectableDipsAgreement(ALLOCATION_ID)).toBe(false)
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ const mockAllocation = {
}

const createMockNetworkMonitor = (hasAgreement: boolean) => ({
hasActiveDipsAgreement: jest.fn().mockResolvedValue(hasAgreement),
hasCollectableDipsAgreement: jest.fn().mockResolvedValue(hasAgreement),
allocation: jest.fn().mockResolvedValue(mockAllocation),
subgraphDeployment: jest.fn().mockResolvedValue({}),
})
Expand All @@ -35,16 +35,16 @@ const baseAction: ActionInput = {
}

describe('validateActionInputs DIPS agreement protection', () => {
it('should reject UNALLOCATE with active DIPS agreement when force is not set', async () => {
it('should reject UNALLOCATE with a collectable DIPS agreement when force is not set', async () => {
const monitor = createMockNetworkMonitor(true)
const logger = createMockLogger()

await expect(
validateActionInputs([baseAction], monitor as any, logger as any),
).rejects.toThrow(/active DIPS agreement/)
).rejects.toThrow(/DIPS agreement that can still collect fees/)
})

it('should allow UNALLOCATE with active DIPS agreement when force is true', async () => {
it('should allow UNALLOCATE with a collectable DIPS agreement when force is true', async () => {
const monitor = createMockNetworkMonitor(true)
const logger = createMockLogger()

Expand All @@ -55,12 +55,12 @@ describe('validateActionInputs DIPS agreement protection', () => {
).resolves.toBeUndefined()

expect(logger.warn).toHaveBeenCalledWith(
'Force-closing allocation with active DIPS agreement',
'Force-closing allocation with a collectable DIPS agreement',
expect.objectContaining({ allocationId: action.allocationID }),
)
})

it('should allow UNALLOCATE with no active DIPS agreement', async () => {
it('should allow UNALLOCATE with no collectable DIPS agreement', async () => {
const monitor = createMockNetworkMonitor(false)
const logger = createMockLogger()

Expand All @@ -84,6 +84,6 @@ describe('validateActionInputs DIPS agreement protection', () => {
validateActionInputs([action], monitor as any, logger as any),
).resolves.toBeUndefined()

expect(monitor.hasActiveDipsAgreement).not.toHaveBeenCalled()
expect(monitor.hasCollectableDipsAgreement).not.toHaveBeenCalled()
})
})
20 changes: 10 additions & 10 deletions packages/indexer-common/src/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,10 +56,8 @@ export interface ActionInput {

const ZERO_POI = '0x0000000000000000000000000000000000000000000000000000000000000000'

// Validates POI-related fields.
// Zero POI is a sentinel meaning "no POI to submit", so publicPOI and
// poiBlockNumber are not required in that case (nor when POI is omitted).
// When POI is a real value, publicPOI and poiBlockNumber are required.
// Zero POI is a sentinel for "no POI to submit", so publicPOI and poiBlockNumber
// are optional then (and when POI is omitted); a real POI requires both.
/* eslint-disable @typescript-eslint/no-explicit-any */
const hasValidPOIParams = (variableToCheck: any): boolean => {
if (variableToCheck.poi === undefined || variableToCheck.poi === ZERO_POI) {
Expand Down Expand Up @@ -183,20 +181,22 @@ export const validateActionInputs = async (
)
}

// Check for active DIPS agreement on UNALLOCATE
// Block closing an allocation that still owes DIPS fees: SubgraphService.collect
// requires the allocation open, so an early close would cancel a live agreement
// on-chain or strand fees a canceled agreement hasn't finished collecting.
if (action.type === ActionType.UNALLOCATE && action.allocationID) {
const hasAgreement = await networkMonitor.hasActiveDipsAgreement(
const hasAgreement = await networkMonitor.hasCollectableDipsAgreement(
action.allocationID,
)
if (hasAgreement && !action.force) {
throw new Error(
`Allocation ${action.allocationID} has an active DIPS agreement. ` +
`Closing this allocation will cancel the agreement on-chain. ` +
`Use force=true to proceed anyway.`,
`Allocation ${action.allocationID} has a DIPS agreement that can still collect fees. ` +
`Closing it now would cancel a live agreement on-chain, or strand fees that a ` +
`canceled agreement has not finished collecting. Use force=true to proceed anyway.`,
)
}
if (hasAgreement && action.force) {
logger.warn('Force-closing allocation with active DIPS agreement', {
logger.warn('Force-closing allocation with a collectable DIPS agreement', {
allocationId: action.allocationID,
actionType: action.type,
})
Expand Down
38 changes: 35 additions & 3 deletions packages/indexer-common/src/indexer-management/monitor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ export class NetworkMonitor {
private indexingPaymentsSubgraph?: SubgraphClient,
) {}

async hasActiveDipsAgreement(allocationId: string): Promise<boolean> {
async hasCollectableDipsAgreement(allocationId: string): Promise<boolean> {
// No DIPS subgraph configured → no agreement can exist
if (!this.indexingPaymentsSubgraph) {
return false
Expand All @@ -67,15 +67,47 @@ export class NetworkMonitor {
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