From d949854f4cc49854ee5901f66ea4af9e7632f213 Mon Sep 17 00:00:00 2001 From: MoonBoi9001 Date: Wed, 13 May 2026 13:00:58 +0800 Subject: [PATCH] feat(schema): index on-chain tx hashes for agreement events IndexingAgreement and IndexingFeeCollection entities don't store the on-chain tx hash for state changes, so consumers had to grep agent logs to recover what the mapping already had in hand. Add acceptedAtTx, canceledAtTx, and per-collection transactionHash. Co-Authored-By: Claude Opus 4.7 (1M context) --- schema.graphql | 6 ++++++ src/helpers.ts | 7 +++++++ src/subgraphService.ts | 3 +++ tests/subgraphService.test.ts | 29 +++++++++++++++++++++++++++++ 4 files changed, 45 insertions(+) diff --git a/schema.graphql b/schema.graphql index 3a17c03..86ac5ec 100644 --- a/schema.graphql +++ b/schema.graphql @@ -22,6 +22,8 @@ type IndexingAgreement @entity(immutable: false) { state: AgreementState! "Timestamp when the agreement was accepted" acceptedAt: BigInt! + "Transaction hash of the SubgraphService.acceptIndexingAgreement call. Auditable link from the indexed agreement back to the on-chain accept (typically a multicall(startService+acceptIndexingAgreement)). 32-byte zero sentinel if never accepted (entity created from a stray Updated/Canceled event)." + acceptedAtTx: Bytes! "Timestamp of last collection" lastCollectionAt: BigInt! "Timestamp when the agreement ends" @@ -42,6 +44,8 @@ type IndexingAgreement @entity(immutable: false) { lastUpdatedAt: BigInt! "Timestamp when agreement was canceled (0 if not canceled)" canceledAt: BigInt! + "Transaction hash of the SubgraphService.cancelIndexingAgreement* call (32-byte zero if not canceled). Mirrors canceledAt/canceledBy for full auditability of the cancel event." + canceledAtTx: Bytes! "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" @@ -62,6 +66,8 @@ type IndexingFeeCollection @entity(immutable: true) { poiBlockNumber: BigInt! blockNumber: BigInt! blockTimestamp: BigInt! + "Transaction hash of the SubgraphService.collect call that emitted this fee collection. Auditable per-payment link." + transactionHash: Bytes! } type IndexerDeploymentLatest @entity(immutable: false) { diff --git a/src/helpers.ts b/src/helpers.ts index dbec73c..55f2647 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -2,6 +2,11 @@ import { Address, BigInt, Bytes } from '@graphprotocol/graph-ts' import { IndexingAgreement } from '../generated/schema' export const BIGINT_ZERO = BigInt.fromI32(0) +// 32-byte zero — same rationale as the canceledBy = Address.zero() pattern +// below. Bytes.empty() serializes with unpredictable padding on non-nullable +// fields; a fixed-length sentinel gives consumers a deterministic value to +// check against when no on-chain tx exists yet. +export const BYTES32_ZERO = Bytes.fromHexString('0x' + '00'.repeat(32)) as Bytes export function createOrLoadIndexingAgreement(agreementId: Bytes): IndexingAgreement { let agreement = IndexingAgreement.load(agreementId) @@ -13,6 +18,7 @@ export function createOrLoadIndexingAgreement(agreementId: Bytes): IndexingAgree agreement.subgraphDeploymentId = Bytes.empty() agreement.state = 'NotAccepted' agreement.acceptedAt = BIGINT_ZERO + agreement.acceptedAtTx = BYTES32_ZERO agreement.lastCollectionAt = BIGINT_ZERO agreement.endsAt = BIGINT_ZERO agreement.maxInitialTokens = BIGINT_ZERO @@ -23,6 +29,7 @@ export function createOrLoadIndexingAgreement(agreementId: Bytes): IndexingAgree agreement.maxSecondsPerCollection = 0 agreement.lastUpdatedAt = BIGINT_ZERO agreement.canceledAt = BIGINT_ZERO + agreement.canceledAtTx = BYTES32_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 diff --git a/src/subgraphService.ts b/src/subgraphService.ts index 6f550af..6d041fb 100644 --- a/src/subgraphService.ts +++ b/src/subgraphService.ts @@ -12,6 +12,7 @@ export function handleIndexingAgreementAccepted(event: AcceptedEvent): void { let agreement = createOrLoadIndexingAgreement(event.params.agreementId) agreement.allocationId = event.params.allocationId agreement.subgraphDeploymentId = event.params.subgraphDeploymentId + agreement.acceptedAtTx = event.transaction.hash let decoded = ethereum.decode('(uint256,uint256)', event.params.versionTerms) if (decoded != null) { @@ -31,6 +32,7 @@ export function handleIndexingAgreementCanceled(event: CanceledEvent): void { // directly. Dipper's chain_listener compares this to its own signer // address to decide CanceledByRequester vs CanceledByIndexer. agreement.canceledBy = event.params.canceledOnBehalfOf + agreement.canceledAtTx = event.transaction.hash agreement.lastStateChangeBlock = event.block.number agreement.save() } @@ -61,6 +63,7 @@ export function handleIndexingFeesCollectedV1(event: FeesCollectedEvent): void { collection.poiBlockNumber = event.params.poiBlockNumber collection.blockNumber = event.block.number collection.blockTimestamp = event.block.timestamp + collection.transactionHash = event.transaction.hash collection.save() let compositeId = diff --git a/tests/subgraphService.test.ts b/tests/subgraphService.test.ts index aa9e5f5..0522c6a 100644 --- a/tests/subgraphService.test.ts +++ b/tests/subgraphService.test.ts @@ -177,6 +177,8 @@ describe('handleIndexingAgreementAccepted', () => { versionTerms, ) event.block.number = BigInt.fromI32(100) + let acceptTxHash = Bytes.fromHexString('0x' + 'aa'.repeat(32)) as Bytes + event.transaction.hash = acceptTxHash handleIndexingAgreementAccepted(event) assert.entityCount('IndexingAgreement', 1) @@ -186,6 +188,12 @@ describe('handleIndexingAgreementAccepted', () => { 'allocationId', allocationId.toHexString(), ) + assert.fieldEquals( + 'IndexingAgreement', + agreementId.toHexString(), + 'acceptedAtTx', + acceptTxHash.toHexString(), + ) assert.fieldEquals( 'IndexingAgreement', agreementId.toHexString(), @@ -225,6 +233,8 @@ describe('handleIndexingAgreementCanceled', () => { let event = createCanceledEvent(indexer, payer, agreementId, operator) event.block.number = BigInt.fromI32(200) + let cancelTxHash = Bytes.fromHexString('0x' + 'bb'.repeat(32)) as Bytes + event.transaction.hash = cancelTxHash handleIndexingAgreementCanceled(event) assert.fieldEquals( @@ -233,6 +243,12 @@ describe('handleIndexingAgreementCanceled', () => { 'canceledBy', operator.toHexString(), ) + assert.fieldEquals( + 'IndexingAgreement', + agreementId.toHexString(), + 'canceledAtTx', + cancelTxHash.toHexString(), + ) assert.fieldEquals( 'IndexingAgreement', agreementId.toHexString(), @@ -335,6 +351,9 @@ describe('handleIndexingFeesCollectedV1', () => { poiBlockNumber, metadata, ) + let collectTxHash = Bytes.fromHexString('0x' + 'cd'.repeat(32)) as Bytes + event.transaction.hash = collectTxHash + event.logIndex = BigInt.fromI32(0) handleIndexingFeesCollectedV1(event) let compositeId = indexer.toHexString() + '-' + subgraphDeploymentId.toHexString() @@ -342,6 +361,16 @@ describe('handleIndexingFeesCollectedV1', () => { assert.fieldEquals('IndexerDeploymentLatest', compositeId, 'entities', '5000') assert.fieldEquals('IndexerDeploymentLatest', compositeId, 'tokensCollected', '1000000') assert.fieldEquals('IndexerDeploymentLatest', compositeId, 'indexer', indexer.toHexString()) + + // IndexingFeeCollection id is event.transaction.hash.concatI32(logIndex). + let collectionId = collectTxHash.concatI32(0).toHexString() + assert.entityCount('IndexingFeeCollection', 1) + assert.fieldEquals( + 'IndexingFeeCollection', + collectionId, + 'transactionHash', + collectTxHash.toHexString(), + ) }) test('second collection updates IndexerDeploymentLatest', () => {