Skip to content
Open
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
1 change: 1 addition & 0 deletions .github/workflows/indexer-agent-image.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
name: Indexer Agent Image

on:
workflow_dispatch:
push:
branches:
- main
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/indexer-cli-image.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
name: Indexer CLI Image

on:
workflow_dispatch:
push:
branches:
- main
Expand Down
23 changes: 8 additions & 15 deletions packages/indexer-agent/src/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -719,22 +719,15 @@ export class Agent {
return
}

await this.multiNetworks.mapNetworkMapped(
activeAllocations,
async ({ network, operator }, activeAllocations: Allocation[]) => {
if (network.specification.indexerOptions.enableDips) {
if (!operator.dipsManager) {
throw new Error('DipsManager is not available')
}

await operator.dipsManager.acceptPendingProposals(
activeAllocations,
)

await operator.dipsManager.collectAgreementPayments()
await this.multiNetworks.map(async ({ network, operator }) => {
if (network.specification.indexerOptions.enableDips) {
if (!operator.dipsManager) {
throw new Error('DipsManager is not available')
}
},
)

await operator.dipsManager.collectAgreementPayments()
}
})
},
)
}
Expand Down
2 changes: 2 additions & 0 deletions packages/indexer-common/src/indexer-management/allocations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,8 @@ export class AllocationManager {
this,
this.pendingRcaModel,
)
this.dipsManager.startProposalAcceptanceLoop()
this.dipsManager.startAllocationSweepLoop()
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,12 @@ import { PendingRcaConsumer } from '../pending-rca-consumer'
import { DecodedRcaProposal } from '../types'
import {
Allocation,
AllocationManager,
AllocationStatus,
IndexerManagementModels,
IndexingDecisionBasis,
Network,
SubgraphIdentifierType,
} from '@graphprotocol/indexer-common'

let logger: Logger
Expand Down Expand Up @@ -108,10 +111,17 @@ function createMockModels() {
findOne: jest.fn().mockResolvedValue(null),
findAll: jest.fn().mockResolvedValue([]),
destroy: jest.fn().mockResolvedValue(1),
upsert: jest.fn().mockResolvedValue([{ id: 1 }, true]),
},
} as unknown as IndexerManagementModels
}

function createMockParent() {
return {
matchingRuleExists: jest.fn().mockResolvedValue(false),
} as unknown as AllocationManager
}

function createMockNetwork() {
return {
contracts: {
Expand Down Expand Up @@ -168,11 +178,18 @@ function createDipsManager(
network: Network,
models: IndexerManagementModels,
consumer: PendingRcaConsumer,
parent: AllocationManager = createMockParent(),
offerMonitor?: { offerExists: jest.Mock },
): DipsManager {
const graphNode = { ensure: jest.fn().mockResolvedValue(undefined) }
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const dm = new DipsManager(logger, models, network, {} as any, null)
const dm = new DipsManager(logger, models, network, graphNode as any, parent)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
;(dm as any).pendingRcaConsumer = consumer
if (offerMonitor !== undefined) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
;(dm as any).offerMonitor = offerMonitor
}
return dm
}

Expand Down Expand Up @@ -593,4 +610,172 @@ describe('DipsManager.acceptPendingProposals', () => {
expect(consumer.markAccepted).toHaveBeenCalledWith(proposal.id)
})
})

describe('rule creation ordering (race condition fix)', () => {
test('upserts the DIPS indexing rule before broadcasting acceptIndexingAgreement', async () => {
const proposal = createMockProposal()
const allocation = createMockAllocation()
const consumer = createMockConsumer([proposal])
const models = createMockModels()
const network = createMockNetwork()
;(network.transactionManager.executeTransaction as jest.Mock).mockResolvedValue({
hash: '0xtx',
status: 1,
})

const dm = createDipsManager(network, models, consumer)

await dm.acceptPendingProposals([allocation])

const upsertOrder = (models.IndexingRule.upsert as jest.Mock).mock
.invocationCallOrder[0]
const executeOrder = (network.transactionManager.executeTransaction as jest.Mock)
.mock.invocationCallOrder[0]

expect(upsertOrder).toBeDefined()
expect(executeOrder).toBeDefined()
expect(upsertOrder).toBeLessThan(executeOrder)
})

test('skips rule upsert and rejects proposal when deployment is blocklisted', async () => {
const proposal = createMockProposal()
const allocation = createMockAllocation()
const consumer = createMockConsumer([proposal])
;(consumer.getPendingProposalsForDeployment as jest.Mock).mockResolvedValue([])
const models = createMockModels()
;(models.IndexingRule.findAll as jest.Mock).mockResolvedValue([
{
identifier: proposal.subgraphDeploymentId.ipfsHash,
identifierType: SubgraphIdentifierType.DEPLOYMENT,
decisionBasis: IndexingDecisionBasis.NEVER,
},
])
const network = createMockNetwork()

const dm = createDipsManager(network, models, consumer)

await dm.acceptPendingProposals([allocation])

expect(consumer.markRejected).toHaveBeenCalledWith(
proposal.id,
'deployment blocklisted',
)
expect(models.IndexingRule.upsert).not.toHaveBeenCalled()
expect(network.transactionManager.executeTransaction).not.toHaveBeenCalled()
})

test('skips rule upsert when parent reports a matching rule already exists', async () => {
const proposal = createMockProposal()
const allocation = createMockAllocation()
const consumer = createMockConsumer([proposal])
const models = createMockModels()
const network = createMockNetwork()
;(network.transactionManager.executeTransaction as jest.Mock).mockResolvedValue({
hash: '0xtx',
status: 1,
})

const parent = {
matchingRuleExists: jest.fn().mockResolvedValue(true),
} as unknown as AllocationManager

const dm = createDipsManager(network, models, consumer, parent)

await dm.acceptPendingProposals([allocation])

expect(models.IndexingRule.upsert).not.toHaveBeenCalled()
expect(network.transactionManager.executeTransaction).toHaveBeenCalled()
expect(consumer.markAccepted).toHaveBeenCalledWith(proposal.id)
})
})

describe('offer-existence gate', () => {
test('stays pending when offer absent and deadline > now + safety margin', async () => {
const proposal = createMockProposal({
// 5 minutes from now — well beyond the 30s safety margin
deadline: BigInt(Math.floor(Date.now() / 1000) + 300),
})
const allocation = createMockAllocation()
const consumer = createMockConsumer([proposal])
const models = createMockModels()
const network = createMockNetwork()
const offerMonitor = { offerExists: jest.fn().mockResolvedValue(false) }

const dm = createDipsManager(network, models, consumer, undefined, offerMonitor)

await dm.acceptPendingProposals([allocation])

expect(offerMonitor.offerExists).toHaveBeenCalledWith(proposal.id)
expect(network.transactionManager.executeTransaction).not.toHaveBeenCalled()
expect(consumer.markRejected).not.toHaveBeenCalled()
expect(consumer.markAccepted).not.toHaveBeenCalled()
})

test('marks rejected when offer absent and deadline within safety margin', async () => {
const proposal = createMockProposal({
// 10 seconds from now — inside the 30s safety margin
deadline: BigInt(Math.floor(Date.now() / 1000) + 10),
})
const allocation = createMockAllocation()
const consumer = createMockConsumer([proposal])
const models = createMockModels()
const network = createMockNetwork()
const offerMonitor = { offerExists: jest.fn().mockResolvedValue(false) }

const dm = createDipsManager(network, models, consumer, undefined, offerMonitor)

await dm.acceptPendingProposals([allocation])

expect(offerMonitor.offerExists).toHaveBeenCalledWith(proposal.id)
expect(consumer.markRejected).toHaveBeenCalledWith(
proposal.id,
'offer_never_landed',
)
expect(network.transactionManager.executeTransaction).not.toHaveBeenCalled()
})

test('proceeds to acceptIndexingAgreement when offer is present', async () => {
const proposal = createMockProposal()
const allocation = createMockAllocation()
const consumer = createMockConsumer([proposal])
const models = createMockModels()
const network = createMockNetwork()
;(network.transactionManager.executeTransaction as jest.Mock).mockResolvedValue({
hash: '0xtxhash',
status: 1,
})
const offerMonitor = { offerExists: jest.fn().mockResolvedValue(true) }

const dm = createDipsManager(network, models, consumer, undefined, offerMonitor)

await dm.acceptPendingProposals([allocation])

expect(offerMonitor.offerExists).toHaveBeenCalledWith(proposal.id)
expect(network.transactionManager.executeTransaction).toHaveBeenCalled()
expect(consumer.markAccepted).toHaveBeenCalledWith(proposal.id)
})

test('bypasses gate when indexingPaymentsSubgraph is not configured', async () => {
const proposal = createMockProposal()
const allocation = createMockAllocation()
const consumer = createMockConsumer([proposal])
const models = createMockModels()
const network = createMockNetwork()
;(network.transactionManager.executeTransaction as jest.Mock).mockResolvedValue({
hash: '0xtxhash',
status: 1,
})

// No offerMonitor passed — DipsManager constructor sets it to null
// because createMockNetwork() does not define indexingPaymentsSubgraph.
const dm = createDipsManager(network, models, consumer)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
expect((dm as any).offerMonitor).toBeNull()

await dm.acceptPendingProposals([allocation])

expect(network.transactionManager.executeTransaction).toHaveBeenCalled()
expect(consumer.markAccepted).toHaveBeenCalledWith(proposal.id)
})
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { createLogger } from '@graphprotocol/common-ts'
import { OfferMonitor } from '../offer-monitor'

const logger = createLogger({
name: 'OfferMonitor.test',
async: false,
level: 'error',
})

describe('OfferMonitor', () => {
it('converts UUID-format agreement ids to bytes16 hex before querying', async () => {
const query = jest.fn().mockResolvedValue({ data: { offer: { id: '0xabc' } } })
const subgraph = { query } as never
const monitor = new OfferMonitor(logger, subgraph)

const exists = await monitor.offerExists('bea99452-e465-e9d9-8a79-2356edcc7e92')

expect(exists).toBe(true)
expect(query).toHaveBeenCalledTimes(1)
expect(query.mock.calls[0][1]).toEqual({
id: '0xbea99452e465e9d98a792356edcc7e92',
})
})

it('passes through already-hex ids unchanged (lowercased)', async () => {
const query = jest.fn().mockResolvedValue({ data: { offer: { id: '0xabc' } } })
const subgraph = { query } as never
const monitor = new OfferMonitor(logger, subgraph)

await monitor.offerExists('0xBEA99452E465E9D98A792356EDCC7E92')

expect(query.mock.calls[0][1]).toEqual({
id: '0xbea99452e465e9d98a792356edcc7e92',
})
})

it('returns false when the subgraph reports the offer is missing', async () => {
const query = jest.fn().mockResolvedValue({ data: { offer: null } })
const subgraph = { query } as never
const monitor = new OfferMonitor(logger, subgraph)

const exists = await monitor.offerExists('bea99452-e465-e9d9-8a79-2356edcc7e92')

expect(exists).toBe(false)
})

it('treats subgraph errors as transient (not yet on-chain)', async () => {
const query = jest.fn().mockResolvedValue({ error: new Error('subgraph hiccup') })
const subgraph = { query } as never
const monitor = new OfferMonitor(logger, subgraph)

const exists = await monitor.offerExists('bea99452-e465-e9d9-8a79-2356edcc7e92')

expect(exists).toBe(false)
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ function encodeTestPayload(overrides?: {
[
{
subgraphDeploymentId: TEST_DEPLOYMENT_BYTES32,
version: 1n,
version: 0n,
terms: termsEncoded,
},
],
Expand Down
Loading
Loading