From e09db8f029c4a3f21f021d20d88279c135f83e78 Mon Sep 17 00:00:00 2001 From: MoonBoi9001 Date: Thu, 4 Jun 2026 16:28:52 +1200 Subject: [PATCH 1/6] feat(indexer): expose service URL alongside indexing agreements A consumer holding an agreement's indexer address must cross-query the network subgraph today to resolve it into a service URL. Index that URL from the SubgraphService registration event into a new Indexer entity, linked from each agreement, so it can be read in the same query. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 1 + abis/SubgraphService.json | 9 ++++ config/arbitrum-one.json | 3 +- config/arbitrum-sepolia.json | 3 +- config/hardhat.json | 3 +- schema.graphql | 24 +++++++++ src/helpers.ts | 29 +++++++---- src/recurringCollector.ts | 40 ++++++--------- src/subgraphService.ts | 40 ++++++++++++--- subgraph.template.yaml | 23 +++++++++ tests/recurringCollector.test.ts | 74 ++++++++++++++++++++++++++- tests/subgraphService.test.ts | 88 ++++++++++++++++++++++++++++++++ 12 files changed, 292 insertions(+), 45 deletions(-) diff --git a/README.md b/README.md index f83d965..cd22e3f 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ This subgraph tracks the full lifecycle of [Direct Indexing Payments](https://gi IndexingAgreementCanceledAgreement canceled by payer or indexer IndexingAgreementUpdatedAgreement terms or allocation changed IndexingFeesCollectedV1Fees collected against an active agreement +ServiceProviderRegisteredIndexer registers (or re-registers) its service URL on-chain
diff --git a/abis/SubgraphService.json b/abis/SubgraphService.json index d18c660..79d83c7 100644 --- a/abis/SubgraphService.json +++ b/abis/SubgraphService.json @@ -54,5 +54,14 @@ ], "name": "IndexingFeesCollectedV1", "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { "indexed": true, "internalType": "address", "name": "serviceProvider", "type": "address" }, + { "indexed": false, "internalType": "bytes", "name": "data", "type": "bytes" } + ], + "name": "ServiceProviderRegistered", + "type": "event" } ] diff --git a/config/arbitrum-one.json b/config/arbitrum-one.json index d68fb89..6345def 100644 --- a/config/arbitrum-one.json +++ b/config/arbitrum-one.json @@ -2,5 +2,6 @@ "network": "arbitrum-one", "subgraphServiceAddress": "0x0000000000000000000000000000000000000000", "recurringCollectorAddress": "0x0000000000000000000000000000000000000000", - "startBlock": 0 + "startBlock": 0, + "registrationStartBlock": 0 } diff --git a/config/arbitrum-sepolia.json b/config/arbitrum-sepolia.json index 5ce3ebe..946370b 100644 --- a/config/arbitrum-sepolia.json +++ b/config/arbitrum-sepolia.json @@ -2,5 +2,6 @@ "network": "arbitrum-sepolia", "subgraphServiceAddress": "0x0000000000000000000000000000000000000000", "recurringCollectorAddress": "0x0000000000000000000000000000000000000000", - "startBlock": 0 + "startBlock": 0, + "registrationStartBlock": 0 } diff --git a/config/hardhat.json b/config/hardhat.json index ece2ea3..62d919b 100644 --- a/config/hardhat.json +++ b/config/hardhat.json @@ -2,5 +2,6 @@ "network": "hardhat", "subgraphServiceAddress": "0x0000000000000000000000000000000000000000", "recurringCollectorAddress": "0x0000000000000000000000000000000000000000", - "startBlock": 0 + "startBlock": 0, + "registrationStartBlock": 0 } diff --git a/schema.graphql b/schema.graphql index 86ac5ec..a275493 100644 --- a/schema.graphql +++ b/schema.graphql @@ -14,6 +14,8 @@ type IndexingAgreement @entity(immutable: false) { payer: Bytes! "The indexer address" indexer: Bytes! + "Link to the indexer's registration record (URL etc.). Null until the indexer's ServiceProviderRegistered event has been indexed; resolves automatically once it is. Lets consumers read the indexer URL alongside the agreement in one query instead of cross-querying the network subgraph." + indexerInfo: Indexer "The allocation linked to this agreement" allocationId: Bytes! "The subgraph deployment ID" @@ -82,6 +84,28 @@ type IndexerDeploymentLatest @entity(immutable: false) { blockTimestamp: BigInt! } +# Indexer (service provider) registration record, keyed by the indexer address. +# +# Sourced from the SubgraphService contract's ServiceProviderRegistered event, +# whose payload decodes to (string url, string geohash, address paymentsDestination). +# We store the url so consumers that hold an agreement's indexer address can read +# the indexer's service URL from this same subgraph instead of cross-querying the +# network subgraph. +# +# Mutable because an indexer can re-register at any time to change its URL; the +# contract overwrites its stored record and re-emits the event, so we treat this +# as last-write-wins. +type Indexer @entity(immutable: false) { + "The indexer (service provider) address" + id: Bytes! + "Service URL from the latest ServiceProviderRegistered event" + url: String! + "Block number of the registration that last set this URL. Consumers polling for URL changes filter with `lastUpdatedAtBlock_gt` since their last seen block." + lastUpdatedAtBlock: BigInt! + "Transaction hash of the registration that last set this URL. Auditable link back to the on-chain register call." + lastUpdatedAtTx: Bytes! +} + # 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 diff --git a/src/helpers.ts b/src/helpers.ts index 55f2647..6ece27c 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -1,11 +1,10 @@ -import { Address, BigInt, Bytes } from '@graphprotocol/graph-ts' +import { Address, BigInt, ByteArray, 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. +// 32-byte zero sentinel. Bytes.empty() serializes with unpredictable padding on +// non-nullable fields; a fixed-length value gives consumers something +// deterministic 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 { @@ -30,13 +29,25 @@ export function createOrLoadIndexingAgreement(agreementId: Bytes): IndexingAgree 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 - // 20-byte-address parsers on the consumer side. + // 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"), breaking strict 20-byte-address parsers downstream. agreement.canceledBy = Address.zero() as Bytes agreement.tokensCollected = BIGINT_ZERO agreement.lastStateChangeBlock = BIGINT_ZERO } return agreement } + +// ethereum.decode expects a standalone dynamic tuple to begin with a 32-byte +// offset word, but abi.encode(url, geohash, dest) on-chain omits that outer +// offset. Prepend it so the registration payload decodes as (string,string,address). +export function tuplePrefixBytes(input: Bytes): Bytes { + let tuplePrefix = ByteArray.fromHexString( + '0x0000000000000000000000000000000000000000000000000000000000000020', + ) + let inputAsTuple = new Uint8Array(tuplePrefix.length + input.length) + inputAsTuple.set(tuplePrefix, 0) + inputAsTuple.set(input.subarray(0), tuplePrefix.length) + return Bytes.fromUint8Array(inputAsTuple) +} diff --git a/src/recurringCollector.ts b/src/recurringCollector.ts index 88ab0ec..8f1710a 100644 --- a/src/recurringCollector.ts +++ b/src/recurringCollector.ts @@ -9,13 +9,9 @@ import { } 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. +// CancelAgreementBy enum from IRecurringCollector.sol: 0 = ServiceProvider, +// 1 = Payer, 2 = ThirdParty. The contract treats anything that isn't Payer as +// ServiceProvider; ThirdParty is currently unreachable from SubgraphService. const CANCEL_BY_PAYER: i32 = 1 export function handleAgreementAccepted(event: AgreementAccepted): void { @@ -25,6 +21,9 @@ export function handleAgreementAccepted(event: AgreementAccepted): void { // accept(), so the event's block timestamp is the canonical value. agreement.payer = event.params.payer agreement.indexer = event.params.serviceProvider + // Link to the Indexer registration record by address. Safe to set even if that + // record isn't indexed yet — the reference resolves once the indexer registers. + agreement.indexerInfo = event.params.serviceProvider agreement.state = 'Accepted' agreement.acceptedAt = event.block.timestamp agreement.lastCollectionAt = event.block.timestamp @@ -44,12 +43,9 @@ export function handleAgreementCanceled(event: AgreementCanceled): void { let agreement = IndexingAgreement.load(event.params.agreementId) if (agreement == null) return - // 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. + // The actual canceler address is written by handleIndexingAgreementCanceled, + // which fires in the same tx and reads canceledOnBehalfOf. The contract sets + // canceledAt = block.timestamp inside cancel(), so the event timestamp is canonical. if (event.params.canceledBy == CANCEL_BY_PAYER) { agreement.state = 'CanceledByPayer' } else { @@ -85,13 +81,9 @@ export function handleRCACollected(event: RCACollected): void { } 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. + // Fires for OFFER_TYPE_NEW and again for each OFFER_TYPE_UPDATE that changes the + // stored hash. The contract overwrites in place, so refresh offerType/offerHash + // every event; createdAt stays pinned to the first NEW to mark the initial offer. let offer = Offer.load(event.params.agreementId) if (offer == null) { offer = new Offer(event.params.agreementId) @@ -107,11 +99,9 @@ export function handleOfferStored(event: OfferStoredEvent): void { } 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". + // Fires when a signer cancels a stored RCA/RCAU offer; the contract deletes the + // on-chain entry, so mark the Offer not-live by stamping canceledAt with the event + // 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 diff --git a/src/subgraphService.ts b/src/subgraphService.ts index 6d041fb..70ea7ae 100644 --- a/src/subgraphService.ts +++ b/src/subgraphService.ts @@ -1,12 +1,13 @@ -import { ethereum } from '@graphprotocol/graph-ts' +import { ethereum, log } from '@graphprotocol/graph-ts' import { IndexingAgreementAccepted as AcceptedEvent, IndexingAgreementCanceled as CanceledEvent, IndexingAgreementUpdated as UpdatedEvent, IndexingFeesCollectedV1 as FeesCollectedEvent, } from '../generated/SubgraphService/SubgraphService' -import { IndexerDeploymentLatest, IndexingFeeCollection } from '../generated/schema' -import { createOrLoadIndexingAgreement } from './helpers' +import { ServiceProviderRegistered as RegisteredEvent } from '../generated/SubgraphServiceRegistration/SubgraphService' +import { Indexer, IndexerDeploymentLatest, IndexingFeeCollection } from '../generated/schema' +import { createOrLoadIndexingAgreement, tuplePrefixBytes } from './helpers' export function handleIndexingAgreementAccepted(event: AcceptedEvent): void { let agreement = createOrLoadIndexingAgreement(event.params.agreementId) @@ -27,10 +28,9 @@ export function handleIndexingAgreementAccepted(event: AcceptedEvent): void { 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. + // canceledOnBehalfOf is the signer that initiated the cancel — for + // operator-initiated cancels the operator, not the payer/indexer. Dipper + // compares it to its own signer to decide CanceledByRequester vs CanceledByIndexer. agreement.canceledBy = event.params.canceledOnBehalfOf agreement.canceledAtTx = event.transaction.hash agreement.lastStateChangeBlock = event.block.number @@ -82,3 +82,29 @@ export function handleIndexingFeesCollectedV1(event: FeesCollectedEvent): void { latest.blockTimestamp = event.block.timestamp latest.save() } + +export function handleServiceProviderRegistered(event: RegisteredEvent): void { + // The url/geohash/paymentsDestination ride inside the opaque `data` blob the + // contract re-emits; decode it the way the contract encoded it. We only keep + // the url (element 0); geohash and paymentsDestination are intentionally dropped. + let decoded = ethereum.decode('(string,string,address)', tuplePrefixBytes(event.params.data)) + if (decoded == null || decoded.kind != ethereum.ValueKind.TUPLE) { + // Surface a malformed payload rather than swallowing it; the indexer simply + // keeps whatever url was last decoded (or none). + log.warning('ServiceProviderRegistered failed to decode for {} (data: {})', [ + event.params.serviceProvider.toHexString(), + event.params.data.toHexString(), + ]) + return + } + + let indexer = Indexer.load(event.params.serviceProvider) + if (indexer == null) { + indexer = new Indexer(event.params.serviceProvider) + } + // Re-registration overwrites the on-chain record, so this is last-write-wins. + indexer.url = decoded.toTuple()[0].toString() + indexer.lastUpdatedAtBlock = event.block.number + indexer.lastUpdatedAtTx = event.transaction.hash + indexer.save() +} diff --git a/subgraph.template.yaml b/subgraph.template.yaml index 272473e..e6054a9 100644 --- a/subgraph.template.yaml +++ b/subgraph.template.yaml @@ -32,6 +32,29 @@ dataSources: - event: IndexingFeesCollectedV1(indexed address,indexed address,indexed bytes16,address,bytes32,uint256,uint256,uint256,bytes32,uint256,bytes) handler: handleIndexingFeesCollectedV1 file: ./src/subgraphService.ts + # Indexer registrations get their own data source so they can start from an + # earlier block than the agreements: an indexer typically registers its URL + # well before any agreement is accepted, and we want the URL indexed regardless. + - kind: ethereum + name: SubgraphServiceRegistration + network: {{network}} + source: + address: "{{subgraphServiceAddress}}" + abi: SubgraphService + startBlock: {{registrationStartBlock}} + mapping: + kind: ethereum/events + apiVersion: 0.0.7 + language: wasm/assemblyscript + entities: + - Indexer + abis: + - name: SubgraphService + file: ./abis/SubgraphService.json + eventHandlers: + - event: ServiceProviderRegistered(indexed address,bytes) + handler: handleServiceProviderRegistered + file: ./src/subgraphService.ts - kind: ethereum name: RecurringCollector network: {{network}} diff --git a/tests/recurringCollector.test.ts b/tests/recurringCollector.test.ts index 23faf52..9919f37 100644 --- a/tests/recurringCollector.test.ts +++ b/tests/recurringCollector.test.ts @@ -1,13 +1,20 @@ 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 { + handleAgreementAccepted, + handleOfferStored, + handleOfferCancelled, +} from '../src/recurringCollector' +import { + AgreementAccepted as AgreementAcceptedEvent, OfferStored as OfferStoredEvent, OfferCancelled as OfferCancelledEvent, } from '../generated/RecurringCollector/RecurringCollector' const PAYER = Address.fromString('0x0000000000000000000000000000000000000002') const CALLER = Address.fromString('0x0000000000000000000000000000000000000003') +const SERVICE_PROVIDER = Address.fromString('0x0000000000000000000000000000000000000001') +const DATA_SERVICE = Address.fromString('0x0000000000000000000000000000000000000005') const AGREEMENT_ID = Bytes.fromHexString('0x0102030405060708090a0b0c0d0e0f10') // OFFER_TYPE_NEW from IAgreementCollector.sol after the audit reshuffle @@ -41,6 +48,54 @@ function createOfferStoredEvent( return event } +function createAgreementAcceptedEvent( + serviceProvider: Address, + agreementId: Bytes, +): AgreementAcceptedEvent { + let event = changetype(newMockEvent()) + + event.parameters = new Array() + event.parameters.push( + new ethereum.EventParam('dataService', ethereum.Value.fromAddress(DATA_SERVICE)), + ) + event.parameters.push(new ethereum.EventParam('payer', ethereum.Value.fromAddress(PAYER))) + event.parameters.push( + new ethereum.EventParam('serviceProvider', ethereum.Value.fromAddress(serviceProvider)), + ) + event.parameters.push( + new ethereum.EventParam('agreementId', ethereum.Value.fromFixedBytes(agreementId)), + ) + event.parameters.push( + new ethereum.EventParam('endsAt', ethereum.Value.fromUnsignedBigInt(BigInt.fromI32(0))), + ) + event.parameters.push( + new ethereum.EventParam( + 'maxInitialTokens', + ethereum.Value.fromUnsignedBigInt(BigInt.fromI32(0)), + ), + ) + event.parameters.push( + new ethereum.EventParam( + 'maxOngoingTokensPerSecond', + ethereum.Value.fromUnsignedBigInt(BigInt.fromI32(0)), + ), + ) + event.parameters.push( + new ethereum.EventParam( + 'minSecondsPerCollection', + ethereum.Value.fromUnsignedBigInt(BigInt.fromI32(0)), + ), + ) + event.parameters.push( + new ethereum.EventParam( + 'maxSecondsPerCollection', + ethereum.Value.fromUnsignedBigInt(BigInt.fromI32(0)), + ), + ) + + return event +} + function createOfferCancelledEvent( caller: Address, agreementId: Bytes, @@ -123,3 +178,20 @@ describe('handleOfferStored', () => { assert.fieldEquals('Offer', id, 'canceledAt', '0') }) }) + +describe('handleAgreementAccepted', () => { + afterEach(() => { + clearStore() + }) + + test('links the agreement to the indexer registration record by address', () => { + let event = createAgreementAcceptedEvent(SERVICE_PROVIDER, AGREEMENT_ID) + handleAgreementAccepted(event) + + let id = AGREEMENT_ID.toHexString() + assert.fieldEquals('IndexingAgreement', id, 'indexer', SERVICE_PROVIDER.toHexString()) + // indexerInfo is the typed link consumers follow to read the indexer URL in + // one query; it stores the same address as the Indexer entity id. + assert.fieldEquals('IndexingAgreement', id, 'indexerInfo', SERVICE_PROVIDER.toHexString()) + }) +}) diff --git a/tests/subgraphService.test.ts b/tests/subgraphService.test.ts index 0522c6a..097bd71 100644 --- a/tests/subgraphService.test.ts +++ b/tests/subgraphService.test.ts @@ -5,6 +5,7 @@ import { handleIndexingAgreementCanceled, handleIndexingAgreementUpdated, handleIndexingFeesCollectedV1, + handleServiceProviderRegistered, } from '../src/subgraphService' import { IndexingAgreementAccepted as AcceptedEvent, @@ -12,6 +13,7 @@ import { IndexingAgreementUpdated as UpdatedEvent, IndexingFeesCollectedV1 as FeesCollectedEvent, } from '../generated/SubgraphService/SubgraphService' +import { ServiceProviderRegistered as RegisteredEvent } from '../generated/SubgraphServiceRegistration/SubgraphService' import { newMockEvent } from 'matchstick-as' function createAcceptedEvent( @@ -152,6 +154,31 @@ function encodeVersionTerms(tokensPerSecond: BigInt, tokensPerEntityPerSecond: B return ethereum.encode(ethereum.Value.fromTuple(tuple))! } +// Build the registration payload the way the contract does: +// abi.encode(string url, string geohash, address paymentsDestination). +function encodeRegistrationData(url: string, geohash: string, paymentsDestination: Address): Bytes { + let tuple = new ethereum.Tuple() + tuple.push(ethereum.Value.fromString(url)) + tuple.push(ethereum.Value.fromString(geohash)) + tuple.push(ethereum.Value.fromAddress(paymentsDestination)) + let encoded = ethereum.encode(ethereum.Value.fromTuple(tuple))! + // Strip the leading 32-byte tuple offset so the bytes match the on-chain + // abi.encode(...) (no outer offset); the handler re-adds it before decoding. + return Bytes.fromUint8Array(encoded.subarray(32)) +} + +function createRegisteredEvent(serviceProvider: Address, data: Bytes): RegisteredEvent { + let event = changetype(newMockEvent()) + + event.parameters = new Array() + event.parameters.push( + new ethereum.EventParam('serviceProvider', ethereum.Value.fromAddress(serviceProvider)), + ) + event.parameters.push(new ethereum.EventParam('data', ethereum.Value.fromBytes(data))) + + return event +} + describe('handleIndexingAgreementAccepted', () => { afterEach(() => { clearStore() @@ -421,3 +448,64 @@ describe('handleIndexingFeesCollectedV1', () => { assert.fieldEquals('IndexerDeploymentLatest', compositeId, 'tokensCollected', '2000000') }) }) + +describe('handleServiceProviderRegistered', () => { + afterEach(() => { + clearStore() + }) + + test('creates Indexer with the decoded url and registration provenance', () => { + let serviceProvider = Address.fromString('0x0000000000000000000000000000000000000001') + let paymentsDestination = Address.fromString('0x0000000000000000000000000000000000000004') + let url = 'https://indexer.example.com' + let data = encodeRegistrationData(url, 'u33dc0', paymentsDestination) + + let event = createRegisteredEvent(serviceProvider, data) + event.block.number = BigInt.fromI32(500) + let txHash = Bytes.fromHexString('0x' + 'ee'.repeat(32)) as Bytes + event.transaction.hash = txHash + handleServiceProviderRegistered(event) + + assert.entityCount('Indexer', 1) + assert.fieldEquals('Indexer', serviceProvider.toHexString(), 'url', url) + assert.fieldEquals('Indexer', serviceProvider.toHexString(), 'lastUpdatedAtBlock', '500') + assert.fieldEquals( + 'Indexer', + serviceProvider.toHexString(), + 'lastUpdatedAtTx', + txHash.toHexString(), + ) + }) + + test('re-registration overwrites the url (last write wins)', () => { + let serviceProvider = Address.fromString('0x0000000000000000000000000000000000000001') + let paymentsDestination = Address.fromString('0x0000000000000000000000000000000000000004') + + let first = createRegisteredEvent( + serviceProvider, + encodeRegistrationData('https://old.example.com', 'u33dc0', paymentsDestination), + ) + first.block.number = BigInt.fromI32(500) + handleServiceProviderRegistered(first) + + let second = createRegisteredEvent( + serviceProvider, + encodeRegistrationData('https://new.example.com', 'gbsuv7', paymentsDestination), + ) + second.block.number = BigInt.fromI32(600) + handleServiceProviderRegistered(second) + + assert.entityCount('Indexer', 1) + assert.fieldEquals('Indexer', serviceProvider.toHexString(), 'url', 'https://new.example.com') + assert.fieldEquals('Indexer', serviceProvider.toHexString(), 'lastUpdatedAtBlock', '600') + }) + + test('skips and creates no entity when the payload cannot be decoded', () => { + let serviceProvider = Address.fromString('0x0000000000000000000000000000000000000001') + // Garbage bytes that do not decode to (string,string,address). + let event = createRegisteredEvent(serviceProvider, Bytes.fromHexString('0xdeadbeef')) + handleServiceProviderRegistered(event) + + assert.entityCount('Indexer', 0) + }) +}) From 4ce7b947742fefc1d65588ecf462a9ec8c323c4f Mon Sep 17 00:00:00 2001 From: MoonBoi9001 Date: Thu, 4 Jun 2026 19:47:34 +1200 Subject: [PATCH 2/6] fix(config): point hardhat at the local SubgraphService address The local config held a zero contract address, so local builds wired every data source to 0x0 and indexed nothing. Use the documented local SubgraphService address so a local build indexes real events. The RecurringCollector address stays unset pending its local value. Co-Authored-By: Claude Opus 4.8 (1M context) --- config/hardhat.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/hardhat.json b/config/hardhat.json index 62d919b..8b409b3 100644 --- a/config/hardhat.json +++ b/config/hardhat.json @@ -1,6 +1,6 @@ { "network": "hardhat", - "subgraphServiceAddress": "0x0000000000000000000000000000000000000000", + "subgraphServiceAddress": "0xCf7Ed3AccA5a467e9e704C703E8D87F634fB0Fc9", "recurringCollectorAddress": "0x0000000000000000000000000000000000000000", "startBlock": 0, "registrationStartBlock": 0 From e344ac0a60b7ad598aec37bad4011b0c873604eb Mon Sep 17 00:00:00 2001 From: MoonBoi9001 Date: Fri, 5 Jun 2026 01:59:08 +1200 Subject: [PATCH 3/6] feat(config): set arbitrum SubgraphService addresses and start blocks The arbitrum configs held zero-address placeholders, so the subgraph could not index real networks. Set the deployed SubgraphService address on Arbitrum One and Sepolia (plus Sepolia's RecurringCollector) and anchor start blocks to deployment. Arbitrum One has no collector yet. Co-Authored-By: Claude Opus 4.8 (1M context) --- config/arbitrum-one.json | 6 +++--- config/arbitrum-sepolia.json | 8 ++++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/config/arbitrum-one.json b/config/arbitrum-one.json index 6345def..e019638 100644 --- a/config/arbitrum-one.json +++ b/config/arbitrum-one.json @@ -1,7 +1,7 @@ { "network": "arbitrum-one", - "subgraphServiceAddress": "0x0000000000000000000000000000000000000000", + "subgraphServiceAddress": "0xb2Bb92d0DE618878E438b55D5846cfecD9301105", "recurringCollectorAddress": "0x0000000000000000000000000000000000000000", - "startBlock": 0, - "registrationStartBlock": 0 + "startBlock": 397492865, + "registrationStartBlock": 397492865 } diff --git a/config/arbitrum-sepolia.json b/config/arbitrum-sepolia.json index 946370b..3daf4ee 100644 --- a/config/arbitrum-sepolia.json +++ b/config/arbitrum-sepolia.json @@ -1,7 +1,7 @@ { "network": "arbitrum-sepolia", - "subgraphServiceAddress": "0x0000000000000000000000000000000000000000", - "recurringCollectorAddress": "0x0000000000000000000000000000000000000000", - "startBlock": 0, - "registrationStartBlock": 0 + "subgraphServiceAddress": "0xc24A3dAC5d06d771f657A48B20cE1a671B78f26b", + "recurringCollectorAddress": "0x0b18befc60455121ad66ae6e4a647955fcde3900", + "startBlock": 201859812, + "registrationStartBlock": 201859812 } From b65468a8853c3030cfd578ecd6c6cc79237657b8 Mon Sep 17 00:00:00 2001 From: MoonBoi9001 Date: Fri, 5 Jun 2026 18:40:17 +1200 Subject: [PATCH 4/6] refactor(subgraph): simplify the registration decode guard ethereum.decode of a tuple type only ever returns null or a tuple-kind value, so the kind check could never fire; reduce it to a null check matching the other decode sites. Add a test that two distinct indexers stay separate records keyed by their address. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/subgraphService.ts | 4 +++- tests/subgraphService.test.ts | 23 +++++++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/src/subgraphService.ts b/src/subgraphService.ts index 70ea7ae..8289c4b 100644 --- a/src/subgraphService.ts +++ b/src/subgraphService.ts @@ -88,7 +88,9 @@ export function handleServiceProviderRegistered(event: RegisteredEvent): void { // contract re-emits; decode it the way the contract encoded it. We only keep // the url (element 0); geohash and paymentsDestination are intentionally dropped. let decoded = ethereum.decode('(string,string,address)', tuplePrefixBytes(event.params.data)) - if (decoded == null || decoded.kind != ethereum.ValueKind.TUPLE) { + // decode() of a tuple type returns either null or a tuple-kind value, so a null + // check is sufficient — matching the (uint256,uint256) decode sites above. + if (decoded == null) { // Surface a malformed payload rather than swallowing it; the indexer simply // keeps whatever url was last decoded (or none). log.warning('ServiceProviderRegistered failed to decode for {} (data: {})', [ diff --git a/tests/subgraphService.test.ts b/tests/subgraphService.test.ts index 097bd71..0adbd6b 100644 --- a/tests/subgraphService.test.ts +++ b/tests/subgraphService.test.ts @@ -500,6 +500,29 @@ describe('handleServiceProviderRegistered', () => { assert.fieldEquals('Indexer', serviceProvider.toHexString(), 'lastUpdatedAtBlock', '600') }) + test('keeps two distinct indexers as separate records keyed by address', () => { + let firstProvider = Address.fromString('0x0000000000000000000000000000000000000001') + let secondProvider = Address.fromString('0x0000000000000000000000000000000000000002') + let paymentsDestination = Address.fromString('0x0000000000000000000000000000000000000004') + + handleServiceProviderRegistered( + createRegisteredEvent( + firstProvider, + encodeRegistrationData('https://first.example.com', 'u33dc0', paymentsDestination), + ), + ) + handleServiceProviderRegistered( + createRegisteredEvent( + secondProvider, + encodeRegistrationData('https://second.example.com', 'gbsuv7', paymentsDestination), + ), + ) + + assert.entityCount('Indexer', 2) + assert.fieldEquals('Indexer', firstProvider.toHexString(), 'url', 'https://first.example.com') + assert.fieldEquals('Indexer', secondProvider.toHexString(), 'url', 'https://second.example.com') + }) + test('skips and creates no entity when the payload cannot be decoded', () => { let serviceProvider = Address.fromString('0x0000000000000000000000000000000000000001') // Garbage bytes that do not decode to (string,string,address). From c6e59ee30202cffaceaf051bcb8a1f525914a457 Mon Sep 17 00:00:00 2001 From: MoonBoi9001 Date: Thu, 11 Jun 2026 11:47:36 +1200 Subject: [PATCH 5/6] refactor(manifest): fold registrations into the main data source The registration handler had its own data source so it could start earlier than agreements, but both read the same contract and every network anchors both at its deployment block. Merge the handler into the agreements data source and drop the registrationStartBlock key. Co-Authored-By: Claude Fable 5 --- config/arbitrum-one.json | 3 +-- config/arbitrum-sepolia.json | 3 +-- config/hardhat.json | 3 +-- src/subgraphService.ts | 2 +- subgraph.template.yaml | 22 +--------------------- tests/subgraphService.test.ts | 2 +- 6 files changed, 6 insertions(+), 29 deletions(-) diff --git a/config/arbitrum-one.json b/config/arbitrum-one.json index e019638..465250f 100644 --- a/config/arbitrum-one.json +++ b/config/arbitrum-one.json @@ -2,6 +2,5 @@ "network": "arbitrum-one", "subgraphServiceAddress": "0xb2Bb92d0DE618878E438b55D5846cfecD9301105", "recurringCollectorAddress": "0x0000000000000000000000000000000000000000", - "startBlock": 397492865, - "registrationStartBlock": 397492865 + "startBlock": 397492865 } diff --git a/config/arbitrum-sepolia.json b/config/arbitrum-sepolia.json index 3daf4ee..3087ce1 100644 --- a/config/arbitrum-sepolia.json +++ b/config/arbitrum-sepolia.json @@ -2,6 +2,5 @@ "network": "arbitrum-sepolia", "subgraphServiceAddress": "0xc24A3dAC5d06d771f657A48B20cE1a671B78f26b", "recurringCollectorAddress": "0x0b18befc60455121ad66ae6e4a647955fcde3900", - "startBlock": 201859812, - "registrationStartBlock": 201859812 + "startBlock": 201859812 } diff --git a/config/hardhat.json b/config/hardhat.json index 8b409b3..afa3f34 100644 --- a/config/hardhat.json +++ b/config/hardhat.json @@ -2,6 +2,5 @@ "network": "hardhat", "subgraphServiceAddress": "0xCf7Ed3AccA5a467e9e704C703E8D87F634fB0Fc9", "recurringCollectorAddress": "0x0000000000000000000000000000000000000000", - "startBlock": 0, - "registrationStartBlock": 0 + "startBlock": 0 } diff --git a/src/subgraphService.ts b/src/subgraphService.ts index 8289c4b..2d75b79 100644 --- a/src/subgraphService.ts +++ b/src/subgraphService.ts @@ -4,8 +4,8 @@ import { IndexingAgreementCanceled as CanceledEvent, IndexingAgreementUpdated as UpdatedEvent, IndexingFeesCollectedV1 as FeesCollectedEvent, + ServiceProviderRegistered as RegisteredEvent, } from '../generated/SubgraphService/SubgraphService' -import { ServiceProviderRegistered as RegisteredEvent } from '../generated/SubgraphServiceRegistration/SubgraphService' import { Indexer, IndexerDeploymentLatest, IndexingFeeCollection } from '../generated/schema' import { createOrLoadIndexingAgreement, tuplePrefixBytes } from './helpers' diff --git a/subgraph.template.yaml b/subgraph.template.yaml index e6054a9..36a9e4a 100644 --- a/subgraph.template.yaml +++ b/subgraph.template.yaml @@ -19,6 +19,7 @@ dataSources: - IndexingAgreement - IndexingFeeCollection - IndexerDeploymentLatest + - Indexer abis: - name: SubgraphService file: ./abis/SubgraphService.json @@ -31,27 +32,6 @@ dataSources: handler: handleIndexingAgreementUpdated - event: IndexingFeesCollectedV1(indexed address,indexed address,indexed bytes16,address,bytes32,uint256,uint256,uint256,bytes32,uint256,bytes) handler: handleIndexingFeesCollectedV1 - file: ./src/subgraphService.ts - # Indexer registrations get their own data source so they can start from an - # earlier block than the agreements: an indexer typically registers its URL - # well before any agreement is accepted, and we want the URL indexed regardless. - - kind: ethereum - name: SubgraphServiceRegistration - network: {{network}} - source: - address: "{{subgraphServiceAddress}}" - abi: SubgraphService - startBlock: {{registrationStartBlock}} - mapping: - kind: ethereum/events - apiVersion: 0.0.7 - language: wasm/assemblyscript - entities: - - Indexer - abis: - - name: SubgraphService - file: ./abis/SubgraphService.json - eventHandlers: - event: ServiceProviderRegistered(indexed address,bytes) handler: handleServiceProviderRegistered file: ./src/subgraphService.ts diff --git a/tests/subgraphService.test.ts b/tests/subgraphService.test.ts index 0adbd6b..41579ee 100644 --- a/tests/subgraphService.test.ts +++ b/tests/subgraphService.test.ts @@ -12,8 +12,8 @@ import { IndexingAgreementCanceled as CanceledEvent, IndexingAgreementUpdated as UpdatedEvent, IndexingFeesCollectedV1 as FeesCollectedEvent, + ServiceProviderRegistered as RegisteredEvent, } from '../generated/SubgraphService/SubgraphService' -import { ServiceProviderRegistered as RegisteredEvent } from '../generated/SubgraphServiceRegistration/SubgraphService' import { newMockEvent } from 'matchstick-as' function createAcceptedEvent( From f3eda5db761adb2c3d65cc3bd6ba5cfc08694beb Mon Sep 17 00:00:00 2001 From: MoonBoi9001 Date: Thu, 11 Jun 2026 11:48:25 +1200 Subject: [PATCH 6/6] fix(agreements): set indexer and payer from the accepted event The indexer, payer and indexer-URL link were only written by the RecurringCollector handler. Both events fire in the same transaction, but a misconfigured collector address would leave agreements missing the indexer link consumers rely on to read the URL. Co-Authored-By: Claude Fable 5 --- src/subgraphService.ts | 6 ++++++ tests/subgraphService.test.ts | 28 ++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/src/subgraphService.ts b/src/subgraphService.ts index 2d75b79..862d3a9 100644 --- a/src/subgraphService.ts +++ b/src/subgraphService.ts @@ -11,6 +11,12 @@ import { createOrLoadIndexingAgreement, tuplePrefixBytes } from './helpers' export function handleIndexingAgreementAccepted(event: AcceptedEvent): void { let agreement = createOrLoadIndexingAgreement(event.params.agreementId) + // The RecurringCollector's AgreementAccepted handler sets these too (same tx, + // same values); setting them here as well keeps the agreement's identity and + // its indexer link intact even if the collector data source is misconfigured. + agreement.indexer = event.params.indexer + agreement.indexerInfo = event.params.indexer + agreement.payer = event.params.payer agreement.allocationId = event.params.allocationId agreement.subgraphDeploymentId = event.params.subgraphDeploymentId agreement.acceptedAtTx = event.transaction.hash diff --git a/tests/subgraphService.test.ts b/tests/subgraphService.test.ts index 41579ee..6c15b88 100644 --- a/tests/subgraphService.test.ts +++ b/tests/subgraphService.test.ts @@ -243,6 +243,34 @@ describe('handleIndexingAgreementAccepted', () => { // State remains NotAccepted until RC handler fires assert.fieldEquals('IndexingAgreement', agreementId.toHexString(), 'state', 'NotAccepted') }) + + test('populates indexer, payer, and the indexer link without the RC event', () => { + let indexer = Address.fromString('0x0000000000000000000000000000000000000001') + let payer = Address.fromString('0x0000000000000000000000000000000000000002') + let agreementId = Bytes.fromHexString('0x0102030405060708090a0b0c0d0e0f10') + let allocationId = Address.fromString('0x0000000000000000000000000000000000000003') + let subgraphDeploymentId = Bytes.fromHexString('0x' + 'ab'.repeat(32)) + let versionTerms = encodeVersionTerms(BigInt.fromI32(1000), BigInt.fromI32(50)) + + let event = createAcceptedEvent( + indexer, + payer, + agreementId, + allocationId, + subgraphDeploymentId, + 1, + versionTerms, + ) + handleIndexingAgreementAccepted(event) + + // The SubgraphService event alone must establish the agreement's identity, + // so the indexer URL link doesn't depend on the RecurringCollector data + // source being configured correctly. + let id = agreementId.toHexString() + assert.fieldEquals('IndexingAgreement', id, 'indexer', indexer.toHexString()) + assert.fieldEquals('IndexingAgreement', id, 'payer', payer.toHexString()) + assert.fieldEquals('IndexingAgreement', id, 'indexerInfo', indexer.toHexString()) + }) }) describe('handleIndexingAgreementCanceled', () => {