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,15 @@
import { activeAgreementRevertGuidance } from '../indexing-agreement-notices'

describe('activeAgreementRevertGuidance', () => {
it('translates the close-guard revert into the opt-out command', () => {
const guidance = activeAgreementRevertGuidance(
'Error: execution reverted: SubgraphServiceAllocationHasActiveAgreement(0x3ec9, 0x7f31)',
)
expect(guidance).toContain('active indexing agreement')
expect(guidance).toContain('graph indexer rules never')
})

it('leaves unrelated errors alone', () => {
expect(activeAgreementRevertGuidance('Error: nonce too low')).toBeNull()
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import chalk from 'chalk'
import { loadValidatedConfig } from '../../../config'
import { createIndexerManagementClient } from '../../../client'
import { closeAllocation } from '../../../allocations'
import { activeAgreementRevertGuidance } from '../../../indexing-agreement-notices'
import {
validatePOI,
printObjectOrArray,
Expand All @@ -20,7 +21,7 @@ ${chalk.dim('Options:')}

-h, --help Show usage information
-n, --network <network> The network to close the allocation on: mainnet, arbitrum-one, sepolia or arbitrum sepolia
-f, --force Bypass POI accuracy checks and submit transaction with provided data
-f, --force Bypass POI accuracy checks and the indexing agreement guard, then submit with provided data
-o, --output table|json|yaml Choose the output format: table (default), JSON, or YAML
-w, --wrap [N] Wrap the output to a specific width (default: 0, no wrapping)

Expand Down Expand Up @@ -114,6 +115,10 @@ module.exports = {
)
} catch (error) {
spinner.fail(error.toString())
const guidance = activeAgreementRevertGuidance(error.toString())
if (guidance) {
print.warning(guidance)
}
process.exitCode = 1
return
}
Expand Down
14 changes: 14 additions & 0 deletions packages/indexer-cli/src/indexing-agreement-notices.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
// SubgraphService reverts a close on an allocation with an active indexing
// agreement when its close guard is enabled; translate that raw revert into
// the command that resolves it instead of leaving an opaque error string.
export function activeAgreementRevertGuidance(errorMessage: string): string | null {
if (!errorMessage.includes('SubgraphServiceAllocationHasActiveAgreement')) {
return null
}
return [
'The network blocked this close: the allocation has an active indexing agreement.',
'Cancel the agreement first by opting the deployment out:',
'',
' graph indexer rules never <deployment> --network <network>',
].join('\n')
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { ActionInput, ActionStatus, ActionType, validateActionInputs } from '../actions'
import {
ActionInput,
ActionStatus,
ActionType,
assertSafeToCloseAllocation,
validateActionInputs,
} from '../actions'
import { AllocationStatus } from '../allocations'

const mockAllocation = {
Expand Down Expand Up @@ -87,3 +93,44 @@ describe('validateActionInputs DIPS agreement protection', () => {
expect(monitor.hasCollectableDipsAgreement).not.toHaveBeenCalled()
})
})

// The same guard is shared with the direct closeAllocation resolver, so it is
// also covered on its own, outside the action-validation wrapper.
describe('assertSafeToCloseAllocation', () => {
const allocationID = baseAction.allocationID as string

it('rejects an unforced close when the agreement can still collect', async () => {
const monitor = createMockNetworkMonitor(true)
const logger = createMockLogger()

await expect(
assertSafeToCloseAllocation(monitor as any, allocationID, false, logger as any),
).rejects.toThrow(/DIPS agreement that can still collect fees/)
})

it('allows a forced close but records a warning', async () => {
const monitor = createMockNetworkMonitor(true)
const logger = createMockLogger()

await expect(
assertSafeToCloseAllocation(monitor as any, allocationID, true, logger as any),
).resolves.toBeUndefined()

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

it('allows a close when nothing is collectable, forced or not', async () => {
for (const force of [false, true]) {
const monitor = createMockNetworkMonitor(false)
const logger = createMockLogger()

await expect(
assertSafeToCloseAllocation(monitor as any, allocationID, force, logger as any),
).resolves.toBeUndefined()
expect(logger.warn).not.toHaveBeenCalled()
}
})
})
45 changes: 28 additions & 17 deletions packages/indexer-common/src/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,30 @@ export const isValidActionInput = (
)
}

// Closing an allocation that still owes DIPS fees cancels a live agreement
// on-chain or strands fees a canceled agreement hasn't finished collecting,
// so every close path shares this guard; force acknowledges the loss.
export const assertSafeToCloseAllocation = async (
networkMonitor: NetworkMonitor,
allocationID: string,
force: boolean,
logger: Logger,
): Promise<void> => {
const hasAgreement = await networkMonitor.hasCollectableDipsAgreement(allocationID)
if (hasAgreement && !force) {
throw new Error(
`Allocation ${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 && force) {
logger.warn('Force-closing allocation with a collectable DIPS agreement', {
allocationId: allocationID,
})
}
}

export const validateActionInputs = async (
actions: ActionInput[],
networkMonitor: NetworkMonitor,
Expand Down Expand Up @@ -181,26 +205,13 @@ export const validateActionInputs = async (
)
}

// 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.hasCollectableDipsAgreement(
await assertSafeToCloseAllocation(
networkMonitor,
action.allocationID,
action.force ?? false,
logger,
)
if (hasAgreement && !action.force) {
throw new Error(
`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 a collectable DIPS agreement', {
allocationId: action.allocationID,
actionType: action.type,
})
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
import {
Allocation,
AllocationStatus,
assertSafeToCloseAllocation,
CloseAllocationResult,
CreateAllocationResult,
encodeCollectData,
Expand Down Expand Up @@ -910,6 +911,10 @@ export default {
const networkMonitor = network.networkMonitor
const allocationData = await networkMonitor.allocation(allocation)

// Same guard the queued unallocate path applies in validateActionInputs:
// an unforced close must not cancel a collectable DIPS agreement on-chain.
await assertSafeToCloseAllocation(networkMonitor, allocation, force, logger)

try {
logger.debug('Resolving POI')
const poiData = await networkMonitor.resolvePOI(
Expand Down
Loading