Skip to content

Commit 671ca97

Browse files
committed
fix: collect before cancelling an agreement
1 parent de88df5 commit 671ca97

2 files changed

Lines changed: 43 additions & 32 deletions

File tree

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

Lines changed: 28 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -749,15 +749,15 @@ describe('DipsManager', () => {
749749
})
750750
})
751751

752-
test('successful cancel + final collect attempt', async () => {
753-
const mockReceipt = { hash: '0xcancel123' }
752+
test('pre-cancel collect attempt + successful cancel', async () => {
754753
const mockCollectReceipt = { hash: '0xcollect456' }
754+
const mockReceipt = { hash: '0xcancel123' }
755755

756-
// Mock cancel transaction
756+
// Order: collect (best-effort, before cancel) then the SP cancel.
757757
network.transactionManager.executeTransaction = jest
758758
.fn()
759+
.mockResolvedValueOnce(mockCollectReceipt) // collect (before cancel)
759760
.mockResolvedValueOnce(mockReceipt) // cancel
760-
.mockResolvedValueOnce(mockCollectReceipt) // collect
761761

762762
// Mock block number and graph node methods for collect
763763
network.networkProvider.getBlockNumber = jest.fn().mockResolvedValue(100)
@@ -773,35 +773,49 @@ describe('DipsManager', () => {
773773
const result = await dipsManager.cancelAgreement(mockAgreement.id, mockAgreement)
774774

775775
expect(result).toBe(true)
776-
// executeTransaction called twice: once for cancel, once for collect
776+
// executeTransaction called twice: once for collect, once for cancel
777777
expect(network.transactionManager.executeTransaction).toHaveBeenCalledTimes(2)
778778
// Tracker should be cleaned up (untracked = ready)
779779
expect(
780780
dipsManager.collectionTracker.isReadyForCollection(mockAgreement.id, 0),
781781
).toBe(true)
782782
})
783783

784-
test('cancel fails returns false, no collect attempted', async () => {
785-
// Mock cancel transaction failure
784+
test('cancel fails returns false (after the pre-cancel collect attempt)', async () => {
785+
const mockCollectReceipt = { hash: '0xcollect456' }
786+
787+
// Collect (before cancel) succeeds, then the on-chain cancel reverts.
786788
network.transactionManager.executeTransaction = jest
787789
.fn()
788-
.mockRejectedValueOnce(new Error('cancel tx reverted'))
790+
.mockResolvedValueOnce(mockCollectReceipt) // collect (before cancel)
791+
.mockRejectedValueOnce(new Error('cancel tx reverted')) // cancel
792+
793+
network.networkProvider.getBlockNumber = jest.fn().mockResolvedValue(100)
794+
graphNode.entityCount = jest.fn().mockResolvedValue([250000])
795+
graphNode.subgraphFeatures = jest.fn().mockResolvedValue({ network: 'mainnet' })
796+
graphNode.blockHashFromNumber = jest.fn().mockResolvedValue('0xblockhash')
797+
graphNode.proofOfIndexing = jest
798+
.fn()
799+
.mockResolvedValue(
800+
'0x0000000000000000000000000000000000000000000000000000000000000001',
801+
)
789802

790803
const result = await dipsManager.cancelAgreement(mockAgreement.id, mockAgreement)
791804

792805
expect(result).toBe(false)
793-
// executeTransaction called only once (for cancel)
794-
expect(network.transactionManager.executeTransaction).toHaveBeenCalledTimes(1)
806+
// collect attempted first, then the failing cancel
807+
expect(network.transactionManager.executeTransaction).toHaveBeenCalledTimes(2)
795808
})
796809

797-
test('cancel succeeds but collect fails returns true, tracker still cleaned up', async () => {
810+
test('pre-cancel collect fails but cancel succeeds returns true, tracker cleaned up', async () => {
798811
const mockReceipt = { hash: '0xcancel123' }
799812

800-
// Mock cancel succeeds
813+
// Collect (before cancel) reverts — outside the collection window — then
814+
// the SP cancel succeeds anyway.
801815
network.transactionManager.executeTransaction = jest
802816
.fn()
817+
.mockRejectedValueOnce(new Error('collect failed')) // collect (before cancel)
803818
.mockResolvedValueOnce(mockReceipt) // cancel succeeds
804-
.mockRejectedValueOnce(new Error('collect failed')) // collect fails
805819

806820
// Mock block number and graph node methods
807821
network.networkProvider.getBlockNumber = jest.fn().mockResolvedValue(100)
@@ -817,7 +831,7 @@ describe('DipsManager', () => {
817831
const result = await dipsManager.cancelAgreement(mockAgreement.id, mockAgreement)
818832

819833
expect(result).toBe(true)
820-
// Tracker should be cleaned up even though collect failed
834+
// Tracker should be cleaned up even though the pre-cancel collect failed
821835
expect(
822836
dipsManager.collectionTracker.isReadyForCollection(mockAgreement.id, 0),
823837
).toBe(true)

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

Lines changed: 15 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -748,13 +748,23 @@ export class DipsManager {
748748
agreementId,
749749
})
750750

751-
// Step 1: Cancel on-chain (skipped if payer already canceled — a second
752-
// cancel reverts on `InvalidAgreementState` and would skip the final collect).
751+
// Step 1: Best-effort final collection BEFORE cancelling.
752+
try {
753+
const blockNumber = await this.network.networkProvider.getBlockNumber()
754+
await this.tryCollectAgreement(agreement, blockNumber, logger)
755+
logger.info('Final collection succeeded before cancel')
756+
} catch (err) {
757+
const errorMsg = err instanceof Error ? err.message : String(err)
758+
logger.warn(
759+
'Final collection before cancel failed (likely outside the collection window); proceeding to cancel',
760+
{ deployment: agreement.subgraphDeploymentId, error: errorMsg },
761+
)
762+
}
763+
764+
// Step 2: Cancel on-chain, skipped if payer already canceled.
753765
const indexerAddress = this.network.specification.indexerOptions.address
754766
if (agreement.state === 'CanceledByPayer') {
755-
logger.info(
756-
'Payer already canceled on-chain; skipping cancel, proceeding to final collection',
757-
)
767+
logger.info('Payer already canceled on-chain; skipping service-provider cancel')
758768
} else {
759769
try {
760770
const receipt = await this.network.transactionManager.executeTransaction(
@@ -787,19 +797,6 @@ export class DipsManager {
787797
}
788798
}
789799

790-
// Step 2: Best-effort final collection
791-
try {
792-
const blockNumber = await this.network.networkProvider.getBlockNumber()
793-
await this.tryCollectAgreement(agreement, blockNumber, logger)
794-
logger.info('Final collection succeeded after cancel')
795-
} catch (err) {
796-
const errorMsg = err instanceof Error ? err.message : String(err)
797-
logger.error('Final collection after cancel failed, fees may be lost', {
798-
deployment: agreement.subgraphDeploymentId,
799-
error: errorMsg,
800-
})
801-
}
802-
803800
// Step 3: Cleanup
804801
this.collectionTracker.remove(agreementId)
805802

0 commit comments

Comments
 (0)