diff --git a/abis/RecurringCollector.json b/abis/RecurringCollector.json index 702cba3..4dafa1e 100644 --- a/abis/RecurringCollector.json +++ b/abis/RecurringCollector.json @@ -6,7 +6,6 @@ { "indexed": true, "name": "payer", "type": "address" }, { "indexed": true, "name": "serviceProvider", "type": "address" }, { "indexed": false, "name": "agreementId", "type": "bytes16" }, - { "indexed": false, "name": "acceptedAt", "type": "uint64" }, { "indexed": false, "name": "endsAt", "type": "uint64" }, { "indexed": false, "name": "maxInitialTokens", "type": "uint256" }, { "indexed": false, "name": "maxOngoingTokensPerSecond", "type": "uint256" }, @@ -23,7 +22,6 @@ { "indexed": true, "name": "payer", "type": "address" }, { "indexed": true, "name": "serviceProvider", "type": "address" }, { "indexed": false, "name": "agreementId", "type": "bytes16" }, - { "indexed": false, "name": "canceledAt", "type": "uint64" }, { "indexed": false, "name": "canceledBy", "type": "uint8" } ], "name": "AgreementCanceled", @@ -36,7 +34,6 @@ { "indexed": true, "name": "payer", "type": "address" }, { "indexed": true, "name": "serviceProvider", "type": "address" }, { "indexed": false, "name": "agreementId", "type": "bytes16" }, - { "indexed": false, "name": "updatedAt", "type": "uint64" }, { "indexed": false, "name": "endsAt", "type": "uint64" }, { "indexed": false, "name": "maxInitialTokens", "type": "uint256" }, { "indexed": false, "name": "maxOngoingTokensPerSecond", "type": "uint256" }, @@ -59,5 +56,26 @@ ], "name": "RCACollected", "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { "indexed": true, "name": "agreementId", "type": "bytes16" }, + { "indexed": true, "name": "payer", "type": "address" }, + { "indexed": true, "name": "offerType", "type": "uint8" }, + { "indexed": false, "name": "offerHash", "type": "bytes32" } + ], + "name": "OfferStored", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { "indexed": true, "name": "caller", "type": "address" }, + { "indexed": true, "name": "agreementId", "type": "bytes16" }, + { "indexed": true, "name": "hash", "type": "bytes32" } + ], + "name": "OfferCancelled", + "type": "event" } ] diff --git a/schema.graphql b/schema.graphql index 2e88a21..3a17c03 100644 --- a/schema.graphql +++ b/schema.graphql @@ -42,8 +42,12 @@ type IndexingAgreement @entity(immutable: false) { lastUpdatedAt: BigInt! "Timestamp when agreement was canceled (0 if not canceled)" canceledAt: BigInt! + "Address that initiated the cancel (zero address if not canceled). Taken from SubgraphService.IndexingAgreementCanceled.canceledOnBehalfOf so operator-initiated cancels are captured correctly." + canceledBy: Bytes! "Total tokens collected over lifetime" tokensCollected: BigInt! + "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." + lastStateChangeBlock: BigInt! "Fee collection history" collections: [IndexingFeeCollection!]! @derivedFrom(field: "agreement") } @@ -71,3 +75,28 @@ type IndexerDeploymentLatest @entity(immutable: false) { blockNumber: BigInt! blockTimestamp: BigInt! } + +# Latest stored offer per agreementId, keyed by bytes16 agreement ID. +# Dipper queries this entity as an idempotency gate -- avoids re-submitting +# an offer after a crashed-mid-flight restart where the on-chain tx landed +# but dipper lost track of it. +# +# Mutable because the contract overwrites the stored offer in two cases: +# (1) an OFFER_TYPE_UPDATE event refreshes the hash and type to the latest +# RCAU terms; (2) an OfferCancelled event stamps `canceledAt` so dipper +# treats `canceledAt > 0` as "no live offer; safe to re-submit." Storage +# stays bounded because there is at most one Offer entity per agreementId. +type Offer @entity(immutable: false) { + id: Bytes! + payer: Bytes! + "Latest offer type observed (1 = OFFER_TYPE_NEW, 2 = OFFER_TYPE_UPDATE)." + offerType: Int! + "Latest stored offer hash; tracks the on-chain rcaOffers / rcauOffers entry." + offerHash: Bytes! + "Timestamp when the offer was canceled on-chain (0 if currently live)." + canceledAt: BigInt! + "Block/timestamp/tx of the first OFFER_TYPE_NEW for this agreement id." + createdAtBlock: BigInt! + createdAtTimestamp: BigInt! + createdAtTx: Bytes! +} diff --git a/src/helpers.ts b/src/helpers.ts index 4f9cf23..dbec73c 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -1,4 +1,4 @@ -import { BigInt, Bytes } from '@graphprotocol/graph-ts' +import { Address, BigInt, Bytes } from '@graphprotocol/graph-ts' import { IndexingAgreement } from '../generated/schema' export const BIGINT_ZERO = BigInt.fromI32(0) @@ -23,7 +23,13 @@ export function createOrLoadIndexingAgreement(agreementId: Bytes): IndexingAgree agreement.maxSecondsPerCollection = 0 agreement.lastUpdatedAt = BIGINT_ZERO agreement.canceledAt = BIGINT_ZERO + // Default to 20-byte zero address rather than Bytes.empty(). Graph-node + // serializes empty Bytes on non-nullable fields with unpredictable + // padding (observed as "0x00000000" in practice), which breaks strict + // 20-byte-address parsers on the consumer side. + agreement.canceledBy = Address.zero() as Bytes agreement.tokensCollected = BIGINT_ZERO + agreement.lastStateChangeBlock = BIGINT_ZERO } return agreement } diff --git a/src/recurringCollector.ts b/src/recurringCollector.ts index 32348d2..88ab0ec 100644 --- a/src/recurringCollector.ts +++ b/src/recurringCollector.ts @@ -1,20 +1,33 @@ -import { IndexingAgreement } from '../generated/schema' +import { IndexingAgreement, Offer } from '../generated/schema' import { AgreementAccepted, AgreementCanceled, AgreementUpdated, RCACollected, + OfferStored as OfferStoredEvent, + OfferCancelled as OfferCancelledEvent, } from '../generated/RecurringCollector/RecurringCollector' import { createOrLoadIndexingAgreement, BIGINT_ZERO } from './helpers' +// CancelAgreementBy enum from IRecurringCollector.sol: +// 0 = ServiceProvider, 1 = Payer, 2 = ThirdParty +// The contract treats anything that isn't Payer as ServiceProvider when +// emitting the SubgraphService-side IndexingAgreementCanceled event, so we +// mirror that mapping here. ThirdParty (2) is currently unreachable from +// SubgraphService — adding the explicit branch documents the contract's +// intent and keeps the mapping correct if a future data service surfaces it. +const CANCEL_BY_PAYER: i32 = 1 + export function handleAgreementAccepted(event: AgreementAccepted): void { let agreement = createOrLoadIndexingAgreement(event.params.agreementId) + // The contract sets `agreement.acceptedAt = uint64(block.timestamp)` inside + // accept(), so the event's block timestamp is the canonical value. agreement.payer = event.params.payer agreement.indexer = event.params.serviceProvider agreement.state = 'Accepted' - agreement.acceptedAt = event.params.acceptedAt - agreement.lastCollectionAt = event.params.acceptedAt + agreement.acceptedAt = event.block.timestamp + agreement.lastCollectionAt = event.block.timestamp agreement.endsAt = event.params.endsAt agreement.maxInitialTokens = event.params.maxInitialTokens agreement.maxOngoingTokensPerSecond = event.params.maxOngoingTokensPerSecond @@ -22,6 +35,7 @@ export function handleAgreementAccepted(event: AgreementAccepted): void { agreement.maxSecondsPerCollection = event.params.maxSecondsPerCollection.toI32() agreement.canceledAt = BIGINT_ZERO agreement.tokensCollected = BIGINT_ZERO + agreement.lastStateChangeBlock = event.block.number agreement.save() } @@ -30,13 +44,19 @@ export function handleAgreementCanceled(event: AgreementCanceled): void { let agreement = IndexingAgreement.load(event.params.agreementId) if (agreement == null) return - // canceledBy enum: 0=ServiceProvider, 1=Payer - if (event.params.canceledBy == 0) { - agreement.state = 'CanceledByServiceProvider' - } else { + // The actual canceler address is written by + // subgraphService.handleIndexingAgreementCanceled, which fires in the + // same transaction and reads the SubgraphService event's + // canceledOnBehalfOf parameter. The contract sets + // `agreement.canceledAt = uint64(block.timestamp)` inside cancel(), so + // the event's block timestamp is the canonical value. + if (event.params.canceledBy == CANCEL_BY_PAYER) { agreement.state = 'CanceledByPayer' + } else { + agreement.state = 'CanceledByServiceProvider' } - agreement.canceledAt = event.params.canceledAt + agreement.canceledAt = event.block.timestamp + agreement.lastStateChangeBlock = event.block.number agreement.save() } @@ -44,12 +64,13 @@ export function handleAgreementUpdated(event: AgreementUpdated): void { let agreement = IndexingAgreement.load(event.params.agreementId) if (agreement == null) return - agreement.lastUpdatedAt = event.params.updatedAt + agreement.lastUpdatedAt = event.block.timestamp agreement.endsAt = event.params.endsAt agreement.maxInitialTokens = event.params.maxInitialTokens agreement.maxOngoingTokensPerSecond = event.params.maxOngoingTokensPerSecond agreement.minSecondsPerCollection = event.params.minSecondsPerCollection.toI32() agreement.maxSecondsPerCollection = event.params.maxSecondsPerCollection.toI32() + agreement.lastStateChangeBlock = event.block.number agreement.save() } @@ -59,5 +80,40 @@ export function handleRCACollected(event: RCACollected): void { agreement.lastCollectionAt = event.block.timestamp agreement.tokensCollected = agreement.tokensCollected.plus(event.params.tokens) + agreement.lastStateChangeBlock = event.block.number agreement.save() } + +export function handleOfferStored(event: OfferStoredEvent): void { + // OfferStored fires once per agreementId for OFFER_TYPE_NEW and again + // for each OFFER_TYPE_UPDATE that changes the stored offer hash. The + // contract overwrites $.rcaOffers / $.rcauOffers in-place, so dipper's + // idempotency gate has to see the latest terms — keep the entity + // mutable and refresh offerType / offerHash on every event. `createdAt` + // fields stay pinned to the first OFFER_TYPE_NEW so consumers can + // distinguish initial offer from subsequent updates. + let offer = Offer.load(event.params.agreementId) + if (offer == null) { + offer = new Offer(event.params.agreementId) + offer.createdAtBlock = event.block.number + offer.createdAtTimestamp = event.block.timestamp + offer.createdAtTx = event.transaction.hash + } + offer.payer = event.params.payer + offer.offerType = event.params.offerType + offer.offerHash = event.params.offerHash + offer.canceledAt = BIGINT_ZERO + offer.save() +} + +export function handleOfferCancelled(event: OfferCancelledEvent): void { + // OfferCancelled fires when a payer (or any signer at SCOPE_SIGNED) + // cancels a stored RCA/RCAU offer. The contract deletes the on-chain + // entry, so dipper's idempotency gate must treat the Offer as no longer + // live. Set canceledAt to the event's block timestamp; consumers query + // `canceledAt > 0` to decide "safe to re-submit". + let offer = Offer.load(event.params.agreementId) + if (offer == null) return + offer.canceledAt = event.block.timestamp + offer.save() +} diff --git a/src/subgraphService.ts b/src/subgraphService.ts index b1a00b9..6f550af 100644 --- a/src/subgraphService.ts +++ b/src/subgraphService.ts @@ -1,6 +1,7 @@ import { ethereum } from '@graphprotocol/graph-ts' import { IndexingAgreementAccepted as AcceptedEvent, + IndexingAgreementCanceled as CanceledEvent, IndexingAgreementUpdated as UpdatedEvent, IndexingFeesCollectedV1 as FeesCollectedEvent, } from '../generated/SubgraphService/SubgraphService' @@ -19,6 +20,18 @@ export function handleIndexingAgreementAccepted(event: AcceptedEvent): void { agreement.tokensPerEntityPerSecond = terms[1].toBigInt() } + agreement.lastStateChangeBlock = event.block.number + agreement.save() +} + +export function handleIndexingAgreementCanceled(event: CanceledEvent): void { + let agreement = createOrLoadIndexingAgreement(event.params.agreementId) + // canceledOnBehalfOf is the actual signer that initiated the cancel. For + // operator-initiated cancels this is the operator, not the payer/indexer + // directly. Dipper's chain_listener compares this to its own signer + // address to decide CanceledByRequester vs CanceledByIndexer. + agreement.canceledBy = event.params.canceledOnBehalfOf + agreement.lastStateChangeBlock = event.block.number agreement.save() } @@ -33,6 +46,7 @@ export function handleIndexingAgreementUpdated(event: UpdatedEvent): void { agreement.tokensPerEntityPerSecond = terms[1].toBigInt() } + agreement.lastStateChangeBlock = event.block.number agreement.save() } diff --git a/subgraph.template.yaml b/subgraph.template.yaml index 1d7f146..272473e 100644 --- a/subgraph.template.yaml +++ b/subgraph.template.yaml @@ -25,6 +25,8 @@ dataSources: eventHandlers: - event: IndexingAgreementAccepted(indexed address,indexed address,indexed bytes16,address,bytes32,uint8,bytes) handler: handleIndexingAgreementAccepted + - event: IndexingAgreementCanceled(indexed address,indexed address,indexed bytes16,address) + handler: handleIndexingAgreementCanceled - event: IndexingAgreementUpdated(indexed address,indexed address,indexed bytes16,address,uint8,bytes) handler: handleIndexingAgreementUpdated - event: IndexingFeesCollectedV1(indexed address,indexed address,indexed bytes16,address,bytes32,uint256,uint256,uint256,bytes32,uint256,bytes) @@ -43,20 +45,25 @@ dataSources: language: wasm/assemblyscript entities: - IndexingAgreement + - Offer abis: - name: RecurringCollector file: ./abis/RecurringCollector.json eventHandlers: - - event: AgreementAccepted(indexed address,indexed address,indexed address,bytes16,uint64,uint64,uint256,uint256,uint32,uint32) + - event: AgreementAccepted(indexed address,indexed address,indexed address,bytes16,uint64,uint256,uint256,uint32,uint32) handler: handleAgreementAccepted topic1: ["{{subgraphServiceAddress}}"] - - event: AgreementCanceled(indexed address,indexed address,indexed address,bytes16,uint64,uint8) + - event: AgreementCanceled(indexed address,indexed address,indexed address,bytes16,uint8) handler: handleAgreementCanceled topic1: ["{{subgraphServiceAddress}}"] - - event: AgreementUpdated(indexed address,indexed address,indexed address,bytes16,uint64,uint64,uint256,uint256,uint32,uint32) + - event: AgreementUpdated(indexed address,indexed address,indexed address,bytes16,uint64,uint256,uint256,uint32,uint32) handler: handleAgreementUpdated topic1: ["{{subgraphServiceAddress}}"] - event: RCACollected(indexed address,indexed address,indexed address,bytes16,bytes32,uint256,uint256) handler: handleRCACollected topic1: ["{{subgraphServiceAddress}}"] + - event: OfferStored(indexed bytes16,indexed address,indexed uint8,bytes32) + handler: handleOfferStored + - event: OfferCancelled(indexed address,indexed bytes16,indexed bytes32) + handler: handleOfferCancelled file: ./src/recurringCollector.ts diff --git a/tests/recurringCollector.test.ts b/tests/recurringCollector.test.ts new file mode 100644 index 0000000..23faf52 --- /dev/null +++ b/tests/recurringCollector.test.ts @@ -0,0 +1,125 @@ +import { assert, describe, test, clearStore, afterEach, newMockEvent } from 'matchstick-as' +import { Address, Bytes, BigInt, ethereum } from '@graphprotocol/graph-ts' +import { handleOfferStored, handleOfferCancelled } from '../src/recurringCollector' +import { + OfferStored as OfferStoredEvent, + OfferCancelled as OfferCancelledEvent, +} from '../generated/RecurringCollector/RecurringCollector' + +const PAYER = Address.fromString('0x0000000000000000000000000000000000000002') +const CALLER = Address.fromString('0x0000000000000000000000000000000000000003') +const AGREEMENT_ID = Bytes.fromHexString('0x0102030405060708090a0b0c0d0e0f10') + +// OFFER_TYPE_NEW from IAgreementCollector.sol after the audit reshuffle +// (NONE=0, NEW=1, UPDATE=2). Tests construct events with the live value so +// the stored entity matches what production indexers would see. +const OFFER_TYPE_NEW: i32 = 1 +const OFFER_TYPE_UPDATE: i32 = 2 + +function createOfferStoredEvent( + agreementId: Bytes, + offerType: i32, + offerHash: Bytes, +): OfferStoredEvent { + let event = changetype(newMockEvent()) + + event.parameters = new Array() + event.parameters.push( + new ethereum.EventParam('agreementId', ethereum.Value.fromFixedBytes(agreementId)), + ) + event.parameters.push(new ethereum.EventParam('payer', ethereum.Value.fromAddress(PAYER))) + event.parameters.push( + new ethereum.EventParam( + 'offerType', + ethereum.Value.fromUnsignedBigInt(BigInt.fromI32(offerType)), + ), + ) + event.parameters.push( + new ethereum.EventParam('offerHash', ethereum.Value.fromFixedBytes(offerHash)), + ) + + return event +} + +function createOfferCancelledEvent( + caller: Address, + agreementId: Bytes, + hash: Bytes, +): OfferCancelledEvent { + let event = changetype(newMockEvent()) + + event.parameters = new Array() + event.parameters.push(new ethereum.EventParam('caller', ethereum.Value.fromAddress(caller))) + event.parameters.push( + new ethereum.EventParam('agreementId', ethereum.Value.fromFixedBytes(agreementId)), + ) + event.parameters.push(new ethereum.EventParam('hash', ethereum.Value.fromFixedBytes(hash))) + + return event +} + +describe('handleOfferStored', () => { + afterEach(() => { + clearStore() + }) + + test('first event creates Offer entity', () => { + let offerHash = Bytes.fromHexString('0x' + 'aa'.repeat(32)) + let event = createOfferStoredEvent(AGREEMENT_ID, OFFER_TYPE_NEW, offerHash) + handleOfferStored(event) + + assert.entityCount('Offer', 1) + + let id = AGREEMENT_ID.toHexString() + assert.fieldEquals('Offer', id, 'payer', PAYER.toHexString()) + assert.fieldEquals('Offer', id, 'offerType', OFFER_TYPE_NEW.toString()) + assert.fieldEquals('Offer', id, 'offerHash', offerHash.toHexString()) + assert.fieldEquals('Offer', id, 'canceledAt', '0') + }) + + test('OFFER_TYPE_UPDATE overwrites offerType and offerHash; createdAt stays pinned', () => { + let initialHash = Bytes.fromHexString('0x' + 'aa'.repeat(32)) + let updatedHash = Bytes.fromHexString('0x' + 'bb'.repeat(32)) + let id = AGREEMENT_ID.toHexString() + + let initial = createOfferStoredEvent(AGREEMENT_ID, OFFER_TYPE_NEW, initialHash) + initial.block.number = BigInt.fromI32(100) + initial.block.timestamp = BigInt.fromI32(1000) + handleOfferStored(initial) + assert.fieldEquals('Offer', id, 'offerType', OFFER_TYPE_NEW.toString()) + assert.fieldEquals('Offer', id, 'offerHash', initialHash.toHexString()) + assert.fieldEquals('Offer', id, 'createdAtBlock', '100') + assert.fieldEquals('Offer', id, 'createdAtTimestamp', '1000') + + let update = createOfferStoredEvent(AGREEMENT_ID, OFFER_TYPE_UPDATE, updatedHash) + update.block.number = BigInt.fromI32(200) + update.block.timestamp = BigInt.fromI32(2000) + handleOfferStored(update) + + assert.entityCount('Offer', 1) + // Latest terms reflect the UPDATE event... + assert.fieldEquals('Offer', id, 'offerType', OFFER_TYPE_UPDATE.toString()) + assert.fieldEquals('Offer', id, 'offerHash', updatedHash.toHexString()) + // ...but createdAt stays pinned to the initial OFFER_TYPE_NEW. + assert.fieldEquals('Offer', id, 'createdAtBlock', '100') + assert.fieldEquals('Offer', id, 'createdAtTimestamp', '1000') + }) + + test('OfferCancelled stamps canceledAt; OfferStored after that clears it', () => { + let offerHash = Bytes.fromHexString('0x' + 'aa'.repeat(32)) + let id = AGREEMENT_ID.toHexString() + + handleOfferStored(createOfferStoredEvent(AGREEMENT_ID, OFFER_TYPE_NEW, offerHash)) + assert.fieldEquals('Offer', id, 'canceledAt', '0') + + let cancelEvent = createOfferCancelledEvent(CALLER, AGREEMENT_ID, offerHash) + cancelEvent.block.timestamp = BigInt.fromI32(12345) + handleOfferCancelled(cancelEvent) + assert.fieldEquals('Offer', id, 'canceledAt', '12345') + + // A fresh OfferStored for the same agreement id should reset canceledAt + // so the idempotency gate sees the entity as live again. + handleOfferStored(createOfferStoredEvent(AGREEMENT_ID, OFFER_TYPE_NEW, offerHash)) + assert.fieldEquals('Offer', id, 'canceledAt', '0') + }) +}) diff --git a/tests/subgraphService.test.ts b/tests/subgraphService.test.ts index d43b67f..aa9e5f5 100644 --- a/tests/subgraphService.test.ts +++ b/tests/subgraphService.test.ts @@ -2,11 +2,13 @@ import { assert, describe, test, clearStore, afterEach } from 'matchstick-as' import { Address, Bytes, BigInt, ethereum } from '@graphprotocol/graph-ts' import { handleIndexingAgreementAccepted, + handleIndexingAgreementCanceled, handleIndexingAgreementUpdated, handleIndexingFeesCollectedV1, } from '../src/subgraphService' import { IndexingAgreementAccepted as AcceptedEvent, + IndexingAgreementCanceled as CanceledEvent, IndexingAgreementUpdated as UpdatedEvent, IndexingFeesCollectedV1 as FeesCollectedEvent, } from '../generated/SubgraphService/SubgraphService' @@ -46,6 +48,27 @@ function createAcceptedEvent( return event } +function createCanceledEvent( + indexer: Address, + payer: Address, + agreementId: Bytes, + canceledOnBehalfOf: Address, +): CanceledEvent { + let event = changetype(newMockEvent()) + + event.parameters = new Array() + event.parameters.push(new ethereum.EventParam('indexer', ethereum.Value.fromAddress(indexer))) + event.parameters.push(new ethereum.EventParam('payer', ethereum.Value.fromAddress(payer))) + event.parameters.push( + new ethereum.EventParam('agreementId', ethereum.Value.fromFixedBytes(agreementId)), + ) + event.parameters.push( + new ethereum.EventParam('canceledOnBehalfOf', ethereum.Value.fromAddress(canceledOnBehalfOf)), + ) + + return event +} + function createUpdatedEvent( indexer: Address, payer: Address, @@ -153,6 +176,7 @@ describe('handleIndexingAgreementAccepted', () => { 1, versionTerms, ) + event.block.number = BigInt.fromI32(100) handleIndexingAgreementAccepted(event) assert.entityCount('IndexingAgreement', 1) @@ -175,11 +199,49 @@ describe('handleIndexingAgreementAccepted', () => { 'tokensPerEntityPerSecond', '50', ) + assert.fieldEquals( + 'IndexingAgreement', + agreementId.toHexString(), + 'lastStateChangeBlock', + '100', + ) // State remains NotAccepted until RC handler fires assert.fieldEquals('IndexingAgreement', agreementId.toHexString(), 'state', 'NotAccepted') }) }) +describe('handleIndexingAgreementCanceled', () => { + afterEach(() => { + clearStore() + }) + + test('sets canceledBy to canceledOnBehalfOf and stamps lastStateChangeBlock', () => { + let indexer = Address.fromString('0x0000000000000000000000000000000000000001') + let payer = Address.fromString('0x0000000000000000000000000000000000000002') + let agreementId = Bytes.fromHexString('0x0102030405060708090a0b0c0d0e0f10') + // Operator address, distinct from payer/indexer, to prove the handler + // captures whoever actually initiated the cancel rather than inferring it. + let operator = Address.fromString('0x000000000000000000000000000000000000000a') + + let event = createCanceledEvent(indexer, payer, agreementId, operator) + event.block.number = BigInt.fromI32(200) + handleIndexingAgreementCanceled(event) + + assert.fieldEquals( + 'IndexingAgreement', + agreementId.toHexString(), + 'canceledBy', + operator.toHexString(), + ) + assert.fieldEquals( + 'IndexingAgreement', + agreementId.toHexString(), + 'lastStateChangeBlock', + '200', + ) + }) +}) + describe('handleIndexingAgreementUpdated', () => { afterEach(() => { clearStore() @@ -216,6 +278,7 @@ describe('handleIndexingAgreementUpdated', () => { 1, newVersionTerms, ) + updateEvent.block.number = BigInt.fromI32(300) handleIndexingAgreementUpdated(updateEvent) assert.entityCount('IndexingAgreement', 1) @@ -232,6 +295,12 @@ describe('handleIndexingAgreementUpdated', () => { 'tokensPerEntityPerSecond', '100', ) + assert.fieldEquals( + 'IndexingAgreement', + agreementId.toHexString(), + 'lastStateChangeBlock', + '300', + ) }) })