Skip to content

Commit 2ee6d35

Browse files
committed
feat: implement on-chain cancel for DIPs agreements
1 parent 53d84e4 commit 2ee6d35

4 files changed

Lines changed: 352 additions & 20 deletions

File tree

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

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1214,15 +1214,6 @@ export class AllocationManager {
12141214

12151215
await upsertIndexingRule(logger, this.models, neverIndexingRule)
12161216

1217-
if (this.dipsManager) {
1218-
await this.dipsManager.tryCancelAgreement(allocationID)
1219-
await this.dipsManager.tryUpdateAgreementAllocation(
1220-
allocation.subgraphDeployment.id.toString(),
1221-
toAddress(allocationID),
1222-
null,
1223-
)
1224-
}
1225-
12261217
return {
12271218
actionID,
12281219
type: 'unallocate',

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

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1762,17 +1762,6 @@ export default {
17621762

17631763
await models.IndexingRule.upsert(offchainIndexingRule)
17641764

1765-
const allocationManager =
1766-
actionManager?.allocationManagers[network.specification.networkIdentifier]
1767-
if (allocationManager?.dipsManager) {
1768-
await allocationManager.dipsManager.tryCancelAgreement(allocation)
1769-
await allocationManager.dipsManager.tryUpdateAgreementAllocation(
1770-
allocationData.subgraphDeployment.id.toString(),
1771-
toAddress(allocation),
1772-
null,
1773-
)
1774-
}
1775-
17761765
// Since upsert succeeded, we _must_ have a rule
17771766
const updatedRule = await models.IndexingRule.findOne({
17781767
where: { identifier: offchainIndexingRule.identifier },

packages/indexer-common/src/indexing-fees/__tests__/dips.test.ts

Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
IndexerManagementClient,
1818
MultiNetworks,
1919
} from '@graphprotocol/indexer-common'
20+
import type { SubgraphIndexingAgreement } from '../agreement-monitor'
2021
import {
2122
connectDatabase,
2223
createLogger,
@@ -458,6 +459,240 @@ describe('DipsManager', () => {
458459
expect(deployments).toHaveLength(1)
459460
expect(deployments[0].ipfsHash).toBe(testDeploymentId)
460461
})
462+
463+
describe('cancelAgreement', () => {
464+
const mockAgreement: SubgraphIndexingAgreement = {
465+
id: '0x123e4567e89b12d3a456426614174000',
466+
allocationId: '0xabcd47df40c29949a75a6693c77834c00b8ad626',
467+
subgraphDeploymentId: 'QmTZ8ejXJxRo7vDBS4uwqBeGoxLSWbhaA7oXa1RvxunLy7',
468+
state: 1, // Accepted
469+
lastCollectionAt: '0',
470+
endsAt: '9999999999',
471+
maxInitialTokens: '1000',
472+
maxOngoingTokensPerSecond: '100',
473+
tokensPerSecond: '10',
474+
tokensPerEntityPerSecond: '1',
475+
minSecondsPerCollection: 60,
476+
maxSecondsPerCollection: 300,
477+
canceledAt: '0',
478+
}
479+
480+
beforeEach(() => {
481+
// Track the agreement so we can verify cleanup
482+
dipsManager.collectionTracker.track(mockAgreement.id, {
483+
lastCollectedAt: 0,
484+
minSecondsPerCollection: 60,
485+
maxSecondsPerCollection: 300,
486+
})
487+
})
488+
489+
test('successful cancel + final collect attempt', async () => {
490+
const mockReceipt = { hash: '0xcancel123' }
491+
const mockCollectReceipt = { hash: '0xcollect456' }
492+
493+
// Mock cancel transaction
494+
network.transactionManager.executeTransaction = jest
495+
.fn()
496+
.mockResolvedValueOnce(mockReceipt) // cancel
497+
.mockResolvedValueOnce(mockCollectReceipt) // collect
498+
499+
// Mock block number and graph node methods for collect
500+
network.networkProvider.getBlockNumber = jest.fn().mockResolvedValue(100)
501+
graphNode.entityCount = jest.fn().mockResolvedValue([250000])
502+
graphNode.subgraphFeatures = jest
503+
.fn()
504+
.mockResolvedValue({ network: 'mainnet' })
505+
graphNode.blockHashFromNumber = jest
506+
.fn()
507+
.mockResolvedValue('0xblockhash')
508+
graphNode.proofOfIndexing = jest
509+
.fn()
510+
.mockResolvedValue(
511+
'0x0000000000000000000000000000000000000000000000000000000000000001',
512+
)
513+
514+
const result = await dipsManager.cancelAgreement(
515+
mockAgreement.id,
516+
mockAgreement,
517+
)
518+
519+
expect(result).toBe(true)
520+
// executeTransaction called twice: once for cancel, once for collect
521+
expect(network.transactionManager.executeTransaction).toHaveBeenCalledTimes(2)
522+
// Tracker should be cleaned up (untracked = ready)
523+
expect(
524+
dipsManager.collectionTracker.isReadyForCollection(mockAgreement.id, 0),
525+
).toBe(true)
526+
})
527+
528+
test('cancel fails returns false, no collect attempted', async () => {
529+
// Mock cancel transaction failure
530+
network.transactionManager.executeTransaction = jest
531+
.fn()
532+
.mockRejectedValueOnce(new Error('cancel tx reverted'))
533+
534+
const result = await dipsManager.cancelAgreement(
535+
mockAgreement.id,
536+
mockAgreement,
537+
)
538+
539+
expect(result).toBe(false)
540+
// executeTransaction called only once (for cancel)
541+
expect(network.transactionManager.executeTransaction).toHaveBeenCalledTimes(1)
542+
})
543+
544+
test('cancel succeeds but collect fails returns true, tracker still cleaned up', async () => {
545+
const mockReceipt = { hash: '0xcancel123' }
546+
547+
// Mock cancel succeeds
548+
network.transactionManager.executeTransaction = jest
549+
.fn()
550+
.mockResolvedValueOnce(mockReceipt) // cancel succeeds
551+
.mockRejectedValueOnce(new Error('collect failed')) // collect fails
552+
553+
// Mock block number and graph node methods
554+
network.networkProvider.getBlockNumber = jest.fn().mockResolvedValue(100)
555+
graphNode.entityCount = jest.fn().mockResolvedValue([250000])
556+
graphNode.subgraphFeatures = jest
557+
.fn()
558+
.mockResolvedValue({ network: 'mainnet' })
559+
graphNode.blockHashFromNumber = jest
560+
.fn()
561+
.mockResolvedValue('0xblockhash')
562+
graphNode.proofOfIndexing = jest
563+
.fn()
564+
.mockResolvedValue(
565+
'0x0000000000000000000000000000000000000000000000000000000000000001',
566+
)
567+
568+
const result = await dipsManager.cancelAgreement(
569+
mockAgreement.id,
570+
mockAgreement,
571+
)
572+
573+
expect(result).toBe(true)
574+
// Tracker should be cleaned up even though collect failed
575+
expect(
576+
dipsManager.collectionTracker.isReadyForCollection(mockAgreement.id, 0),
577+
).toBe(true)
578+
})
579+
})
580+
581+
describe('cleanupFinishedAgreement', () => {
582+
const baseAgreement: SubgraphIndexingAgreement = {
583+
id: '0x123e4567e89b12d3a456426614174000',
584+
allocationId: '0xabcd47df40c29949a75a6693c77834c00b8ad626',
585+
subgraphDeploymentId: 'QmTZ8ejXJxRo7vDBS4uwqBeGoxLSWbhaA7oXa1RvxunLy7',
586+
state: 1,
587+
lastCollectionAt: '0',
588+
endsAt: '9999999999',
589+
maxInitialTokens: '1000',
590+
maxOngoingTokensPerSecond: '100',
591+
tokensPerSecond: '10',
592+
tokensPerEntityPerSecond: '1',
593+
minSecondsPerCollection: 60,
594+
maxSecondsPerCollection: 300,
595+
canceledAt: '0',
596+
}
597+
598+
beforeEach(() => {
599+
dipsManager.collectionTracker.track(baseAgreement.id, {
600+
lastCollectedAt: 0,
601+
minSecondsPerCollection: 60,
602+
maxSecondsPerCollection: 300,
603+
})
604+
})
605+
606+
test('removes payer-cancelled agreement from tracker after collection', () => {
607+
const removeSpy = jest.spyOn(dipsManager.collectionTracker, 'remove')
608+
const agreement = { ...baseAgreement, state: 3 }
609+
610+
const result = dipsManager.cleanupFinishedAgreement(agreement, 1000, logger)
611+
612+
expect(result).toBe(true)
613+
expect(removeSpy).toHaveBeenCalledWith(agreement.id)
614+
})
615+
616+
test('removes expired agreement from tracker after collection', () => {
617+
const removeSpy = jest.spyOn(dipsManager.collectionTracker, 'remove')
618+
const nowSeconds = 2000
619+
const agreement = { ...baseAgreement, state: 1, endsAt: '1000' }
620+
621+
const result = dipsManager.cleanupFinishedAgreement(
622+
agreement,
623+
nowSeconds,
624+
logger,
625+
)
626+
627+
expect(result).toBe(true)
628+
expect(removeSpy).toHaveBeenCalledWith(agreement.id)
629+
})
630+
631+
test('does not remove active agreement from tracker', () => {
632+
const removeSpy = jest.spyOn(dipsManager.collectionTracker, 'remove')
633+
const nowSeconds = 1000
634+
const agreement = { ...baseAgreement, state: 1, endsAt: '9999999999' }
635+
636+
const result = dipsManager.cleanupFinishedAgreement(
637+
agreement,
638+
nowSeconds,
639+
logger,
640+
)
641+
642+
expect(result).toBe(false)
643+
expect(removeSpy).not.toHaveBeenCalled()
644+
})
645+
})
646+
647+
describe('cancelBlocklistedAgreements', () => {
648+
const mockAgreement: SubgraphIndexingAgreement = {
649+
id: '0x123e4567e89b12d3a456426614174000',
650+
allocationId: '0xabcd47df40c29949a75a6693c77834c00b8ad626',
651+
subgraphDeploymentId: 'QmTZ8ejXJxRo7vDBS4uwqBeGoxLSWbhaA7oXa1RvxunLy7',
652+
state: 1,
653+
lastCollectionAt: '0',
654+
endsAt: '9999999999',
655+
maxInitialTokens: '1000',
656+
maxOngoingTokensPerSecond: '100',
657+
tokensPerSecond: '10',
658+
tokensPerEntityPerSecond: '1',
659+
minSecondsPerCollection: 60,
660+
maxSecondsPerCollection: 300,
661+
canceledAt: '0',
662+
}
663+
664+
test('cancels agreements with NEVER rule for their deployment', async () => {
665+
// Create a NEVER rule for the test deployment
666+
await managementModels.IndexingRule.create({
667+
identifier: testDeploymentId,
668+
identifierType: SubgraphIdentifierType.DEPLOYMENT,
669+
decisionBasis: IndexingDecisionBasis.NEVER,
670+
requireSupported: true,
671+
safety: true,
672+
protocolNetwork: 'eip155:421614',
673+
allocationAmount: '0',
674+
})
675+
676+
const cancelSpy = jest
677+
.spyOn(dipsManager, 'cancelAgreement')
678+
.mockResolvedValue(true)
679+
680+
await dipsManager.cancelBlocklistedAgreements([mockAgreement])
681+
682+
expect(cancelSpy).toHaveBeenCalledTimes(1)
683+
expect(cancelSpy).toHaveBeenCalledWith(mockAgreement.id, mockAgreement)
684+
})
685+
686+
test('does not cancel agreements without NEVER rule', async () => {
687+
const cancelSpy = jest
688+
.spyOn(dipsManager, 'cancelAgreement')
689+
.mockResolvedValue(true)
690+
691+
await dipsManager.cancelBlocklistedAgreements([mockAgreement])
692+
693+
expect(cancelSpy).not.toHaveBeenCalled()
694+
})
695+
})
461696
})
462697
})
463698

0 commit comments

Comments
 (0)