Skip to content

Commit b4c94d8

Browse files
MoonBoi9001claude
andcommitted
feat: track state-change block and canceler on IndexingAgreement
Consumers that reconcile agreement state against the on-chain truth (dipper's chain_listener, primarily) need two things the aggregated IndexingAgreement entity does not currently expose: 1. A block-indexed marker of the most recent state change, so a poll can fetch only agreements that moved since the last seen block. Adds `lastStateChangeBlock: BigInt!` to the entity and stamps it with `event.block.number` on every handler that transitions state: AgreementAccepted / Canceled / Updated / RCACollected in recurringCollector.ts, and IndexingAgreementAccepted / Canceled / Updated in subgraphService.ts. Distinct from the existing `lastUpdatedAt` timestamp which is only written on AgreementUpdated. 2. The address that initiated a cancel, preserved as data rather than derived from the enum. Adds `canceledBy: Bytes!` to the entity and a new `handleIndexingAgreementCanceled` on the SubgraphService data source that reads `canceledOnBehalfOf` from the event. That captures operator-initiated cancels correctly -- the RecurringCollector's AgreementCanceled event only carries an enum (ServiceProvider or Payer) and the real signer is lost. Matchstick tests cover the new field stamping and the new cancel handler; existing tests gain `lastStateChangeBlock` assertions. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent eb408d4 commit b4c94d8

6 files changed

Lines changed: 102 additions & 1 deletion

File tree

schema.graphql

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,12 @@ type IndexingAgreement @entity(immutable: false) {
4242
lastUpdatedAt: BigInt!
4343
"Timestamp when agreement was canceled (0 if not canceled)"
4444
canceledAt: BigInt!
45+
"Address that initiated the cancel (zero address if not canceled). Taken from SubgraphService.IndexingAgreementCanceled.canceledOnBehalfOf so operator-initiated cancels are captured correctly."
46+
canceledBy: Bytes!
4547
"Total tokens collected over lifetime"
4648
tokensCollected: BigInt!
49+
"Block number of the latest state change on this agreement (Accepted / Updated / Canceled / RCACollected). Consumers that reconcile state diffs poll with `lastStateChangeBlock_gt` since last seen block."
50+
lastStateChangeBlock: BigInt!
4751
"Fee collection history"
4852
collections: [IndexingFeeCollection!]! @derivedFrom(field: "agreement")
4953
}

src/helpers.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,9 @@ export function createOrLoadIndexingAgreement(agreementId: Bytes): IndexingAgree
2323
agreement.maxSecondsPerCollection = 0
2424
agreement.lastUpdatedAt = BIGINT_ZERO
2525
agreement.canceledAt = BIGINT_ZERO
26+
agreement.canceledBy = Bytes.empty()
2627
agreement.tokensCollected = BIGINT_ZERO
28+
agreement.lastStateChangeBlock = BIGINT_ZERO
2729
}
2830
return agreement
2931
}

src/recurringCollector.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export function handleAgreementAccepted(event: AgreementAccepted): void {
2323
agreement.maxSecondsPerCollection = event.params.maxSecondsPerCollection.toI32()
2424
agreement.canceledAt = BIGINT_ZERO
2525
agreement.tokensCollected = BIGINT_ZERO
26+
agreement.lastStateChangeBlock = event.block.number
2627

2728
agreement.save()
2829
}
@@ -31,13 +32,17 @@ export function handleAgreementCanceled(event: AgreementCanceled): void {
3132
let agreement = IndexingAgreement.load(event.params.agreementId)
3233
if (agreement == null) return
3334

34-
// canceledBy enum: 0=ServiceProvider, 1=Payer
35+
// canceledBy enum: 0=ServiceProvider, 1=Payer. The actual canceler address
36+
// is written by subgraphService.handleIndexingAgreementCanceled, which
37+
// fires in the same transaction and reads the SubgraphService event's
38+
// canceledOnBehalfOf parameter.
3539
if (event.params.canceledBy == 0) {
3640
agreement.state = 'CanceledByServiceProvider'
3741
} else {
3842
agreement.state = 'CanceledByPayer'
3943
}
4044
agreement.canceledAt = event.params.canceledAt
45+
agreement.lastStateChangeBlock = event.block.number
4146
agreement.save()
4247
}
4348

@@ -51,6 +56,7 @@ export function handleAgreementUpdated(event: AgreementUpdated): void {
5156
agreement.maxOngoingTokensPerSecond = event.params.maxOngoingTokensPerSecond
5257
agreement.minSecondsPerCollection = event.params.minSecondsPerCollection.toI32()
5358
agreement.maxSecondsPerCollection = event.params.maxSecondsPerCollection.toI32()
59+
agreement.lastStateChangeBlock = event.block.number
5460
agreement.save()
5561
}
5662

@@ -60,6 +66,7 @@ export function handleRCACollected(event: RCACollected): void {
6066

6167
agreement.lastCollectionAt = event.block.timestamp
6268
agreement.tokensCollected = agreement.tokensCollected.plus(event.params.tokens)
69+
agreement.lastStateChangeBlock = event.block.number
6370
agreement.save()
6471
}
6572

src/subgraphService.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { ethereum } from '@graphprotocol/graph-ts'
22
import {
33
IndexingAgreementAccepted as AcceptedEvent,
4+
IndexingAgreementCanceled as CanceledEvent,
45
IndexingAgreementUpdated as UpdatedEvent,
56
IndexingFeesCollectedV1 as FeesCollectedEvent,
67
} from '../generated/SubgraphService/SubgraphService'
@@ -19,6 +20,18 @@ export function handleIndexingAgreementAccepted(event: AcceptedEvent): void {
1920
agreement.tokensPerEntityPerSecond = terms[1].toBigInt()
2021
}
2122

23+
agreement.lastStateChangeBlock = event.block.number
24+
agreement.save()
25+
}
26+
27+
export function handleIndexingAgreementCanceled(event: CanceledEvent): void {
28+
let agreement = createOrLoadIndexingAgreement(event.params.agreementId)
29+
// canceledOnBehalfOf is the actual signer that initiated the cancel. For
30+
// operator-initiated cancels this is the operator, not the payer/indexer
31+
// directly. Dipper's chain_listener compares this to its own signer
32+
// address to decide CanceledByRequester vs CanceledByIndexer.
33+
agreement.canceledBy = event.params.canceledOnBehalfOf
34+
agreement.lastStateChangeBlock = event.block.number
2235
agreement.save()
2336
}
2437

@@ -33,6 +46,7 @@ export function handleIndexingAgreementUpdated(event: UpdatedEvent): void {
3346
agreement.tokensPerEntityPerSecond = terms[1].toBigInt()
3447
}
3548

49+
agreement.lastStateChangeBlock = event.block.number
3650
agreement.save()
3751
}
3852

subgraph.template.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ dataSources:
2525
eventHandlers:
2626
- event: IndexingAgreementAccepted(indexed address,indexed address,indexed bytes16,address,bytes32,uint8,bytes)
2727
handler: handleIndexingAgreementAccepted
28+
- event: IndexingAgreementCanceled(indexed address,indexed address,indexed bytes16,address)
29+
handler: handleIndexingAgreementCanceled
2830
- event: IndexingAgreementUpdated(indexed address,indexed address,indexed bytes16,address,uint8,bytes)
2931
handler: handleIndexingAgreementUpdated
3032
- event: IndexingFeesCollectedV1(indexed address,indexed address,indexed bytes16,address,bytes32,uint256,uint256,uint256,bytes32,uint256,bytes)

tests/subgraphService.test.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,13 @@ import { assert, describe, test, clearStore, afterEach } from 'matchstick-as'
22
import { Address, Bytes, BigInt, ethereum } from '@graphprotocol/graph-ts'
33
import {
44
handleIndexingAgreementAccepted,
5+
handleIndexingAgreementCanceled,
56
handleIndexingAgreementUpdated,
67
handleIndexingFeesCollectedV1,
78
} from '../src/subgraphService'
89
import {
910
IndexingAgreementAccepted as AcceptedEvent,
11+
IndexingAgreementCanceled as CanceledEvent,
1012
IndexingAgreementUpdated as UpdatedEvent,
1113
IndexingFeesCollectedV1 as FeesCollectedEvent,
1214
} from '../generated/SubgraphService/SubgraphService'
@@ -46,6 +48,30 @@ function createAcceptedEvent(
4648
return event
4749
}
4850

51+
function createCanceledEvent(
52+
indexer: Address,
53+
payer: Address,
54+
agreementId: Bytes,
55+
canceledOnBehalfOf: Address,
56+
): CanceledEvent {
57+
let event = changetype<CanceledEvent>(newMockEvent())
58+
59+
event.parameters = new Array()
60+
event.parameters.push(new ethereum.EventParam('indexer', ethereum.Value.fromAddress(indexer)))
61+
event.parameters.push(new ethereum.EventParam('payer', ethereum.Value.fromAddress(payer)))
62+
event.parameters.push(
63+
new ethereum.EventParam('agreementId', ethereum.Value.fromFixedBytes(agreementId)),
64+
)
65+
event.parameters.push(
66+
new ethereum.EventParam(
67+
'canceledOnBehalfOf',
68+
ethereum.Value.fromAddress(canceledOnBehalfOf),
69+
),
70+
)
71+
72+
return event
73+
}
74+
4975
function createUpdatedEvent(
5076
indexer: Address,
5177
payer: Address,
@@ -153,6 +179,7 @@ describe('handleIndexingAgreementAccepted', () => {
153179
1,
154180
versionTerms,
155181
)
182+
event.block.number = BigInt.fromI32(100)
156183
handleIndexingAgreementAccepted(event)
157184

158185
assert.entityCount('IndexingAgreement', 1)
@@ -175,11 +202,49 @@ describe('handleIndexingAgreementAccepted', () => {
175202
'tokensPerEntityPerSecond',
176203
'50',
177204
)
205+
assert.fieldEquals(
206+
'IndexingAgreement',
207+
agreementId.toHexString(),
208+
'lastStateChangeBlock',
209+
'100',
210+
)
178211
// State remains NotAccepted until RC handler fires
179212
assert.fieldEquals('IndexingAgreement', agreementId.toHexString(), 'state', 'NotAccepted')
180213
})
181214
})
182215

216+
describe('handleIndexingAgreementCanceled', () => {
217+
afterEach(() => {
218+
clearStore()
219+
})
220+
221+
test('sets canceledBy to canceledOnBehalfOf and stamps lastStateChangeBlock', () => {
222+
let indexer = Address.fromString('0x0000000000000000000000000000000000000001')
223+
let payer = Address.fromString('0x0000000000000000000000000000000000000002')
224+
let agreementId = Bytes.fromHexString('0x0102030405060708090a0b0c0d0e0f10')
225+
// Operator address, distinct from payer/indexer, to prove the handler
226+
// captures whoever actually initiated the cancel rather than inferring it.
227+
let operator = Address.fromString('0x000000000000000000000000000000000000000a')
228+
229+
let event = createCanceledEvent(indexer, payer, agreementId, operator)
230+
event.block.number = BigInt.fromI32(200)
231+
handleIndexingAgreementCanceled(event)
232+
233+
assert.fieldEquals(
234+
'IndexingAgreement',
235+
agreementId.toHexString(),
236+
'canceledBy',
237+
operator.toHexString(),
238+
)
239+
assert.fieldEquals(
240+
'IndexingAgreement',
241+
agreementId.toHexString(),
242+
'lastStateChangeBlock',
243+
'200',
244+
)
245+
})
246+
})
247+
183248
describe('handleIndexingAgreementUpdated', () => {
184249
afterEach(() => {
185250
clearStore()
@@ -216,6 +281,7 @@ describe('handleIndexingAgreementUpdated', () => {
216281
1,
217282
newVersionTerms,
218283
)
284+
updateEvent.block.number = BigInt.fromI32(300)
219285
handleIndexingAgreementUpdated(updateEvent)
220286

221287
assert.entityCount('IndexingAgreement', 1)
@@ -232,6 +298,12 @@ describe('handleIndexingAgreementUpdated', () => {
232298
'tokensPerEntityPerSecond',
233299
'100',
234300
)
301+
assert.fieldEquals(
302+
'IndexingAgreement',
303+
agreementId.toHexString(),
304+
'lastStateChangeBlock',
305+
'300',
306+
)
235307
})
236308
})
237309

0 commit comments

Comments
 (0)