Skip to content

Commit 3393d54

Browse files
tmigoneMoonBoi9001claude
authored
fix: gracefully handle overallocation when reallocating (#1203)
Signed-off-by: Tomás Migone <tomas@edgeandnode.com> Co-authored-by: MoonBoi9001 <67825802+MoonBoi9001@users.noreply.github.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 0ed83b1 commit 3393d54

6 files changed

Lines changed: 221 additions & 0 deletions

File tree

packages/indexer-common/src/errors.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ export enum IndexerErrorCode {
100100
IE087 = 'IE087',
101101
IE088 = 'IE088',
102102
IE089 = 'IE089',
103+
IE090 = 'IE090',
103104
}
104105

105106
export const INDEXER_ERROR_MESSAGES: Record<IndexerErrorCode, string> = {
@@ -193,6 +194,7 @@ export const INDEXER_ERROR_MESSAGES: Record<IndexerErrorCode, string> = {
193194
IE087: 'Failed to resize allocation',
194195
IE088: 'Failed to present POI',
195196
IE089: 'Failed to collect indexing rewards',
197+
IE090: 'Failed to reallocate: indexer is overallocated',
196198
}
197199

198200
export type IndexerErrorCause = unknown
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
import { createLogger, Logger } from '@graphprotocol/common-ts'
2+
import { assertNotOverAllocated } from '../over-allocation'
3+
import { IndexerError, IndexerErrorCode } from '../../errors'
4+
5+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
6+
declare const __LOG_LEVEL__: any
7+
8+
const indexer = '0x0000000000000000000000000000000000000001'
9+
const allocationId = '0x000000000000000000000000000000000000000a'
10+
const subgraphServiceAddress = '0x00000000000000000000000000000000000000ff'
11+
12+
interface MockSubgraphService {
13+
isOverAllocated: jest.Mock
14+
allocationProvisionTracker: jest.Mock
15+
getDelegationRatio: jest.Mock
16+
target: string
17+
}
18+
19+
interface MockHorizonStaking {
20+
getTokensAvailable: jest.Mock
21+
}
22+
23+
const buildContracts = (
24+
overrides: {
25+
isOverAllocated?: boolean
26+
allocatedTokens?: bigint
27+
delegationRatio?: bigint
28+
tokensAvailable?: bigint
29+
} = {},
30+
): {
31+
contracts: { SubgraphService: MockSubgraphService; HorizonStaking: MockHorizonStaking }
32+
subgraphService: MockSubgraphService
33+
horizonStaking: MockHorizonStaking
34+
} => {
35+
const subgraphService: MockSubgraphService = {
36+
isOverAllocated: jest.fn().mockResolvedValue(overrides.isOverAllocated ?? false),
37+
allocationProvisionTracker: jest
38+
.fn()
39+
.mockResolvedValue(overrides.allocatedTokens ?? 0n),
40+
getDelegationRatio: jest.fn().mockResolvedValue(overrides.delegationRatio ?? 1n),
41+
target: subgraphServiceAddress,
42+
}
43+
const horizonStaking: MockHorizonStaking = {
44+
getTokensAvailable: jest.fn().mockResolvedValue(overrides.tokensAvailable ?? 0n),
45+
}
46+
return {
47+
contracts: { SubgraphService: subgraphService, HorizonStaking: horizonStaking },
48+
subgraphService,
49+
horizonStaking,
50+
}
51+
}
52+
53+
describe('assertNotOverAllocated', () => {
54+
let logger: Logger
55+
56+
beforeAll(() => {
57+
logger = createLogger({
58+
name: 'over-allocation-test',
59+
async: false,
60+
level: __LOG_LEVEL__ ?? 'error',
61+
})
62+
})
63+
64+
it('returns without throwing when the indexer is not over-allocated', async () => {
65+
const { contracts, subgraphService, horizonStaking } = buildContracts({
66+
isOverAllocated: false,
67+
})
68+
69+
await expect(
70+
assertNotOverAllocated(
71+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
72+
contracts as any,
73+
indexer,
74+
logger,
75+
allocationId,
76+
),
77+
).resolves.toBeUndefined()
78+
79+
expect(subgraphService.isOverAllocated).toHaveBeenCalledWith(indexer)
80+
// None of the diagnostic reads should fire on the happy path — they are
81+
// only needed to compose the error message when over-allocation is true.
82+
expect(subgraphService.allocationProvisionTracker).not.toHaveBeenCalled()
83+
expect(subgraphService.getDelegationRatio).not.toHaveBeenCalled()
84+
expect(horizonStaking.getTokensAvailable).not.toHaveBeenCalled()
85+
})
86+
87+
it('throws IE090 with the over-allocated delta when over-allocated', async () => {
88+
// 1000 GRT allocated, 600 GRT available => 400 GRT over.
89+
const { contracts } = buildContracts({
90+
isOverAllocated: true,
91+
allocatedTokens: 1000n * 10n ** 18n,
92+
delegationRatio: 2n,
93+
tokensAvailable: 600n * 10n ** 18n,
94+
})
95+
96+
let captured: unknown
97+
try {
98+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
99+
await assertNotOverAllocated(contracts as any, indexer, logger, allocationId)
100+
} catch (err) {
101+
captured = err
102+
}
103+
104+
expect(captured).toBeInstanceOf(IndexerError)
105+
const err = captured as IndexerError
106+
expect(err.code).toBe(IndexerErrorCode.IE090)
107+
// Spot-check the cause string contains the GRT delta and the actionable
108+
// hint pointing operators at the close path (which still collects rewards).
109+
const cause = String(err.cause)
110+
expect(cause).toContain('400.0')
111+
expect(cause).toContain('graph indexer allocations close')
112+
})
113+
114+
it('passes the SubgraphService address as the verifier to getTokensAvailable', async () => {
115+
const { contracts, horizonStaking } = buildContracts({
116+
isOverAllocated: true,
117+
allocatedTokens: 100n,
118+
delegationRatio: 3n,
119+
tokensAvailable: 50n,
120+
})
121+
122+
await expect(
123+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
124+
assertNotOverAllocated(contracts as any, indexer, logger, allocationId),
125+
).rejects.toBeInstanceOf(IndexerError)
126+
127+
expect(horizonStaking.getTokensAvailable).toHaveBeenCalledWith(
128+
indexer,
129+
subgraphServiceAddress,
130+
3n,
131+
)
132+
})
133+
134+
it('clamps the over-allocated amount to zero if reads race to a negative delta', async () => {
135+
// tokensAvailable > allocatedTokens shouldn't happen when isOverAllocated
136+
// is true, but the three reads aren't atomic; defend against a negative
137+
// bigint formatting into the error message.
138+
const { contracts } = buildContracts({
139+
isOverAllocated: true,
140+
allocatedTokens: 100n,
141+
delegationRatio: 1n,
142+
tokensAvailable: 200n,
143+
})
144+
145+
let captured: unknown
146+
try {
147+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
148+
await assertNotOverAllocated(contracts as any, indexer, logger, allocationId)
149+
} catch (err) {
150+
captured = err
151+
}
152+
153+
const err = captured as IndexerError
154+
expect(err.code).toBe(IndexerErrorCode.IE090)
155+
const cause = String(err.cause)
156+
expect(cause).toContain('0.0 GRT')
157+
expect(cause).not.toContain('-')
158+
})
159+
})

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ import {
5050
encodeCollectIndexingRewardsData,
5151
encodePOIMetadata,
5252
} from '@graphprotocol/toolshed'
53+
import { assertNotOverAllocated } from './over-allocation'
5354

5455
import {
5556
BigNumberish,
@@ -2123,6 +2124,13 @@ export class AllocationManager {
21232124
}
21242125
}
21252126
} else {
2127+
await assertNotOverAllocated(
2128+
this.network.contracts,
2129+
params.indexer,
2130+
logger,
2131+
params.closingAllocationID,
2132+
)
2133+
21262134
// Horizon: Need to multicall collect and stopService
21272135

21282136
// collect

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ export * from './allocations'
33
export * from './client'
44
export * from './models'
55
export * from './monitor'
6+
export * from './over-allocation'
67
export * from './server'
78
export * from './rules'
89
export * from './types'
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { formatGRT, Logger } from '@graphprotocol/common-ts'
2+
import {
3+
GraphHorizonContracts,
4+
SubgraphServiceContracts,
5+
} from '@graphprotocol/toolshed/deployments'
6+
import { indexerError, IndexerErrorCode } from '../errors'
7+
8+
// Throws IE090 if the indexer is over-allocated on the SubgraphService.
9+
// Used by reallocate paths: collect() would auto-close the existing allocation
10+
// and the new allocation would be rejected, leaving the indexer with no
11+
// allocation on the deployment. The indexer can still collect rewards by
12+
// closing the allocation directly via `graph indexer allocations close`,
13+
// which handles over-allocation gracefully.
14+
export async function assertNotOverAllocated(
15+
contracts: GraphHorizonContracts & SubgraphServiceContracts,
16+
indexer: string,
17+
logger: Logger,
18+
allocationId: string,
19+
): Promise<void> {
20+
const isOverAllocated = await contracts.SubgraphService.isOverAllocated(indexer)
21+
22+
logger.debug('Checking over-allocation status for reallocate allocation', {
23+
allocationId,
24+
isOverAllocated,
25+
})
26+
27+
if (!isOverAllocated) {
28+
return
29+
}
30+
31+
const [allocatedTokens, delegationRatio] = await Promise.all([
32+
contracts.SubgraphService.allocationProvisionTracker(indexer),
33+
contracts.SubgraphService.getDelegationRatio(),
34+
])
35+
const tokensAvailable = await contracts.HorizonStaking.getTokensAvailable(
36+
indexer,
37+
contracts.SubgraphService.target,
38+
delegationRatio,
39+
)
40+
const overallocatedAmount =
41+
allocatedTokens > tokensAvailable ? allocatedTokens - tokensAvailable : 0n
42+
throw indexerError(
43+
IndexerErrorCode.IE090,
44+
`Overallocated by ${formatGRT(
45+
overallocatedAmount,
46+
)} GRT. Close this allocation via 'graph indexer allocations close' to collect rewards, or add provision tokens before retrying reallocate.`,
47+
)
48+
}

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
import {
1717
Allocation,
1818
AllocationStatus,
19+
assertNotOverAllocated,
1920
CloseAllocationResult,
2021
CreateAllocationResult,
2122
encodeCollectData,
@@ -1207,6 +1208,8 @@ async function reallocateHorizonAllocation(
12071208
epoch: currentEpoch.toString(),
12081209
})
12091210

1211+
await assertNotOverAllocated(contracts, address, logger, allocation.id)
1212+
12101213
// Identify how many GRT the indexer has staked
12111214
const freeStake = (await network.networkMonitor.freeStake()).horizon
12121215

0 commit comments

Comments
 (0)