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
IndexingAgreementCanceled | Agreement canceled by payer or indexer |
IndexingAgreementUpdated | Agreement terms or allocation changed |
IndexingFeesCollectedV1 | Fees collected against an active agreement |
+ServiceProviderRegistered | Indexer 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)
+ })
+})