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..465250f 100644 --- a/config/arbitrum-one.json +++ b/config/arbitrum-one.json @@ -1,6 +1,6 @@ { "network": "arbitrum-one", - "subgraphServiceAddress": "0x0000000000000000000000000000000000000000", + "subgraphServiceAddress": "0xb2Bb92d0DE618878E438b55D5846cfecD9301105", "recurringCollectorAddress": "0x0000000000000000000000000000000000000000", - "startBlock": 0 + "startBlock": 397492865 } diff --git a/config/arbitrum-sepolia.json b/config/arbitrum-sepolia.json index 5ce3ebe..3087ce1 100644 --- a/config/arbitrum-sepolia.json +++ b/config/arbitrum-sepolia.json @@ -1,6 +1,6 @@ { "network": "arbitrum-sepolia", - "subgraphServiceAddress": "0x0000000000000000000000000000000000000000", - "recurringCollectorAddress": "0x0000000000000000000000000000000000000000", - "startBlock": 0 + "subgraphServiceAddress": "0xc24A3dAC5d06d771f657A48B20cE1a671B78f26b", + "recurringCollectorAddress": "0x0b18befc60455121ad66ae6e4a647955fcde3900", + "startBlock": 201859812 } diff --git a/config/hardhat.json b/config/hardhat.json index ece2ea3..afa3f34 100644 --- a/config/hardhat.json +++ b/config/hardhat.json @@ -1,6 +1,6 @@ { "network": "hardhat", - "subgraphServiceAddress": "0x0000000000000000000000000000000000000000", + "subgraphServiceAddress": "0xCf7Ed3AccA5a467e9e704C703E8D87F634fB0Fc9", "recurringCollectorAddress": "0x0000000000000000000000000000000000000000", "startBlock": 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..862d3a9 100644 --- a/src/subgraphService.ts +++ b/src/subgraphService.ts @@ -1,15 +1,22 @@ -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, + ServiceProviderRegistered as RegisteredEvent, } from '../generated/SubgraphService/SubgraphService' -import { IndexerDeploymentLatest, IndexingFeeCollection } from '../generated/schema' -import { createOrLoadIndexingAgreement } from './helpers' +import { Indexer, IndexerDeploymentLatest, IndexingFeeCollection } from '../generated/schema' +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 @@ -27,10 +34,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 +88,31 @@ 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)) + // 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: {})', [ + 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..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,6 +32,8 @@ dataSources: handler: handleIndexingAgreementUpdated - event: IndexingFeesCollectedV1(indexed address,indexed address,indexed bytes16,address,bytes32,uint256,uint256,uint256,bytes32,uint256,bytes) handler: handleIndexingFeesCollectedV1 + - event: ServiceProviderRegistered(indexed address,bytes) + handler: handleServiceProviderRegistered file: ./src/subgraphService.ts - kind: ethereum name: RecurringCollector 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..6c15b88 100644 --- a/tests/subgraphService.test.ts +++ b/tests/subgraphService.test.ts @@ -5,12 +5,14 @@ import { handleIndexingAgreementCanceled, handleIndexingAgreementUpdated, handleIndexingFeesCollectedV1, + handleServiceProviderRegistered, } from '../src/subgraphService' import { IndexingAgreementAccepted as AcceptedEvent, IndexingAgreementCanceled as CanceledEvent, IndexingAgreementUpdated as UpdatedEvent, IndexingFeesCollectedV1 as FeesCollectedEvent, + ServiceProviderRegistered as RegisteredEvent, } from '../generated/SubgraphService/SubgraphService' import { newMockEvent } from 'matchstick-as' @@ -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() @@ -216,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', () => { @@ -421,3 +476,87 @@ 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('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). + let event = createRegisteredEvent(serviceProvider, Bytes.fromHexString('0xdeadbeef')) + handleServiceProviderRegistered(event) + + assert.entityCount('Indexer', 0) + }) +})