From 3849657b71d2f5be18cfaffea54827811bcd22a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Migone?= Date: Wed, 13 May 2026 09:39:00 -0300 Subject: [PATCH 1/4] docs: add subgraph readme MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomás Migone --- packages/subgraph/README.md | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 packages/subgraph/README.md diff --git a/packages/subgraph/README.md b/packages/subgraph/README.md new file mode 100644 index 0000000..9e9b2b0 --- /dev/null +++ b/packages/subgraph/README.md @@ -0,0 +1,35 @@ +# Graph Horizon Network Subgraph + +Indexes The Graph's Horizon protocol core contracts: `HorizonStaking`, `GraphPayments` and `PaymentsEscrow`. + +The Graph Horizon Network subgraph is an **aggregate state subgraph** that tracks current state (balances, counts, parameters) for: + +- **Service provider stake and provisions**: Token balances, provision parameters, and thawing state +- **Delegation pools**: Pool-level token balances, shares, and thawing tokens +- **Thaw requests**: Active deprovisioning requests +- **Payment collections and escrow**: Escrow balances and collector discovery +- **Operators**: Operator authorizations per service provider + +Entities are updated in place as events occur. Data services and payment collectors are discovered dynamically via staking and escrow events, but are tracked generically without service-specific or collector-specific details. + +### What this subgraph is NOT + +- **Not historical**: This subgraph does not track event-by-event history. Individual stake deposits, slashes, or delegation details are not recorded. + +- **Not data-service specific**: Data services are indexed but no service-specific parameters are tracked. Each data service (Subgraph Service, etc.) should create their own subgraph that suits their own needs. + +- **Not collector specific**: Payment collectors are discovered but collector-specific logic like signer authorizations is not tracked. Collector-specific subgraphs should handle these details. + +- **Not delegator-level**: Individual Delegator and Delegation entities are not included. Only pool-level aggregates (DelegationPool) are tracked. + +### What data is indexed? + +This subgraph begins indexing at **Horizon genesis**, not protocol genesis. The Graph protocol existed before the Horizon upgrade, so pre-existing state that is relevant such as stake or delegations needs to be backfilled to have a correct view of the network state. + +At the Horizon genesis block, a one-time migration seeds entities by reading current contract state. The list of entities to migrate is generated by querying the legacy network subgraph at the Horizon genesis block (see `packages/tools`), these include: + +- **Service providers**: Stake balances are read from the `HorizonStaking` contract for all service providers with `staked tokens > 0` at Graph Horizon genesis. All pre-Horizon activity (deposits, thaws, rewards, slashings) is aggregated into a single stake snapshot regardless of it's source: direct stake deposit, indexing rewards or query fee payments. + +- **Delegation pools**: Pool state (tokens, shares, tokensThawing) is read from the contract for all service providers with `delegated tokens > 0` at Graph Horizon genesis. + +**Important**: With this approach pre-Horizon history is "flattened" into these snapshots. Cumulative fields like `tokensStaked` include pre-Horizon values, but event-driven fields (e.g., query fees collected) only track activity from Horizon onwards. \ No newline at end of file From 45163f7b12b6c0684d93b7aa13f6eda86cf757c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Migone?= Date: Wed, 13 May 2026 17:15:06 -0300 Subject: [PATCH 2/4] fix: delegation fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomás Migone --- packages/subgraph/src/handlers/delegation.ts | 16 +++++- packages/subgraph/src/handlers/migration.ts | 4 -- packages/subgraph/src/handlers/staking.ts | 5 +- packages/subgraph/tests/delegation.test.ts | 51 ++++++++++++++++++ packages/subgraph/tests/staking.test.ts | 56 ++++++++++++++++++-- 5 files changed, 122 insertions(+), 10 deletions(-) diff --git a/packages/subgraph/src/handlers/delegation.ts b/packages/subgraph/src/handlers/delegation.ts index 5505d7f..9d69942 100644 --- a/packages/subgraph/src/handlers/delegation.ts +++ b/packages/subgraph/src/handlers/delegation.ts @@ -9,6 +9,7 @@ import { import { getOrCreateGraphNetwork, saveGraphNetwork } from "../entities/graphNetwork" import { getOrCreateServiceProvider, saveServiceProvider } from "../entities/serviceProvider" import { getOrCreateDelegationPool, saveDelegationPool } from "../entities/delegationPool" +import { BIGINT_ZERO } from "../common/constants" /** * Handles TokensDelegated event. @@ -36,6 +37,7 @@ export function handleTokensDelegated(event: TokensDelegated): void { // Update ServiceProvider let serviceProvider = getOrCreateServiceProvider(serviceProviderBytes, event.block.number, event.block.timestamp) + assert(!serviceProvider.isNew, "Service provider does not exist.") serviceProvider.entity.tokensDelegated = serviceProvider.entity.tokensDelegated.plus(tokens) saveServiceProvider(serviceProvider.entity, event.block) @@ -62,14 +64,14 @@ export function handleTokensUndelegated(event: TokensUndelegated): void { let verifierBytes = Bytes.fromHexString(verifier.toHexString()) as Bytes // Update DelegationPool - shares burned, tokens start thawing - // Note: sharesThawing not tracked here - it's a calculated value in the contract - // that differs from the delegation shares being burned let pool = getOrCreateDelegationPool( serviceProviderBytes, verifierBytes, event.block.number, event.block.timestamp ) + assert(!pool.isNew, "Delegation pool does not exist.") + assert(pool.entity.shares >= shares, "Undelegated shares exceed pool shares.") pool.entity.shares = pool.entity.shares.minus(shares) pool.entity.tokensThawing = pool.entity.tokensThawing.plus(tokens) saveDelegationPool(pool.entity, event.block) @@ -94,6 +96,7 @@ export function handleDelegatedTokensWithdrawn(event: DelegatedTokensWithdrawn): event.block.number, event.block.timestamp ) + assert(!pool.isNew, "Delegation pool does not exist.") assert(pool.entity.tokens >= tokens, "Withdraw tokens exceed pool tokens.") pool.entity.tokens = pool.entity.tokens.minus(tokens) assert(pool.entity.tokensThawing >= tokens, "Withdraw tokens exceed pool thawing tokens.") @@ -102,6 +105,7 @@ export function handleDelegatedTokensWithdrawn(event: DelegatedTokensWithdrawn): // Update ServiceProvider let serviceProvider = getOrCreateServiceProvider(serviceProviderBytes, event.block.number, event.block.timestamp) + assert(!serviceProvider.isNew, "Service provider does not exist.") assert(serviceProvider.entity.tokensDelegated >= tokens, "Withdraw tokens exceed service provider delegated tokens.") serviceProvider.entity.tokensDelegated = serviceProvider.entity.tokensDelegated.minus(tokens) saveServiceProvider(serviceProvider.entity, event.block) @@ -110,6 +114,10 @@ export function handleDelegatedTokensWithdrawn(event: DelegatedTokensWithdrawn): let graphNetwork = getOrCreateGraphNetwork() assert(graphNetwork.tokensDelegated >= tokens, "Withdraw tokens exceed network tokens delegated.") graphNetwork.tokensDelegated = graphNetwork.tokensDelegated.minus(tokens) + if (pool.entity.tokens.equals(BIGINT_ZERO)) { + assert(graphNetwork.countDelegationPools > 0, "Delegation pool count is zero.") + graphNetwork.countDelegationPools -= 1 + } saveGraphNetwork(graphNetwork) } @@ -132,12 +140,14 @@ export function handleDelegationSlashed(event: DelegationSlashed): void { event.block.number, event.block.timestamp ) + assert(!pool.isNew, "Delegation pool does not exist.") assert(pool.entity.tokens >= tokens, "Slash tokens exceed pool tokens.") pool.entity.tokens = pool.entity.tokens.minus(tokens) saveDelegationPool(pool.entity, event.block) // Update ServiceProvider let serviceProvider = getOrCreateServiceProvider(serviceProviderBytes, event.block.number, event.block.timestamp) + assert(!serviceProvider.isNew, "Service provider does not exist.") assert(serviceProvider.entity.tokensDelegated >= tokens, "Slash tokens exceed service provider delegated tokens.") serviceProvider.entity.tokensDelegated = serviceProvider.entity.tokensDelegated.minus(tokens) saveServiceProvider(serviceProvider.entity, event.block) @@ -168,11 +178,13 @@ export function handleTokensToDelegationPoolAdded(event: TokensToDelegationPoolA event.block.number, event.block.timestamp ) + assert(!pool.isNew, "Delegation pool does not exist.") pool.entity.tokens = pool.entity.tokens.plus(tokens) saveDelegationPool(pool.entity, event.block) // Update ServiceProvider let serviceProvider = getOrCreateServiceProvider(serviceProviderBytes, event.block.number, event.block.timestamp) + assert(!serviceProvider.isNew, "Service provider does not exist.") serviceProvider.entity.tokensDelegated = serviceProvider.entity.tokensDelegated.plus(tokens) saveServiceProvider(serviceProvider.entity, event.block) diff --git a/packages/subgraph/src/handlers/migration.ts b/packages/subgraph/src/handlers/migration.ts index 7b13c06..04af41b 100644 --- a/packages/subgraph/src/handlers/migration.ts +++ b/packages/subgraph/src/handlers/migration.ts @@ -162,10 +162,6 @@ export function migrateDelegationPools(block: ethereum.Block, networkConfig: Net serviceProvider.entity.tokensDelegated = serviceProvider.entity.tokensDelegated.plus(poolTokens) saveServiceProvider(serviceProvider.entity, block) - // Update graph network - if (serviceProvider.isNew) { - graphNetwork.countServiceProviders += 1 - } graphNetwork.countDelegationPools += 1 graphNetwork.tokensDelegated = graphNetwork.tokensDelegated.plus(poolTokens) } diff --git a/packages/subgraph/src/handlers/staking.ts b/packages/subgraph/src/handlers/staking.ts index d165fa4..de43a59 100644 --- a/packages/subgraph/src/handlers/staking.ts +++ b/packages/subgraph/src/handlers/staking.ts @@ -50,6 +50,7 @@ export function handleHorizonStakeWithdrawn(event: HorizonStakeWithdrawn): void ) // ServiceProvider + assert(!serviceProvider.isNew, "Service provider does not exist.") assert(serviceProvider.entity.tokensStaked >= event.params.tokens, "Withdraw exceeds staked tokens.") serviceProvider.entity.tokensStaked = serviceProvider.entity.tokensStaked.minus(event.params.tokens) assert(serviceProvider.entity.tokensStaked >= serviceProvider.entity.tokensProvisioned, "Provisioned tokens exceed staked tokens.") @@ -59,7 +60,9 @@ export function handleHorizonStakeWithdrawn(event: HorizonStakeWithdrawn): void // GraphNetwork assert(graphNetwork.tokensStaked >= event.params.tokens, "Withdraw exceeds total staked.") graphNetwork.tokensStaked = graphNetwork.tokensStaked.minus(event.params.tokens) - if(serviceProvider.entity.tokensStaked.equals(BIGINT_ZERO)) { + // Decrement counter if SP becomes inactive (no stake) + if (serviceProvider.entity.tokensStaked.equals(BIGINT_ZERO)) { + assert(graphNetwork.countServiceProviders > 0, "Service provider count is zero.") graphNetwork.countServiceProviders -= 1 } saveGraphNetwork(graphNetwork) diff --git a/packages/subgraph/tests/delegation.test.ts b/packages/subgraph/tests/delegation.test.ts index 1f7fa7a..137f1a5 100644 --- a/packages/subgraph/tests/delegation.test.ts +++ b/packages/subgraph/tests/delegation.test.ts @@ -361,6 +361,57 @@ describe("DelegationSlashed", () => { }) }) +describe("Service Provider counter behavior", () => { + beforeEach(() => { + clearStore() + }) + + test("does not decrement countServiceProviders when withdrawal leaves SP with stake", () => { + // Create SP with stake first + let stakeTokens = BigInt.fromString("5000000000000000000000") + let depositEvent = createStakeDepositedEvent(SP_ADDRESS, stakeTokens) + handleHorizonStakeDeposited(depositEvent) + + // Then add delegation + let delegatedTokens = BigInt.fromString("1000000000000000000000") + let shares = BigInt.fromString("1000000000000000000000") + let delegateEvent = createTokensDelegatedEvent(SP_ADDRESS, VERIFIER_ADDRESS, DELEGATOR_ADDRESS, delegatedTokens, shares) + handleTokensDelegated(delegateEvent) + + assert.fieldEquals("GraphNetwork", GRAPH_NETWORK_ID.toHexString(), "countServiceProviders", "1") + + // Undelegate and withdraw all + let undelegateEvent = createTokensUndelegatedEvent(SP_ADDRESS, VERIFIER_ADDRESS, DELEGATOR_ADDRESS, delegatedTokens, shares) + handleTokensUndelegated(undelegateEvent) + + let withdrawEvent = createDelegatedTokensWithdrawnEvent(SP_ADDRESS, VERIFIER_ADDRESS, DELEGATOR_ADDRESS, delegatedTokens) + handleDelegatedTokensWithdrawn(withdrawEvent) + + // Counter should NOT decrement because SP still has stake + assert.fieldEquals("ServiceProvider", SP_ADDRESS.toHexString(), "tokensStaked", stakeTokens.toString()) + assert.fieldEquals("ServiceProvider", SP_ADDRESS.toHexString(), "tokensDelegated", "0") + assert.fieldEquals("GraphNetwork", GRAPH_NETWORK_ID.toHexString(), "countServiceProviders", "1") + }) + + test("does not double count when delegation is added to existing staked SP", () => { + // Create SP with stake first + let stakeTokens = BigInt.fromString("5000000000000000000000") + let depositEvent = createStakeDepositedEvent(SP_ADDRESS, stakeTokens) + handleHorizonStakeDeposited(depositEvent) + + assert.fieldEquals("GraphNetwork", GRAPH_NETWORK_ID.toHexString(), "countServiceProviders", "1") + + // Add delegation - should NOT increment count since SP already exists + let delegatedTokens = BigInt.fromString("1000000000000000000000") + let shares = BigInt.fromString("1000000000000000000000") + let delegateEvent = createTokensDelegatedEvent(SP_ADDRESS, VERIFIER_ADDRESS, DELEGATOR_ADDRESS, delegatedTokens, shares) + handleTokensDelegated(delegateEvent) + + // Counter should still be 1 + assert.fieldEquals("GraphNetwork", GRAPH_NETWORK_ID.toHexString(), "countServiceProviders", "1") + }) +}) + describe("Delegation lifecycle", () => { beforeEach(() => { clearStore() diff --git a/packages/subgraph/tests/staking.test.ts b/packages/subgraph/tests/staking.test.ts index 6113084..8d3a731 100644 --- a/packages/subgraph/tests/staking.test.ts +++ b/packages/subgraph/tests/staking.test.ts @@ -7,8 +7,9 @@ import { newTypedMockEvent, } from "matchstick-as" import { Address, BigInt, Bytes, ethereum } from "@graphprotocol/graph-ts" -import { HorizonStakeDeposited, HorizonStakeWithdrawn } from "../generated/HorizonStaking/HorizonStaking" +import { HorizonStakeDeposited, HorizonStakeWithdrawn, TokensDelegated } from "../generated/HorizonStaking/HorizonStaking" import { handleHorizonStakeDeposited, handleHorizonStakeWithdrawn } from "../src/handlers/staking" +import { handleTokensDelegated } from "../src/handlers/delegation" import { GRAPH_NETWORK_ID } from "../src/common/constants" // Test addresses @@ -37,6 +38,29 @@ function createStakeWithdrawnEvent(serviceProvider: Address, tokens: BigInt): Ho return event } +// Helper to create TokensDelegated event +const VERIFIER_ADDRESS = Address.fromString("0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee") +const DELEGATOR_ADDRESS = Address.fromString("0x9999999999999999999999999999999999999999") + +function createTokensDelegatedEvent( + serviceProvider: Address, + verifier: Address, + delegator: Address, + tokens: BigInt, + shares: BigInt +): TokensDelegated { + let event = newTypedMockEvent() + event.parameters = new Array() + event.parameters.push(new ethereum.EventParam("serviceProvider", ethereum.Value.fromAddress(serviceProvider))) + event.parameters.push(new ethereum.EventParam("verifier", ethereum.Value.fromAddress(verifier))) + event.parameters.push(new ethereum.EventParam("delegator", ethereum.Value.fromAddress(delegator))) + event.parameters.push(new ethereum.EventParam("tokens", ethereum.Value.fromUnsignedBigInt(tokens))) + event.parameters.push(new ethereum.EventParam("shares", ethereum.Value.fromUnsignedBigInt(shares))) + event.block.number = BigInt.fromI32(150) + event.block.timestamp = BigInt.fromI32(1500) + return event +} + describe("HorizonStakeDeposited", () => { beforeEach(() => { clearStore() @@ -143,7 +167,7 @@ describe("HorizonStakeWithdrawn", () => { assert.fieldEquals("GraphNetwork", GRAPH_NETWORK_ID.toHexString(), "countServiceProviders", "1") }) - test("allows full withdrawal and decrements countServiceProviders", () => { + test("allows full withdrawal and decrements countServiceProviders when no delegation", () => { let stake = BigInt.fromString("1000000000000000000000") // 1000 GRT let depositEvent = createStakeDepositedEvent(SP_ADDRESS, stake) @@ -158,7 +182,33 @@ describe("HorizonStakeWithdrawn", () => { assert.fieldEquals("ServiceProvider", SP_ADDRESS.toHexString(), "tokensStaked", "0") assert.fieldEquals("ServiceProvider", SP_ADDRESS.toHexString(), "tokensIdle", "0") assert.fieldEquals("GraphNetwork", GRAPH_NETWORK_ID.toHexString(), "tokensStaked", "0") - // Count should decrement to 0 after full withdrawal + // Count should decrement to 0 after full withdrawal (no delegation) + assert.fieldEquals("GraphNetwork", GRAPH_NETWORK_ID.toHexString(), "countServiceProviders", "0") + }) + + test("decrements countServiceProviders on full withdrawal even with delegation", () => { + let stake = BigInt.fromString("1000000000000000000000") // 1000 GRT + + // First deposit stake + let depositEvent = createStakeDepositedEvent(SP_ADDRESS, stake) + handleHorizonStakeDeposited(depositEvent) + + // Then receive delegation + let delegatedTokens = BigInt.fromString("500000000000000000000") // 500 GRT + let shares = BigInt.fromString("500000000000000000000") + let delegateEvent = createTokensDelegatedEvent(SP_ADDRESS, VERIFIER_ADDRESS, DELEGATOR_ADDRESS, delegatedTokens, shares) + handleTokensDelegated(delegateEvent) + + // Verify count is still 1 (no double counting) + assert.fieldEquals("GraphNetwork", GRAPH_NETWORK_ID.toHexString(), "countServiceProviders", "1") + + // Withdraw all stake + let withdrawEvent = createStakeWithdrawnEvent(SP_ADDRESS, stake) + handleHorizonStakeWithdrawn(withdrawEvent) + + assert.fieldEquals("ServiceProvider", SP_ADDRESS.toHexString(), "tokensStaked", "0") + assert.fieldEquals("ServiceProvider", SP_ADDRESS.toHexString(), "tokensDelegated", delegatedTokens.toString()) + // countServiceProviders tracks SPs with stake > 0, so it decrements regardless of delegation assert.fieldEquals("GraphNetwork", GRAPH_NETWORK_ID.toHexString(), "countServiceProviders", "0") }) From c49206626527f22005d225c3c07d092928465447 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Migone?= Date: Fri, 15 May 2026 11:47:36 -0300 Subject: [PATCH 3/4] feat: add entity counts and other missing bits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomás Migone --- packages/subgraph/schema.graphql | 38 +++++++++++++++++++ .../subgraph/src/entities/graphNetwork.ts | 13 +++++++ .../subgraph/src/entities/serviceProvider.ts | 18 +++++++++ packages/subgraph/src/handlers/delegation.ts | 21 ++++++++++ packages/subgraph/src/handlers/provision.ts | 28 +++++++++++++- 5 files changed, 117 insertions(+), 1 deletion(-) diff --git a/packages/subgraph/schema.graphql b/packages/subgraph/schema.graphql index 8428621..5d633a5 100644 --- a/packages/subgraph/schema.graphql +++ b/packages/subgraph/schema.graphql @@ -9,6 +9,10 @@ type GraphNetwork @entity(immutable: false) { countProvisions: Int! "Active delegation pools" countDelegationPools: Int! + "Provision slash events" + countProvisionSlashEvents: Int! + "Delegation pool slash events" + countDelegationPoolSlashEvents: Int! # Stake aggregates "Total tokens staked by service providers" @@ -17,12 +21,32 @@ type GraphNetwork @entity(immutable: false) { tokensProvisioned: BigInt! "Total tokens delegated to service providers" tokensDelegated: BigInt! + "Total tokens currently thawing from provisions" + tokensThawingFromProvisions: BigInt! + "Total tokens currently thawing from delegation pools" + tokensThawingFromDelegationPools: BigInt! + + # Slashing aggregates + "Total tokens slashed" + tokensSlashed: BigInt! + "Total tokens slashed from provisions" + tokensSlashedFromProvisions: BigInt! + "Total tokens slashed from delegation pools" + tokensSlashedFromDelegationPools: BigInt! } type ServiceProvider @entity(immutable: false) { "Service provider address" id: Bytes! + # Counts + "Number of active provisions" + countProvisions: Int! + "Provision slash events" + countProvisionSlashEvents: Int! + "Delegation pool slash events" + countDelegationPoolSlashEvents: Int! + # Stake "Tokens staked by the service provider" tokensStaked: BigInt! @@ -30,8 +54,22 @@ type ServiceProvider @entity(immutable: false) { tokensProvisioned: BigInt! "Tokens that are not locked in provisions" tokensIdle: BigInt! + "Tokens currently thawing from provisions" + tokensThawing: BigInt! + + # Delegation "Tokens delegated to this service provider" tokensDelegated: BigInt! + "Tokens currently thawing from delegation pools" + tokensDelegatedThawing: BigInt! + + # Slashing + "Total tokens slashed" + tokensSlashed: BigInt! + "Tokens slashed from provisions" + tokensSlashedFromProvisions: BigInt! + "Tokens slashed from delegation pools" + tokensSlashedFromDelegationPools: BigInt! # Provisions "Provisions created by this service provider" diff --git a/packages/subgraph/src/entities/graphNetwork.ts b/packages/subgraph/src/entities/graphNetwork.ts index 7be7349..49a05b9 100644 --- a/packages/subgraph/src/entities/graphNetwork.ts +++ b/packages/subgraph/src/entities/graphNetwork.ts @@ -5,12 +5,25 @@ export function getOrCreateGraphNetwork(): GraphNetwork { let entity = GraphNetwork.load(GRAPH_NETWORK_ID) if (entity == null) { entity = new GraphNetwork(GRAPH_NETWORK_ID) + + // Counts entity.countServiceProviders = 0 entity.countProvisions = 0 entity.countDelegationPools = 0 + entity.countProvisionSlashEvents = 0 + entity.countDelegationPoolSlashEvents = 0 + + // Stake aggregates entity.tokensStaked = BIGINT_ZERO entity.tokensProvisioned = BIGINT_ZERO entity.tokensDelegated = BIGINT_ZERO + entity.tokensThawingFromProvisions = BIGINT_ZERO + entity.tokensThawingFromDelegationPools = BIGINT_ZERO + + // Slashing aggregates + entity.tokensSlashed = BIGINT_ZERO + entity.tokensSlashedFromProvisions = BIGINT_ZERO + entity.tokensSlashedFromDelegationPools = BIGINT_ZERO } return entity } diff --git a/packages/subgraph/src/entities/serviceProvider.ts b/packages/subgraph/src/entities/serviceProvider.ts index 6c04cda..6e5571d 100644 --- a/packages/subgraph/src/entities/serviceProvider.ts +++ b/packages/subgraph/src/entities/serviceProvider.ts @@ -22,10 +22,28 @@ export function getOrCreateServiceProvider( if (entity == null) { entity = new ServiceProvider(id) + + // Counts + entity.countProvisions = 0 + entity.countProvisionSlashEvents = 0 + entity.countDelegationPoolSlashEvents = 0 + + // Stake entity.tokensStaked = BIGINT_ZERO entity.tokensProvisioned = BIGINT_ZERO entity.tokensIdle = BIGINT_ZERO + entity.tokensThawing = BIGINT_ZERO + + // Delegation entity.tokensDelegated = BIGINT_ZERO + entity.tokensDelegatedThawing = BIGINT_ZERO + + // Slashing + entity.tokensSlashed = BIGINT_ZERO + entity.tokensSlashedFromProvisions = BIGINT_ZERO + entity.tokensSlashedFromDelegationPools = BIGINT_ZERO + + // Metadata entity.createdAtBlock = blockNumber entity.createdAt = timestamp entity.updatedAtBlock = blockNumber diff --git a/packages/subgraph/src/handlers/delegation.ts b/packages/subgraph/src/handlers/delegation.ts index 9d69942..9956e1e 100644 --- a/packages/subgraph/src/handlers/delegation.ts +++ b/packages/subgraph/src/handlers/delegation.ts @@ -75,6 +75,17 @@ export function handleTokensUndelegated(event: TokensUndelegated): void { pool.entity.shares = pool.entity.shares.minus(shares) pool.entity.tokensThawing = pool.entity.tokensThawing.plus(tokens) saveDelegationPool(pool.entity, event.block) + + // Update ServiceProvider + let serviceProvider = getOrCreateServiceProvider(serviceProviderBytes, event.block.number, event.block.timestamp) + assert(!serviceProvider.isNew, "Service provider does not exist.") + serviceProvider.entity.tokensDelegatedThawing = serviceProvider.entity.tokensDelegatedThawing.plus(tokens) + saveServiceProvider(serviceProvider.entity, event.block) + + // Update GraphNetwork + let graphNetwork = getOrCreateGraphNetwork() + graphNetwork.tokensThawingFromDelegationPools = graphNetwork.tokensThawingFromDelegationPools.plus(tokens) + saveGraphNetwork(graphNetwork) } /** @@ -106,12 +117,16 @@ export function handleDelegatedTokensWithdrawn(event: DelegatedTokensWithdrawn): // Update ServiceProvider let serviceProvider = getOrCreateServiceProvider(serviceProviderBytes, event.block.number, event.block.timestamp) assert(!serviceProvider.isNew, "Service provider does not exist.") + assert(serviceProvider.entity.tokensDelegatedThawing >= tokens, "Withdraw tokens exceed service provider delegated tokens thawing.") + serviceProvider.entity.tokensDelegatedThawing = serviceProvider.entity.tokensDelegatedThawing.minus(tokens) assert(serviceProvider.entity.tokensDelegated >= tokens, "Withdraw tokens exceed service provider delegated tokens.") serviceProvider.entity.tokensDelegated = serviceProvider.entity.tokensDelegated.minus(tokens) saveServiceProvider(serviceProvider.entity, event.block) // Update GraphNetwork let graphNetwork = getOrCreateGraphNetwork() + assert(graphNetwork.tokensThawingFromDelegationPools >= tokens, "Withdraw tokens exceed network tokens thawing from delegation pools.") + graphNetwork.tokensThawingFromDelegationPools = graphNetwork.tokensThawingFromDelegationPools.minus(tokens) assert(graphNetwork.tokensDelegated >= tokens, "Withdraw tokens exceed network tokens delegated.") graphNetwork.tokensDelegated = graphNetwork.tokensDelegated.minus(tokens) if (pool.entity.tokens.equals(BIGINT_ZERO)) { @@ -150,12 +165,18 @@ export function handleDelegationSlashed(event: DelegationSlashed): void { assert(!serviceProvider.isNew, "Service provider does not exist.") assert(serviceProvider.entity.tokensDelegated >= tokens, "Slash tokens exceed service provider delegated tokens.") serviceProvider.entity.tokensDelegated = serviceProvider.entity.tokensDelegated.minus(tokens) + serviceProvider.entity.countDelegationPoolSlashEvents += 1 + serviceProvider.entity.tokensSlashed = serviceProvider.entity.tokensSlashed.plus(tokens) + serviceProvider.entity.tokensSlashedFromDelegationPools = serviceProvider.entity.tokensSlashedFromDelegationPools.plus(tokens) saveServiceProvider(serviceProvider.entity, event.block) // Update GraphNetwork let graphNetwork = getOrCreateGraphNetwork() assert(graphNetwork.tokensDelegated >= tokens, "Slash tokens exceed network tokens delegated.") graphNetwork.tokensDelegated = graphNetwork.tokensDelegated.minus(tokens) + graphNetwork.countDelegationPoolSlashEvents += 1 + graphNetwork.tokensSlashed = graphNetwork.tokensSlashed.plus(tokens) + graphNetwork.tokensSlashedFromDelegationPools = graphNetwork.tokensSlashedFromDelegationPools.plus(tokens) saveGraphNetwork(graphNetwork) } diff --git a/packages/subgraph/src/handlers/provision.ts b/packages/subgraph/src/handlers/provision.ts index 8397eb9..e0145ca 100644 --- a/packages/subgraph/src/handlers/provision.ts +++ b/packages/subgraph/src/handlers/provision.ts @@ -39,6 +39,7 @@ export function handleProvisionCreated(event: ProvisionCreated): void { // ServiceProvider assert(!serviceProvider.isNew, "Service provider does not exist.") + serviceProvider.entity.countProvisions += 1 serviceProvider.entity.tokensProvisioned = serviceProvider.entity.tokensProvisioned.plus(event.params.tokens) assert(serviceProvider.entity.tokensStaked >= serviceProvider.entity.tokensProvisioned, "Provisioned tokens exceed staked tokens.") serviceProvider.entity.tokensIdle = serviceProvider.entity.tokensStaked.minus(serviceProvider.entity.tokensProvisioned) @@ -86,9 +87,15 @@ export function handleProvisionIncreased(event: ProvisionIncreased): void { /** * Emitted when tokens begin thawing from a provision. - * Note: Thawing tokens are still considered "provisioned" . + * Note: Thawing tokens are still considered "provisioned". */ export function handleProvisionThawed(event: ProvisionThawed): void { + let graphNetwork = getOrCreateGraphNetwork() + let serviceProvider = getOrCreateServiceProvider( + event.params.serviceProvider, + event.block.number, + event.block.timestamp + ) let provision = getOrCreateProvision( event.params.serviceProvider, event.params.verifier, @@ -102,6 +109,15 @@ export function handleProvisionThawed(event: ProvisionThawed): void { provision.entity.tokens = provision.entity.tokens.minus(event.params.tokens) provision.entity.tokensThawing = provision.entity.tokensThawing.plus(event.params.tokens) saveProvision(provision.entity, event.block) + + // ServiceProvider + assert(!serviceProvider.isNew, "Service provider does not exist.") + serviceProvider.entity.tokensThawing = serviceProvider.entity.tokensThawing.plus(event.params.tokens) + saveServiceProvider(serviceProvider.entity, event.block) + + // GraphNetwork + graphNetwork.tokensThawingFromProvisions = graphNetwork.tokensThawingFromProvisions.plus(event.params.tokens) + saveGraphNetwork(graphNetwork) } /** @@ -129,6 +145,8 @@ export function handleTokensDeprovisioned(event: TokensDeprovisioned): void { // ServiceProvider assert(!serviceProvider.isNew, "Service provider does not exist.") + assert(serviceProvider.entity.tokensThawing >= event.params.tokens, "Deprovision exceeds service provider tokens thawing.") + serviceProvider.entity.tokensThawing = serviceProvider.entity.tokensThawing.minus(event.params.tokens) assert(serviceProvider.entity.tokensProvisioned >= event.params.tokens, "Deprovision exceeds service provider tokens provisioned.") serviceProvider.entity.tokensProvisioned = serviceProvider.entity.tokensProvisioned.minus(event.params.tokens) assert(serviceProvider.entity.tokensStaked >= serviceProvider.entity.tokensProvisioned, "Provisioned tokens exceed staked tokens.") @@ -136,6 +154,8 @@ export function handleTokensDeprovisioned(event: TokensDeprovisioned): void { saveServiceProvider(serviceProvider.entity, event.block) // GraphNetwork + assert(graphNetwork.tokensThawingFromProvisions >= event.params.tokens, "Deprovision exceeds network tokens thawing from provisions.") + graphNetwork.tokensThawingFromProvisions = graphNetwork.tokensThawingFromProvisions.minus(event.params.tokens) assert(graphNetwork.tokensProvisioned >= event.params.tokens, "Deprovision exceeds network tokens provisioned.") graphNetwork.tokensProvisioned = graphNetwork.tokensProvisioned.minus(event.params.tokens) saveGraphNetwork(graphNetwork) @@ -172,6 +192,9 @@ export function handleProvisionSlashed(event: ProvisionSlashed): void { serviceProvider.entity.tokensProvisioned = serviceProvider.entity.tokensProvisioned.minus(event.params.tokens) assert(serviceProvider.entity.tokensStaked >= serviceProvider.entity.tokensProvisioned, "Provisioned tokens exceed staked tokens.") serviceProvider.entity.tokensIdle = serviceProvider.entity.tokensStaked.minus(serviceProvider.entity.tokensProvisioned) + serviceProvider.entity.countProvisionSlashEvents += 1 + serviceProvider.entity.tokensSlashed = serviceProvider.entity.tokensSlashed.plus(event.params.tokens) + serviceProvider.entity.tokensSlashedFromProvisions = serviceProvider.entity.tokensSlashedFromProvisions.plus(event.params.tokens) saveServiceProvider(serviceProvider.entity, event.block) // GraphNetwork @@ -179,6 +202,9 @@ export function handleProvisionSlashed(event: ProvisionSlashed): void { assert(graphNetwork.tokensProvisioned >= event.params.tokens, "Slash exceeds network tokens provisioned.") graphNetwork.tokensStaked = graphNetwork.tokensStaked.minus(event.params.tokens) graphNetwork.tokensProvisioned = graphNetwork.tokensProvisioned.minus(event.params.tokens) + graphNetwork.countProvisionSlashEvents += 1 + graphNetwork.tokensSlashed = graphNetwork.tokensSlashed.plus(event.params.tokens) + graphNetwork.tokensSlashedFromProvisions = graphNetwork.tokensSlashedFromProvisions.plus(event.params.tokens) saveGraphNetwork(graphNetwork) } From 4b84892d8a768a797a75bfc1a6b45124e18f1560 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Migone?= Date: Fri, 15 May 2026 12:36:23 -0300 Subject: [PATCH 4/4] test: add better validation for service providers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomás Migone --- .claude/settings.local.json | 3 +- packages/subgraph/README.md | 11 +- packages/tools/README.md | 121 ++++++++ packages/tools/package.json | 7 +- packages/tools/src/common.ts | 109 ++++++- packages/tools/src/onchain.ts | 125 ++++++++ packages/tools/src/validation/delegations.ts | 193 ------------ packages/tools/src/validation/internal.ts | 282 ++++++++++++++++++ .../src/validation/onchain/delegations.ts | 92 ++++++ .../src/validation/onchain/provisions.ts | 101 +++++++ .../validation/onchain/service-providers.ts | 216 ++++++++++++++ packages/tools/src/validation/provisions.ts | 199 ------------ packages/tools/src/validation/stake.ts | 108 ------- 13 files changed, 1055 insertions(+), 512 deletions(-) create mode 100644 packages/tools/README.md delete mode 100644 packages/tools/src/validation/delegations.ts create mode 100644 packages/tools/src/validation/internal.ts create mode 100644 packages/tools/src/validation/onchain/delegations.ts create mode 100644 packages/tools/src/validation/onchain/provisions.ts create mode 100644 packages/tools/src/validation/onchain/service-providers.ts delete mode 100644 packages/tools/src/validation/provisions.ts delete mode 100644 packages/tools/src/validation/stake.ts diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 4043be3..b460e47 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -25,8 +25,7 @@ "Bash(npx tsx:*)", "Bash(cast sig:*)", "Bash(npx tsc:*)", - "Bash(npm run build:*)", - "Bash(npm run test:*)" + "Bash(pnpm exec tsc:*)" ] } } diff --git a/packages/subgraph/README.md b/packages/subgraph/README.md index 9e9b2b0..a82f78f 100644 --- a/packages/subgraph/README.md +++ b/packages/subgraph/README.md @@ -12,7 +12,7 @@ The Graph Horizon Network subgraph is an **aggregate state subgraph** that track Entities are updated in place as events occur. Data services and payment collectors are discovered dynamically via staking and escrow events, but are tracked generically without service-specific or collector-specific details. -### What this subgraph is NOT +## What this subgraph is NOT - **Not historical**: This subgraph does not track event-by-event history. Individual stake deposits, slashes, or delegation details are not recorded. @@ -22,7 +22,7 @@ Entities are updated in place as events occur. Data services and payment collect - **Not delegator-level**: Individual Delegator and Delegation entities are not included. Only pool-level aggregates (DelegationPool) are tracked. -### What data is indexed? +## What data is indexed? This subgraph begins indexing at **Horizon genesis**, not protocol genesis. The Graph protocol existed before the Horizon upgrade, so pre-existing state that is relevant such as stake or delegations needs to be backfilled to have a correct view of the network state. @@ -32,4 +32,9 @@ At the Horizon genesis block, a one-time migration seeds entities by reading cur - **Delegation pools**: Pool state (tokens, shares, tokensThawing) is read from the contract for all service providers with `delegated tokens > 0` at Graph Horizon genesis. -**Important**: With this approach pre-Horizon history is "flattened" into these snapshots. Cumulative fields like `tokensStaked` include pre-Horizon values, but event-driven fields (e.g., query fees collected) only track activity from Horizon onwards. \ No newline at end of file +### Important caveats + +1. **Migrated data**: As described before, the migration strategy "flattens" pre-Horizon history into snapshots. Cumulative fields that represent current state include aggregated pre-Horizon values, but event-driven fields (e.g., query fees collected) only track activity from Horizon onwards. For example: + - `tokensStaked` includes stake deposits/thaws made before/after Horizon. This is expected. However it also aggregates all rewards and query fees that were direclty deposited into the service provider's stake before Horizon, that stake is also counted towards `tokensStaked` as it is the "starting stake" at Horizon genesis. + - `tokensCollected` tracks payments (indexing rewards and query fees) only after Horizon. Pre-horizon data for collections is not available in this subgraph. +2. **Legacy allocations**: During Horizon's transition period legacy allocations were allowed to exist but forced to be eventually closed. To simplify the subgraph this is not being tracked. The consequence is that stake balances in the subgraph will be incorrect for service providers untill all their legacy allocations are closed down. For most service providers this is ~30 days after Horizon genesis (Dec 9th 2025). \ No newline at end of file diff --git a/packages/tools/README.md b/packages/tools/README.md new file mode 100644 index 0000000..30da04d --- /dev/null +++ b/packages/tools/README.md @@ -0,0 +1,121 @@ +# Graph Horizon Tools + +Utilities for validating and seeding the Graph Horizon subgraph. + +## Validation Scripts + +Validation is split into two categories: + +- **Internal validation** - Checks consistency within the subgraph data (fast, no RPC needed) +- **On-chain validation** - Compares subgraph data against on-chain contract state (slower, requires RPC) + +### Internal Validation + +```bash +pnpm validate:internal +``` + +Validates that the subgraph data is internally consistent. This catches mapping bugs where aggregates drift out of sync with individual entities. + +#### GraphNetwork Count Checks + +| Field | Expected Value | +|-------|----------------| +| `countServiceProviders` | Number of ServiceProvider entities with `tokensStaked > 0` | +| `countProvisions` | Number of Provision entities | +| `countDelegationPools` | Number of DelegationPool entities with `tokens > 0` | + +#### GraphNetwork Sum Checks + +| Field | Expected Value | +|-------|----------------| +| `tokensStaked` | Sum of `ServiceProvider.tokensStaked` | +| `tokensProvisioned` | Sum of `Provision.tokens` | +| `tokensDelegated` | Sum of `DelegationPool.tokens` | +| `tokensThawingFromProvisions` | Sum of `Provision.tokensThawing` | +| `tokensThawingFromDelegationPools` | Sum of `DelegationPool.tokensThawing` | + +#### ServiceProvider Aggregate Checks + +For each ServiceProvider, validates that aggregate fields match the sum of their child entities: + +| Field | Expected Value | +|-------|----------------| +| `tokensProvisioned` | Sum of `Provision.tokens` for this SP | +| `tokensThawing` | Sum of `Provision.tokensThawing` for this SP | +| `tokensDelegated` | Sum of `DelegationPool.tokens` for this SP | +| `tokensDelegatedThawing` | Sum of `DelegationPool.tokensThawing` for this SP | +| `tokensIdle` | `tokensStaked - tokensProvisioned` | + +### On-Chain Validation + +These scripts compare subgraph entity fields against on-chain contract state by calling view functions on the HorizonStaking contract. + +#### ServiceProvider Validation + +```bash +NETWORK=arbitrum-one pnpm validate:onchain:service-providers +``` + +For each ServiceProvider, compares: + +| Subgraph Field | On-Chain Source | +|----------------|-----------------| +| `tokensStaked` | `getServiceProvider(address).tokensStaked` | +| `tokensProvisioned` | `getServiceProvider(address).tokensProvisioned` | +| `tokensThawing` | Sum of `getProvision(sp, verifier).tokensThawing` for all SP's provisions | +| `tokensDelegated` | Sum of `getDelegationPool(sp, verifier).tokens` for all SP's pools | +| `tokensDelegatedThawing` | Sum of `getDelegationPool(sp, verifier).tokensThawing` for all SP's pools | + +**Note** that `tokensIdle` is implicitly validated by checking `tokensStaked` and `tokensProvisioned` as it's a computed value derived from those two. + +#### Provisions Validation + +```bash +NETWORK=arbitrum-one pnpm validate:onchain:provisions +``` + +For each Provision, compares: + +| Subgraph Field | Contract Field | +|----------------|----------------| +| `tokens` | `Provision.tokens` | +| `tokensThawing` | `Provision.tokensThawing` | +| `maxVerifierCut` | `Provision.maxVerifierCut` | +| `thawingPeriod` | `Provision.thawingPeriod` | +| `maxVerifierCutPending` | `Provision.maxVerifierCutPending` | +| `thawingPeriodPending` | `Provision.thawingPeriodPending` | + +Contract call: `HorizonStaking.getProvision(serviceProvider, verifier)` + +#### Delegations Validation + +```bash +NETWORK=arbitrum-one pnpm validate:onchain:delegations +``` + +For each DelegationPool, compares: + +| Subgraph Field | Contract Field | +|----------------|----------------| +| `tokens` | `DelegationPool.tokens` | +| `shares` | `DelegationPool.shares` | +| `tokensThawing` | `DelegationPool.tokensThawing` | + +Contract call: `HorizonStaking.getDelegationPool(serviceProvider, verifier)` + +## Environment Variables + +For on-chain validation scripts: + +| Variable | Description | +|----------|-------------| +| `NETWORK` | Network name (e.g., `arbitrum-one`, `arbitrum-sepolia`) | + +Network configuration is loaded from `src/config.ts`. + +## Exit Codes + +All validation scripts exit with: +- `0` - All checks passed +- `1` - One or more checks failed or an error occurred diff --git a/packages/tools/package.json b/packages/tools/package.json index c575b41..8905aec 100644 --- a/packages/tools/package.json +++ b/packages/tools/package.json @@ -6,9 +6,10 @@ "scripts": { "seed:indexers": "tsx src/seed/indexers.ts", "seed:delegations": "tsx src/seed/delegations.ts", - "validate:stake": "tsx src/validation/stake.ts", - "validate:provisions": "tsx src/validation/provisions.ts", - "validate:delegations": "tsx src/validation/delegations.ts" + "validate:internal": "tsx src/validation/internal.ts", + "validate:onchain:service-providers": "tsx src/validation/onchain/service-providers.ts", + "validate:onchain:provisions": "tsx src/validation/onchain/provisions.ts", + "validate:onchain:delegations": "tsx src/validation/onchain/delegations.ts" }, "devDependencies": { "@types/node": "22.15.18", diff --git a/packages/tools/src/common.ts b/packages/tools/src/common.ts index 351a007..7187ed7 100644 --- a/packages/tools/src/common.ts +++ b/packages/tools/src/common.ts @@ -4,6 +4,10 @@ import { getConfig } from "./config" +// ============================================================================ +// Subgraph Queries +// ============================================================================ + export async function querySubgraph(url: string, query: string): Promise { const response = await fetch(url, { method: "POST", @@ -17,6 +21,10 @@ export async function querySubgraph(url: string, query: string): Promise { return json.data } +// ============================================================================ +// Formatting +// ============================================================================ + export function formatGRT(wei: bigint): string { const decimals = 18n const divisor = 10n ** decimals @@ -26,19 +34,32 @@ export function formatGRT(wei: bigint): string { return `${whole.toLocaleString()}.${fractionStr} GRT` } -// Rate limiting delay between RPC calls +export function formatValue(value: bigint, isTokens: boolean): string { + return isTokens ? formatGRT(value) : value.toString() +} + +// ============================================================================ +// Rate Limiting +// ============================================================================ + export const RPC_DELAY_MS = 50 export async function delay(ms: number = RPC_DELAY_MS): Promise { return new Promise((resolve) => setTimeout(resolve, ms)) } -export function printHeader(subgraphUrl: string): void { +// ============================================================================ +// CLI Helpers +// ============================================================================ + +export function printHeader(subgraphUrl: string, includeRpc: boolean = true): void { const config = getConfig() console.log(`Network: ${config.name}`) console.log(`Subgraph URL: ${subgraphUrl}`) - console.log(`RPC URL: ${config.rpcUrl}`) - console.log(`Staking contract: ${config.stakingAddress}`) + if (includeRpc) { + console.log(`RPC URL: ${config.rpcUrl}`) + console.log(`Staking contract: ${config.stakingAddress}`) + } console.log("") } @@ -51,3 +72,83 @@ export function getSubgraphUrlFromArgs(): string { } return url } + +export async function runValidation(main: () => Promise): Promise { + try { + const exitCode = await main() + process.exit(exitCode) + } catch (err) { + console.error("Error:", err) + process.exit(1) + } +} + +// ============================================================================ +// Validation Utilities +// ============================================================================ + +export interface FieldComparison { + match: boolean + message?: string +} + +export function compareField( + name: string, + subgraphValue: bigint, + onChainValue: bigint, + isTokens = false +): FieldComparison { + if (subgraphValue === onChainValue) { + return { match: true } + } + const subgraphStr = formatValue(subgraphValue, isTokens) + const onChainStr = formatValue(onChainValue, isTokens) + return { + match: false, + message: ` ${name}: subgraph=${subgraphStr}, chain=${onChainStr}`, + } +} + +export interface ValidationResult { + label: string + total: number + matches: number + mismatches: number +} + +export function printValidationSummary(results: ValidationResult[]): void { + console.log("=== Summary ===") + for (const result of results) { + console.log(`${result.label}:`) + console.log(` Total: ${result.total}`) + console.log(` Matches: ${result.matches}`) + console.log(` Mismatches: ${result.mismatches}`) + console.log("") + } +} + +export function validateCount(label: string, actual: number, expected: number): boolean { + if (actual !== expected) { + console.log(`WARNING: ${label} count mismatch - expected ${expected}, found ${actual}`) + console.log("") + return false + } + return true +} + +export function validateSum( + label: string, + items: T[], + field: keyof T, + expected: bigint +): boolean { + const actual = items.reduce((sum, item) => sum + BigInt(item[field] as string), 0n) + if (actual !== expected) { + console.log(`WARNING: ${label} sum mismatch`) + console.log(` Expected: ${formatGRT(expected)}`) + console.log(` Actual: ${formatGRT(actual)}`) + console.log("") + return false + } + return true +} diff --git a/packages/tools/src/onchain.ts b/packages/tools/src/onchain.ts index e546a14..7afbd54 100644 --- a/packages/tools/src/onchain.ts +++ b/packages/tools/src/onchain.ts @@ -5,6 +5,7 @@ const GET_STAKE_SELECTOR = "0x7a766460" // getStake(address) const GET_SERVICE_PROVIDER_SELECTOR = "0x8cc01c86" // getServiceProvider(address) const GET_PROVISION_SELECTOR = "0x25d9897e" // getProvision(address,address) const GET_DELEGATION_POOL_SELECTOR = "0x561285e4" // getDelegationPool(address,address) +const MULTICALL_SELECTOR = "0xac9650d8" // multicall(bytes[]) export interface ServiceProviderData { tokensStaked: bigint @@ -128,3 +129,127 @@ export async function getDelegationPool(serviceProvider: string, verifier: strin thawingNonce: BigInt("0x" + hex.slice(256, 320)), } } + +// ============================================================================ +// Multicall +// ============================================================================ + +// Encode call data helpers (for use with multicall) +export function encodeGetServiceProvider(address: string): string { + return GET_SERVICE_PROVIDER_SELECTOR + padAddress(address) +} + +export function encodeGetProvision(serviceProvider: string, verifier: string): string { + return GET_PROVISION_SELECTOR + padAddress(serviceProvider) + padAddress(verifier) +} + +export function encodeGetDelegationPool(serviceProvider: string, verifier: string): string { + return GET_DELEGATION_POOL_SELECTOR + padAddress(serviceProvider) + padAddress(verifier) +} + +// Decode result helpers +export function decodeServiceProviderResult(hex: string): ServiceProviderData { + const data = hex.startsWith("0x") ? hex.slice(2) : hex + return { + tokensStaked: BigInt("0x" + data.slice(0, 64)), + tokensProvisioned: BigInt("0x" + data.slice(64, 128)), + } +} + +export function decodeProvisionResult(hex: string): ProvisionData { + const data = hex.startsWith("0x") ? hex.slice(2) : hex + return { + tokens: BigInt("0x" + data.slice(0, 64)), + tokensThawing: BigInt("0x" + data.slice(64, 128)), + sharesThawing: BigInt("0x" + data.slice(128, 192)), + maxVerifierCut: BigInt("0x" + data.slice(192, 256)), + thawingPeriod: BigInt("0x" + data.slice(256, 320)), + createdAt: BigInt("0x" + data.slice(320, 384)), + maxVerifierCutPending: BigInt("0x" + data.slice(384, 448)), + thawingPeriodPending: BigInt("0x" + data.slice(448, 512)), + lastParametersStagedAt: BigInt("0x" + data.slice(512, 576)), + thawingNonce: BigInt("0x" + data.slice(576, 640)), + } +} + +export function decodeDelegationPoolResult(hex: string): DelegationPoolData { + const data = hex.startsWith("0x") ? hex.slice(2) : hex + return { + tokens: BigInt("0x" + data.slice(0, 64)), + shares: BigInt("0x" + data.slice(64, 128)), + tokensThawing: BigInt("0x" + data.slice(128, 192)), + sharesThawing: BigInt("0x" + data.slice(192, 256)), + thawingNonce: BigInt("0x" + data.slice(256, 320)), + } +} + +/** + * Executes multiple calls in a single RPC request using HorizonStaking's built-in multicall. + * @param calls Array of encoded call data (without 0x prefix is fine) + * @returns Array of result hex strings + */ +export async function multicall(calls: string[]): Promise { + const config = getConfig() + + // ABI encode bytes[] parameter + // - offset to array data (32 bytes): 0x20 + // - array length (32 bytes) + // - offsets to each bytes element (32 bytes each) + // - each bytes element: length (32 bytes) + data (padded to 32 bytes) + + const normalizedCalls = calls.map((c) => (c.startsWith("0x") ? c.slice(2) : c)) + + // Calculate offsets for each element + // Offsets are relative to the start of the array data (after the length) + const headerSize = normalizedCalls.length * 32 // space for all offset pointers + const offsets: number[] = [] + let currentOffset = headerSize + + for (const call of normalizedCalls) { + offsets.push(currentOffset) + const dataLen = call.length / 2 // bytes length + const paddedLen = Math.ceil(dataLen / 32) * 32 + currentOffset += 32 + paddedLen // 32 for length + padded data + } + + // Build the encoded data + let encoded = MULTICALL_SELECTOR.slice(2) // remove 0x + encoded += "0000000000000000000000000000000000000000000000000000000000000020" // offset to array = 32 + encoded += normalizedCalls.length.toString(16).padStart(64, "0") // array length + + // Add offsets + for (const offset of offsets) { + encoded += offset.toString(16).padStart(64, "0") + } + + // Add each bytes element + for (const call of normalizedCalls) { + const dataLen = call.length / 2 + encoded += dataLen.toString(16).padStart(64, "0") // length + const paddedLen = Math.ceil(dataLen / 32) * 32 + encoded += call.padEnd(paddedLen * 2, "0") // data padded to 32-byte boundary + } + + const result = await ethCall(config.stakingAddress, "0x" + encoded) + + // Decode bytes[] result + // - offset to array data (32 bytes) + // - array length (32 bytes) + // - offsets to each bytes element + // - each bytes element: length + data + const resultHex = result.slice(2) + const resultLength = parseInt(resultHex.slice(64, 128), 16) + + const results: string[] = [] + for (let i = 0; i < resultLength; i++) { + const offsetPos = 128 + i * 64 + const offset = parseInt(resultHex.slice(offsetPos, offsetPos + 64), 16) * 2 + const lengthPos = 64 + offset // relative to after the initial offset + const length = parseInt(resultHex.slice(lengthPos, lengthPos + 64), 16) + const dataStart = lengthPos + 64 + const data = resultHex.slice(dataStart, dataStart + length * 2) + results.push("0x" + data) + } + + return results +} diff --git a/packages/tools/src/validation/delegations.ts b/packages/tools/src/validation/delegations.ts deleted file mode 100644 index 832c70d..0000000 --- a/packages/tools/src/validation/delegations.ts +++ /dev/null @@ -1,193 +0,0 @@ -/** - * Validates subgraph DelegationPool entities against on-chain HorizonStaking.getDelegationPool() - * Also validates ServiceProvider.tokensDelegated and GraphNetwork.tokensDelegated. - * - * Usage: NETWORK=arbitrum-one pnpm validate:delegations - */ - -import { getDelegationPool } from "../onchain" -import { querySubgraph, formatGRT, getSubgraphUrlFromArgs, printHeader, delay } from "../common" - -interface SubgraphDelegationPool { - id: string - serviceProvider: { id: string } - verifier: string - tokens: string - shares: string - tokensThawing: string -} - -interface SubgraphServiceProvider { - id: string - tokensDelegated: string -} - -interface GraphNetwork { - id: string - tokensDelegated: string - countDelegationPools: number -} - -function compareField( - name: string, - subgraphValue: bigint, - onChainValue: bigint, - isTokens = false -): { match: boolean; message?: string } { - if (subgraphValue === onChainValue) { - return { match: true } - } - const subgraphStr = isTokens ? formatGRT(subgraphValue) : subgraphValue.toString() - const onChainStr = isTokens ? formatGRT(onChainValue) : onChainValue.toString() - return { - match: false, - message: ` ${name}: subgraph=${subgraphStr}, chain=${onChainStr}`, - } -} - -async function main() { - const subgraphUrl = getSubgraphUrlFromArgs() - printHeader(subgraphUrl) - - // Fetch GraphNetwork - const networkData = await querySubgraph<{ graphNetwork: GraphNetwork }>( - subgraphUrl, - `{ graphNetwork(id: "0x01000000") { id tokensDelegated countDelegationPools } }` - ) - const graphNetwork = networkData.graphNetwork - - if (!graphNetwork) { - console.error("GraphNetwork entity not found") - process.exit(1) - } - - console.log("=== GraphNetwork ===") - console.log(` countDelegationPools: ${graphNetwork.countDelegationPools}`) - console.log(` tokensDelegated: ${formatGRT(BigInt(graphNetwork.tokensDelegated))}`) - console.log("") - - // Fetch all DelegationPools - console.log("=== Fetching DelegationPools ===") - const poolData = await querySubgraph<{ delegationPools: SubgraphDelegationPool[] }>( - subgraphUrl, - `{ delegationPools(first: 1000, orderBy: tokens, orderDirection: desc) { - id - serviceProvider { id } - verifier - tokens - shares - tokensThawing - } }` - ) - const pools = poolData.delegationPools - - console.log(` Found ${pools.length} delegation pools`) - console.log("") - - // Validate count - if (pools.length !== graphNetwork.countDelegationPools) { - console.log( - `WARNING: Pool count mismatch - GraphNetwork says ${graphNetwork.countDelegationPools}, found ${pools.length}` - ) - console.log("") - } - - // Validate sum of tokens - const subgraphSum = pools.reduce((sum, p) => sum + BigInt(p.tokens), 0n) - if (subgraphSum.toString() !== graphNetwork.tokensDelegated) { - console.log(`WARNING: tokensDelegated sum mismatch`) - console.log(` GraphNetwork.tokensDelegated: ${formatGRT(BigInt(graphNetwork.tokensDelegated))}`) - console.log(` Sum of pool tokens: ${formatGRT(subgraphSum)}`) - console.log("") - } - - // Compare each DelegationPool against on-chain - console.log("=== Comparing DelegationPools against on-chain state ===") - let poolMismatches = 0 - let poolMatches = 0 - - for (const pool of pools) { - const onChain = await getDelegationPool(pool.serviceProvider.id, pool.verifier) - - const fields = [ - compareField("tokens", BigInt(pool.tokens), onChain.tokens, true), - compareField("shares", BigInt(pool.shares), onChain.shares), - compareField("tokensThawing", BigInt(pool.tokensThawing), onChain.tokensThawing, true), - ] - - const mismatches = fields.filter((f) => !f.match) - if (mismatches.length > 0) { - poolMismatches++ - console.log(`MISMATCH: ${pool.serviceProvider.id} -> ${pool.verifier}`) - for (const m of mismatches) { - console.log(m.message) - } - console.log("") - } else { - poolMatches++ - } - - await delay() - } - - // Fetch and validate ServiceProviders - console.log("=== Validating ServiceProvider.tokensDelegated ===") - const spData = await querySubgraph<{ serviceProviders: SubgraphServiceProvider[] }>( - subgraphUrl, - `{ serviceProviders(first: 1000, where: { tokensDelegated_gt: "0" }) { id tokensDelegated } }` - ) - const serviceProviders = spData.serviceProviders - - console.log(` Found ${serviceProviders.length} service providers with delegations`) - console.log("") - - let spMismatches = 0 - let spMatches = 0 - - for (const sp of serviceProviders) { - // Sum up all delegation pools for this SP - const spPools = pools.filter((p) => p.serviceProvider.id === sp.id) - const onChainSum = await Promise.all( - spPools.map(async (p) => { - const onChain = await getDelegationPool(p.serviceProvider.id, p.verifier) - await delay() - return onChain.tokens - }) - ).then((tokens) => tokens.reduce((sum, t) => sum + t, 0n)) - - const subgraphDelegated = BigInt(sp.tokensDelegated) - if (subgraphDelegated !== onChainSum) { - spMismatches++ - console.log(`MISMATCH: ${sp.id}`) - console.log(` tokensDelegated: subgraph=${formatGRT(subgraphDelegated)}, chain=${formatGRT(onChainSum)}`) - console.log("") - } else { - spMatches++ - } - } - - // Summary - console.log("=== Summary ===") - console.log(`DelegationPools:`) - console.log(` Total: ${pools.length}`) - console.log(` Matches: ${poolMatches}`) - console.log(` Mismatches: ${poolMismatches}`) - console.log("") - console.log(`ServiceProviders (tokensDelegated):`) - console.log(` Total: ${serviceProviders.length}`) - console.log(` Matches: ${spMatches}`) - console.log(` Mismatches: ${spMismatches}`) - - const totalMismatches = poolMismatches + spMismatches - if (totalMismatches === 0) { - console.log("") - console.log("All delegation pools match on-chain state!") - } - - process.exit(totalMismatches > 0 ? 1 : 0) -} - -main().catch((err) => { - console.error("Error:", err) - process.exit(1) -}) diff --git a/packages/tools/src/validation/internal.ts b/packages/tools/src/validation/internal.ts new file mode 100644 index 0000000..49724e2 --- /dev/null +++ b/packages/tools/src/validation/internal.ts @@ -0,0 +1,282 @@ +/** + * Validates internal consistency of subgraph data. + * Checks that aggregates match entity sums and counts match entity counts. + * This is fast (no RPC calls) and catches mapping bugs. + * + * Usage: pnpm validate:internal + */ + +import { + querySubgraph, + formatGRT, + getSubgraphUrlFromArgs, + printHeader, + runValidation, + validateCount, + validateSum, +} from "../common" + +// ============================================================================ +// Types +// ============================================================================ + +interface GraphNetwork { + id: string + countServiceProviders: number + countProvisions: number + countDelegationPools: number + tokensStaked: string + tokensProvisioned: string + tokensDelegated: string + tokensThawingFromProvisions: string + tokensThawingFromDelegationPools: string +} + +interface ServiceProvider { + id: string + tokensStaked: string + tokensProvisioned: string + tokensDelegated: string + tokensThawing: string + tokensDelegatedThawing: string + tokensIdle: string +} + +interface Provision { + id: string + serviceProvider: { id: string } + tokens: string + tokensThawing: string +} + +interface DelegationPool { + id: string + serviceProvider: { id: string } + tokens: string + tokensThawing: string +} + +// ============================================================================ +// Queries +// ============================================================================ + +const GRAPH_NETWORK_QUERY = `{ + graphNetwork(id: "0x01000000") { + id + countServiceProviders + countProvisions + countDelegationPools + tokensStaked + tokensProvisioned + tokensDelegated + tokensThawingFromProvisions + tokensThawingFromDelegationPools + } +}` + +const SERVICE_PROVIDERS_QUERY = `{ + serviceProviders(first: 1000, orderBy: tokensStaked, orderDirection: desc) { + id + tokensStaked + tokensProvisioned + tokensDelegated + tokensThawing + tokensDelegatedThawing + tokensIdle + } +}` + +const PROVISIONS_QUERY = `{ + provisions(first: 1000) { + id + serviceProvider { id } + tokens + tokensThawing + } +}` + +const DELEGATION_POOLS_QUERY = `{ + delegationPools(first: 1000) { + id + serviceProvider { id } + tokens + tokensThawing + } +}` + +// ============================================================================ +// Main +// ============================================================================ + +async function main(): Promise { + const subgraphUrl = getSubgraphUrlFromArgs() + printHeader(subgraphUrl, false) + + let warnings = 0 + + // Fetch all data + console.log("=== Fetching subgraph data ===") + const [networkData, spData, provisionData, poolData] = await Promise.all([ + querySubgraph<{ graphNetwork: GraphNetwork }>(subgraphUrl, GRAPH_NETWORK_QUERY), + querySubgraph<{ serviceProviders: ServiceProvider[] }>(subgraphUrl, SERVICE_PROVIDERS_QUERY), + querySubgraph<{ provisions: Provision[] }>(subgraphUrl, PROVISIONS_QUERY), + querySubgraph<{ delegationPools: DelegationPool[] }>(subgraphUrl, DELEGATION_POOLS_QUERY), + ]) + + const graphNetwork = networkData.graphNetwork + if (!graphNetwork) { + console.error("GraphNetwork entity not found") + return 1 + } + + const serviceProviders = spData.serviceProviders + const provisions = provisionData.provisions + const pools = poolData.delegationPools + + // Filter to only SPs with stake > 0 (matches countServiceProviders semantics) + const stakedSPs = serviceProviders.filter((sp) => BigInt(sp.tokensStaked) > 0n) + + // Filter to only pools with tokens > 0 (matches countDelegationPools semantics) + const activePools = pools.filter((p) => BigInt(p.tokens) > 0n) + + console.log(` GraphNetwork: found`) + console.log(` ServiceProviders: ${serviceProviders.length} total, ${stakedSPs.length} with stake`) + console.log(` Provisions: ${provisions.length}`) + console.log(` DelegationPools: ${pools.length} total, ${activePools.length} with tokens`) + console.log("") + + // ============================================================================ + // GraphNetwork Count Validations + // ============================================================================ + + console.log("=== GraphNetwork Count Validations ===") + + if (!validateCount("ServiceProviders", stakedSPs.length, graphNetwork.countServiceProviders)) { + warnings++ + } + + if (!validateCount("Provisions", provisions.length, graphNetwork.countProvisions)) { + warnings++ + } + + if (!validateCount("DelegationPools", activePools.length, graphNetwork.countDelegationPools)) { + warnings++ + } + + if (warnings === 0) { + console.log("All counts match!") + console.log("") + } + + // ============================================================================ + // GraphNetwork Sum Validations + // ============================================================================ + + console.log("=== GraphNetwork Sum Validations ===") + const sumWarningsBefore = warnings + + // tokensStaked: sum of SP.tokensStaked + if (!validateSum("tokensStaked", serviceProviders, "tokensStaked", BigInt(graphNetwork.tokensStaked))) { + warnings++ + } + + // tokensProvisioned: sum of Provision.tokens (not SP.tokensProvisioned, to catch SP aggregate drift) + if (!validateSum("tokensProvisioned", provisions, "tokens", BigInt(graphNetwork.tokensProvisioned))) { + warnings++ + } + + // tokensDelegated: sum of DelegationPool.tokens + if (!validateSum("tokensDelegated", pools, "tokens", BigInt(graphNetwork.tokensDelegated))) { + warnings++ + } + + // tokensThawingFromProvisions: sum of Provision.tokensThawing + if (!validateSum("tokensThawingFromProvisions", provisions, "tokensThawing", BigInt(graphNetwork.tokensThawingFromProvisions))) { + warnings++ + } + + // tokensThawingFromDelegationPools: sum of DelegationPool.tokensThawing + if (!validateSum("tokensThawingFromDelegationPools", pools, "tokensThawing", BigInt(graphNetwork.tokensThawingFromDelegationPools))) { + warnings++ + } + + if (warnings === sumWarningsBefore) { + console.log("All sums match!") + console.log("") + } + + // ============================================================================ + // ServiceProvider Aggregate Validations + // ============================================================================ + + console.log("=== ServiceProvider Aggregate Validations ===") + let spWarnings = 0 + + for (const sp of serviceProviders) { + const spProvisions = provisions.filter((p) => p.serviceProvider.id === sp.id) + const spPools = pools.filter((p) => p.serviceProvider.id === sp.id) + + const issues: string[] = [] + + // tokensProvisioned should equal sum of provision tokens + const provisionedSum = spProvisions.reduce((sum, p) => sum + BigInt(p.tokens), 0n) + if (BigInt(sp.tokensProvisioned) !== provisionedSum) { + issues.push(`tokensProvisioned: SP=${formatGRT(BigInt(sp.tokensProvisioned))}, sum=${formatGRT(provisionedSum)}`) + } + + // tokensThawing should equal sum of provision tokensThawing + const thawingSum = spProvisions.reduce((sum, p) => sum + BigInt(p.tokensThawing), 0n) + if (BigInt(sp.tokensThawing) !== thawingSum) { + issues.push(`tokensThawing: SP=${formatGRT(BigInt(sp.tokensThawing))}, sum=${formatGRT(thawingSum)}`) + } + + // tokensDelegated should equal sum of pool tokens + const delegatedSum = spPools.reduce((sum, p) => sum + BigInt(p.tokens), 0n) + if (BigInt(sp.tokensDelegated) !== delegatedSum) { + issues.push(`tokensDelegated: SP=${formatGRT(BigInt(sp.tokensDelegated))}, sum=${formatGRT(delegatedSum)}`) + } + + // tokensDelegatedThawing should equal sum of pool tokensThawing + const delegatedThawingSum = spPools.reduce((sum, p) => sum + BigInt(p.tokensThawing), 0n) + if (BigInt(sp.tokensDelegatedThawing) !== delegatedThawingSum) { + issues.push(`tokensDelegatedThawing: SP=${formatGRT(BigInt(sp.tokensDelegatedThawing))}, sum=${formatGRT(delegatedThawingSum)}`) + } + + // tokensIdle should equal tokensStaked - tokensProvisioned + const expectedIdle = BigInt(sp.tokensStaked) - BigInt(sp.tokensProvisioned) + if (BigInt(sp.tokensIdle) !== expectedIdle) { + issues.push(`tokensIdle: SP=${formatGRT(BigInt(sp.tokensIdle))}, expected=${formatGRT(expectedIdle)}`) + } + + if (issues.length > 0) { + spWarnings++ + console.log(`WARNING: ${sp.id}`) + for (const issue of issues) { + console.log(` ${issue}`) + } + console.log("") + } + } + + if (spWarnings === 0) { + console.log("All ServiceProvider aggregates match!") + console.log("") + } + + warnings += spWarnings + + // ============================================================================ + // Summary + // ============================================================================ + + console.log("=== Summary ===") + if (warnings === 0) { + console.log("All internal consistency checks passed!") + } else { + console.log(`Found ${warnings} warning(s)`) + } + + return warnings > 0 ? 1 : 0 +} + +runValidation(main) diff --git a/packages/tools/src/validation/onchain/delegations.ts b/packages/tools/src/validation/onchain/delegations.ts new file mode 100644 index 0000000..d7cb049 --- /dev/null +++ b/packages/tools/src/validation/onchain/delegations.ts @@ -0,0 +1,92 @@ +/** + * Validates subgraph DelegationPool entities against on-chain HorizonStaking.getDelegationPool() + * + * Usage: NETWORK=arbitrum-one pnpm validate:onchain:delegations + */ + +import { getDelegationPool } from "../../onchain" +import { + querySubgraph, + getSubgraphUrlFromArgs, + printHeader, + delay, + runValidation, + compareField, + printValidationSummary, + type ValidationResult, +} from "../../common" + +interface DelegationPool { + id: string + serviceProvider: { id: string } + verifier: string + tokens: string + shares: string + tokensThawing: string +} + +async function main(): Promise { + const subgraphUrl = getSubgraphUrlFromArgs() + printHeader(subgraphUrl) + + // Fetch all DelegationPools + console.log("=== Fetching DelegationPools ===") + const poolData = await querySubgraph<{ delegationPools: DelegationPool[] }>( + subgraphUrl, + `{ delegationPools(first: 1000, orderBy: tokens, orderDirection: desc) { + id + serviceProvider { id } + verifier + tokens + shares + tokensThawing + } }` + ) + const pools = poolData.delegationPools + + console.log(` Found ${pools.length} delegation pools`) + console.log("") + + // Compare each DelegationPool against on-chain + console.log("=== Comparing DelegationPools against on-chain state ===") + let mismatches = 0 + let matches = 0 + + for (const pool of pools) { + const onChain = await getDelegationPool(pool.serviceProvider.id, pool.verifier) + + const fields = [ + compareField("tokens", BigInt(pool.tokens), onChain.tokens, true), + compareField("shares", BigInt(pool.shares), onChain.shares), + compareField("tokensThawing", BigInt(pool.tokensThawing), onChain.tokensThawing, true), + ] + + const fieldMismatches = fields.filter((f) => !f.match) + if (fieldMismatches.length > 0) { + mismatches++ + console.log(`MISMATCH: ${pool.serviceProvider.id} -> ${pool.verifier}`) + for (const m of fieldMismatches) { + console.log(m.message) + } + console.log("") + } else { + matches++ + } + + await delay() + } + + // Summary + const results: ValidationResult[] = [ + { label: "DelegationPools", total: pools.length, matches, mismatches }, + ] + printValidationSummary(results) + + if (mismatches === 0) { + console.log("All delegation pools match on-chain state!") + } + + return mismatches > 0 ? 1 : 0 +} + +runValidation(main) diff --git a/packages/tools/src/validation/onchain/provisions.ts b/packages/tools/src/validation/onchain/provisions.ts new file mode 100644 index 0000000..c5f058b --- /dev/null +++ b/packages/tools/src/validation/onchain/provisions.ts @@ -0,0 +1,101 @@ +/** + * Validates subgraph Provision entities against on-chain HorizonStaking.getProvision() + * + * Usage: NETWORK=arbitrum-one pnpm validate:onchain:provisions + */ + +import { getProvision } from "../../onchain" +import { + querySubgraph, + getSubgraphUrlFromArgs, + printHeader, + delay, + runValidation, + compareField, + printValidationSummary, + type ValidationResult, +} from "../../common" + +interface Provision { + id: string + serviceProvider: { id: string } + verifier: string + tokens: string + tokensThawing: string + maxVerifierCut: string + thawingPeriod: string + maxVerifierCutPending: string + thawingPeriodPending: string +} + +async function main(): Promise { + const subgraphUrl = getSubgraphUrlFromArgs() + printHeader(subgraphUrl) + + // Fetch all Provisions + console.log("=== Fetching Provisions ===") + const provisionData = await querySubgraph<{ provisions: Provision[] }>( + subgraphUrl, + `{ provisions(first: 1000, orderBy: tokens, orderDirection: desc) { + id + serviceProvider { id } + verifier + tokens + tokensThawing + maxVerifierCut + thawingPeriod + maxVerifierCutPending + thawingPeriodPending + } }` + ) + const provisions = provisionData.provisions + + console.log(` Found ${provisions.length} provisions`) + console.log("") + + // Compare each Provision against on-chain + console.log("=== Comparing Provisions against on-chain state ===") + let mismatches = 0 + let matches = 0 + + for (const provision of provisions) { + const onChain = await getProvision(provision.serviceProvider.id, provision.verifier) + + const fields = [ + compareField("tokens", BigInt(provision.tokens), onChain.tokens, true), + compareField("tokensThawing", BigInt(provision.tokensThawing), onChain.tokensThawing, true), + compareField("maxVerifierCut", BigInt(provision.maxVerifierCut), onChain.maxVerifierCut), + compareField("thawingPeriod", BigInt(provision.thawingPeriod), onChain.thawingPeriod), + compareField("maxVerifierCutPending", BigInt(provision.maxVerifierCutPending), onChain.maxVerifierCutPending), + compareField("thawingPeriodPending", BigInt(provision.thawingPeriodPending), onChain.thawingPeriodPending), + ] + + const fieldMismatches = fields.filter((f) => !f.match) + if (fieldMismatches.length > 0) { + mismatches++ + console.log(`MISMATCH: ${provision.serviceProvider.id} -> ${provision.verifier}`) + for (const m of fieldMismatches) { + console.log(m.message) + } + console.log("") + } else { + matches++ + } + + await delay() + } + + // Summary + const results: ValidationResult[] = [ + { label: "Provisions", total: provisions.length, matches, mismatches }, + ] + printValidationSummary(results) + + if (mismatches === 0) { + console.log("All provisions match on-chain state!") + } + + return mismatches > 0 ? 1 : 0 +} + +runValidation(main) diff --git a/packages/tools/src/validation/onchain/service-providers.ts b/packages/tools/src/validation/onchain/service-providers.ts new file mode 100644 index 0000000..b5b6cc9 --- /dev/null +++ b/packages/tools/src/validation/onchain/service-providers.ts @@ -0,0 +1,216 @@ +/** + * Validates subgraph ServiceProvider fields against on-chain HorizonStaking + * + * Usage: NETWORK=arbitrum-one pnpm validate:onchain:service-providers + */ + +import { + multicall, + encodeGetServiceProvider, + encodeGetProvision, + encodeGetDelegationPool, + decodeServiceProviderResult, + decodeProvisionResult, + decodeDelegationPoolResult, +} from "../../onchain" +import { + querySubgraph, + formatGRT, + getSubgraphUrlFromArgs, + printHeader, + runValidation, + printValidationSummary, + type ValidationResult, +} from "../../common" + +interface ServiceProvider { + id: string + tokensStaked: string + tokensProvisioned: string + tokensThawing: string + tokensDelegated: string + tokensDelegatedThawing: string +} + +interface Provision { + id: string + serviceProvider: { id: string } + verifier: string +} + +interface DelegationPool { + id: string + serviceProvider: { id: string } + verifier: string +} + +async function main(): Promise { + const subgraphUrl = getSubgraphUrlFromArgs() + printHeader(subgraphUrl) + + // Fetch all data from subgraph + console.log("=== Fetching subgraph data ===") + const [spData, provisionData, poolData] = await Promise.all([ + querySubgraph<{ serviceProviders: ServiceProvider[] }>( + subgraphUrl, + `{ serviceProviders(first: 1000, orderBy: tokensStaked, orderDirection: desc) { + id + tokensStaked + tokensProvisioned + tokensThawing + tokensDelegated + tokensDelegatedThawing + } }` + ), + querySubgraph<{ provisions: Provision[] }>( + subgraphUrl, + `{ provisions(first: 1000) { id serviceProvider { id } verifier } }` + ), + querySubgraph<{ delegationPools: DelegationPool[] }>( + subgraphUrl, + `{ delegationPools(first: 1000) { id serviceProvider { id } verifier } }` + ), + ]) + + const serviceProviders = spData.serviceProviders + const provisions = provisionData.provisions + const pools = poolData.delegationPools + + // Group provisions by service provider + const provisionsBySP = new Map() + for (const provision of provisions) { + const spId = provision.serviceProvider.id + if (!provisionsBySP.has(spId)) { + provisionsBySP.set(spId, []) + } + provisionsBySP.get(spId)!.push(provision) + } + + // Group delegation pools by service provider + const poolsBySP = new Map() + for (const pool of pools) { + const spId = pool.serviceProvider.id + if (!poolsBySP.has(spId)) { + poolsBySP.set(spId, []) + } + poolsBySP.get(spId)!.push(pool) + } + + console.log(` Found ${serviceProviders.length} service providers`) + console.log(` Found ${provisions.length} provisions`) + console.log(` Found ${pools.length} delegation pools`) + console.log("") + + // Compare each SP against on-chain using multicall (1 RPC call per SP) + console.log("=== Comparing against on-chain state ===") + let mismatches = 0 + let matches = 0 + + for (const sp of serviceProviders) { + const spProvisions = provisionsBySP.get(sp.id) || [] + const spPools = poolsBySP.get(sp.id) || [] + + // Build multicall batch: 1 SP call + N provision calls + M pool calls + const calls: string[] = [encodeGetServiceProvider(sp.id)] + for (const provision of spProvisions) { + calls.push(encodeGetProvision(sp.id, provision.verifier)) + } + for (const pool of spPools) { + calls.push(encodeGetDelegationPool(sp.id, pool.verifier)) + } + + // Execute single multicall for this SP + const results = await multicall(calls) + + // Decode results + const onChainSP = decodeServiceProviderResult(results[0]) + + let onChainThawing = 0n + for (let i = 0; i < spProvisions.length; i++) { + const provisionResult = decodeProvisionResult(results[1 + i]) + onChainThawing += provisionResult.tokensThawing + } + + let onChainDelegated = 0n + let onChainDelegatedThawing = 0n + for (let i = 0; i < spPools.length; i++) { + const poolResult = decodeDelegationPoolResult(results[1 + spProvisions.length + i]) + onChainDelegated += poolResult.tokens + onChainDelegatedThawing += poolResult.tokensThawing + } + + // Compare values + const issues: string[] = [] + + const subgraphStaked = BigInt(sp.tokensStaked) + const subgraphProvisioned = BigInt(sp.tokensProvisioned) + const subgraphThawing = BigInt(sp.tokensThawing) + const subgraphDelegated = BigInt(sp.tokensDelegated) + const subgraphDelegatedThawing = BigInt(sp.tokensDelegatedThawing) + + if (subgraphStaked !== onChainSP.tokensStaked) { + const diff = onChainSP.tokensStaked - subgraphStaked + issues.push( + `tokensStaked: subgraph=${formatGRT(subgraphStaked)}, chain=${formatGRT(onChainSP.tokensStaked)}, ` + + `diff=${formatGRT(diff > 0n ? diff : -diff)} (${diff > 0n ? "chain higher" : "subgraph higher"})` + ) + } + + if (subgraphProvisioned !== onChainSP.tokensProvisioned) { + const diff = onChainSP.tokensProvisioned - subgraphProvisioned + issues.push( + `tokensProvisioned: subgraph=${formatGRT(subgraphProvisioned)}, chain=${formatGRT(onChainSP.tokensProvisioned)}, ` + + `diff=${formatGRT(diff > 0n ? diff : -diff)} (${diff > 0n ? "chain higher" : "subgraph higher"})` + ) + } + + if (subgraphThawing !== onChainThawing) { + const diff = onChainThawing - subgraphThawing + issues.push( + `tokensThawing: subgraph=${formatGRT(subgraphThawing)}, chain=${formatGRT(onChainThawing)}, ` + + `diff=${formatGRT(diff > 0n ? diff : -diff)} (${diff > 0n ? "chain higher" : "subgraph higher"})` + ) + } + + if (subgraphDelegated !== onChainDelegated) { + const diff = onChainDelegated - subgraphDelegated + issues.push( + `tokensDelegated: subgraph=${formatGRT(subgraphDelegated)}, chain=${formatGRT(onChainDelegated)}, ` + + `diff=${formatGRT(diff > 0n ? diff : -diff)} (${diff > 0n ? "chain higher" : "subgraph higher"})` + ) + } + + if (subgraphDelegatedThawing !== onChainDelegatedThawing) { + const diff = onChainDelegatedThawing - subgraphDelegatedThawing + issues.push( + `tokensDelegatedThawing: subgraph=${formatGRT(subgraphDelegatedThawing)}, chain=${formatGRT(onChainDelegatedThawing)}, ` + + `diff=${formatGRT(diff > 0n ? diff : -diff)} (${diff > 0n ? "chain higher" : "subgraph higher"})` + ) + } + + if (issues.length > 0) { + mismatches++ + console.log(`MISMATCH: ${sp.id}`) + for (const issue of issues) { + console.log(` ${issue}`) + } + console.log("") + } else { + matches++ + } + } + + // Summary + const results: ValidationResult[] = [ + { label: "ServiceProviders", total: serviceProviders.length, matches, mismatches }, + ] + printValidationSummary(results) + + if (mismatches === 0) { + console.log("All service providers match on-chain state!") + } + + return mismatches > 0 ? 1 : 0 +} + +runValidation(main) diff --git a/packages/tools/src/validation/provisions.ts b/packages/tools/src/validation/provisions.ts deleted file mode 100644 index 596925a..0000000 --- a/packages/tools/src/validation/provisions.ts +++ /dev/null @@ -1,199 +0,0 @@ -/** - * Validates subgraph Provision entities against on-chain HorizonStaking.getProvision() - * Also validates ServiceProvider.tokensProvisioned against on-chain state. - * - * Usage: NETWORK=arbitrum-one pnpm validate:provisions - */ - -import { getProvision, getServiceProvider } from "../onchain" -import { querySubgraph, formatGRT, getSubgraphUrlFromArgs, printHeader, delay } from "../common" - -interface SubgraphProvision { - id: string - serviceProvider: { id: string } - verifier: string - tokens: string - tokensThawing: string - maxVerifierCut: string - thawingPeriod: string - maxVerifierCutPending: string - thawingPeriodPending: string - lastParametersStagedAt: string -} - -interface SubgraphServiceProvider { - id: string - tokensStaked: string - tokensProvisioned: string -} - -interface GraphNetwork { - id: string - tokensProvisioned: string - countProvisions: number -} - -function compareField( - name: string, - subgraphValue: bigint, - onChainValue: bigint, - isTokens = false -): { match: boolean; message?: string } { - if (subgraphValue === onChainValue) { - return { match: true } - } - const subgraphStr = isTokens ? formatGRT(subgraphValue) : subgraphValue.toString() - const onChainStr = isTokens ? formatGRT(onChainValue) : onChainValue.toString() - return { - match: false, - message: ` ${name}: subgraph=${subgraphStr}, chain=${onChainStr}`, - } -} - -async function main() { - const subgraphUrl = getSubgraphUrlFromArgs() - printHeader(subgraphUrl) - - // Fetch GraphNetwork - const networkData = await querySubgraph<{ graphNetwork: GraphNetwork }>( - subgraphUrl, - `{ graphNetwork(id: "0x01000000") { id tokensProvisioned countProvisions } }` - ) - const graphNetwork = networkData.graphNetwork - - if (!graphNetwork) { - console.error("GraphNetwork entity not found") - process.exit(1) - } - - console.log("=== GraphNetwork ===") - console.log(` countProvisions: ${graphNetwork.countProvisions}`) - console.log(` tokensProvisioned: ${formatGRT(BigInt(graphNetwork.tokensProvisioned))}`) - console.log("") - - // Fetch all Provisions - console.log("=== Fetching Provisions ===") - const provisionData = await querySubgraph<{ provisions: SubgraphProvision[] }>( - subgraphUrl, - `{ provisions(first: 1000, orderBy: tokens, orderDirection: desc) { - id - serviceProvider { id } - verifier - tokens - tokensThawing - maxVerifierCut - thawingPeriod - maxVerifierCutPending - thawingPeriodPending - lastParametersStagedAt - } }` - ) - const provisions = provisionData.provisions - - console.log(` Found ${provisions.length} provisions`) - console.log("") - - // Validate count - if (provisions.length !== graphNetwork.countProvisions) { - console.log( - `WARNING: Provision count mismatch - GraphNetwork says ${graphNetwork.countProvisions}, found ${provisions.length}` - ) - console.log("") - } - - // Validate sum of tokens - const subgraphSum = provisions.reduce((sum, p) => sum + BigInt(p.tokens), 0n) - if (subgraphSum.toString() !== graphNetwork.tokensProvisioned) { - console.log(`WARNING: tokensProvisioned sum mismatch`) - console.log(` GraphNetwork.tokensProvisioned: ${formatGRT(BigInt(graphNetwork.tokensProvisioned))}`) - console.log(` Sum of provision tokens: ${formatGRT(subgraphSum)}`) - console.log("") - } - - // Compare each Provision against on-chain - console.log("=== Comparing Provisions against on-chain state ===") - let provisionMismatches = 0 - let provisionMatches = 0 - - for (const provision of provisions) { - const onChain = await getProvision(provision.serviceProvider.id, provision.verifier) - - const fields = [ - compareField("tokens", BigInt(provision.tokens), onChain.tokens, true), - compareField("tokensThawing", BigInt(provision.tokensThawing), onChain.tokensThawing, true), - compareField("maxVerifierCut", BigInt(provision.maxVerifierCut), onChain.maxVerifierCut), - compareField("thawingPeriod", BigInt(provision.thawingPeriod), onChain.thawingPeriod), - compareField("maxVerifierCutPending", BigInt(provision.maxVerifierCutPending), onChain.maxVerifierCutPending), - compareField("thawingPeriodPending", BigInt(provision.thawingPeriodPending), onChain.thawingPeriodPending), - ] - - const mismatches = fields.filter((f) => !f.match) - if (mismatches.length > 0) { - provisionMismatches++ - console.log(`MISMATCH: ${provision.serviceProvider.id} -> ${provision.verifier}`) - for (const m of mismatches) { - console.log(m.message) - } - console.log("") - } else { - provisionMatches++ - } - - await delay() - } - - // Fetch and validate ServiceProviders - console.log("=== Validating ServiceProvider.tokensProvisioned ===") - const spData = await querySubgraph<{ serviceProviders: SubgraphServiceProvider[] }>( - subgraphUrl, - `{ serviceProviders(first: 1000, where: { tokensProvisioned_gt: "0" }) { id tokensStaked tokensProvisioned } }` - ) - const serviceProviders = spData.serviceProviders - - console.log(` Found ${serviceProviders.length} service providers with provisions`) - console.log("") - - let spMismatches = 0 - let spMatches = 0 - - for (const sp of serviceProviders) { - const onChain = await getServiceProvider(sp.id) - - const subgraphProvisioned = BigInt(sp.tokensProvisioned) - if (subgraphProvisioned !== onChain.tokensProvisioned) { - spMismatches++ - console.log(`MISMATCH: ${sp.id}`) - console.log(` tokensProvisioned: subgraph=${formatGRT(subgraphProvisioned)}, chain=${formatGRT(onChain.tokensProvisioned)}`) - console.log("") - } else { - spMatches++ - } - - await delay() - } - - // Summary - console.log("=== Summary ===") - console.log(`Provisions:`) - console.log(` Total: ${provisions.length}`) - console.log(` Matches: ${provisionMatches}`) - console.log(` Mismatches: ${provisionMismatches}`) - console.log("") - console.log(`ServiceProviders (tokensProvisioned):`) - console.log(` Total: ${serviceProviders.length}`) - console.log(` Matches: ${spMatches}`) - console.log(` Mismatches: ${spMismatches}`) - - const totalMismatches = provisionMismatches + spMismatches - if (totalMismatches === 0) { - console.log("") - console.log("All provisions match on-chain state!") - } - - process.exit(totalMismatches > 0 ? 1 : 0) -} - -main().catch((err) => { - console.error("Error:", err) - process.exit(1) -}) diff --git a/packages/tools/src/validation/stake.ts b/packages/tools/src/validation/stake.ts deleted file mode 100644 index 2edd337..0000000 --- a/packages/tools/src/validation/stake.ts +++ /dev/null @@ -1,108 +0,0 @@ -/** - * Validates subgraph ServiceProvider.tokensStaked against on-chain HorizonStaking.getStake() - * - * Usage: NETWORK=arbitrum-one pnpm validate:stake - */ - -import { getStake } from "../onchain" -import { querySubgraph, formatGRT, getSubgraphUrlFromArgs, printHeader, delay } from "../common" - -interface ServiceProvider { - id: string - tokensStaked: string -} - -interface GraphNetwork { - id: string - tokensStaked: string - countServiceProviders: number -} - -async function main() { - const subgraphUrl = getSubgraphUrlFromArgs() - printHeader(subgraphUrl) - - // Fetch GraphNetwork - const networkData = await querySubgraph<{ graphNetwork: GraphNetwork }>( - subgraphUrl, - `{ graphNetwork(id: "0x01000000") { id tokensStaked countServiceProviders } }` - ) - const graphNetwork = networkData.graphNetwork - - if (!graphNetwork) { - console.error("GraphNetwork entity not found") - process.exit(1) - } - - console.log("=== GraphNetwork ===") - console.log(` countServiceProviders: ${graphNetwork.countServiceProviders}`) - console.log(` tokensStaked: ${formatGRT(BigInt(graphNetwork.tokensStaked))}`) - console.log("") - - // Fetch all ServiceProviders - console.log("=== Fetching ServiceProviders ===") - const spData = await querySubgraph<{ serviceProviders: ServiceProvider[] }>( - subgraphUrl, - `{ serviceProviders(first: 1000, orderBy: tokensStaked, orderDirection: desc) { id tokensStaked } }` - ) - const serviceProviders = spData.serviceProviders - - console.log(` Found ${serviceProviders.length} service providers`) - console.log("") - - // Validate count - if (serviceProviders.length !== graphNetwork.countServiceProviders) { - console.log(`WARNING: SP count mismatch - GraphNetwork says ${graphNetwork.countServiceProviders}, found ${serviceProviders.length}`) - } - - // Validate sum - const subgraphSum = serviceProviders.reduce((sum, sp) => sum + BigInt(sp.tokensStaked), 0n) - if (subgraphSum.toString() !== graphNetwork.tokensStaked) { - console.log(`WARNING: tokensStaked sum mismatch`) - console.log(` GraphNetwork.tokensStaked: ${formatGRT(BigInt(graphNetwork.tokensStaked))}`) - console.log(` Sum of SP stakes: ${formatGRT(subgraphSum)}`) - console.log("") - } - - // Compare each SP against on-chain - console.log("=== Comparing against on-chain state ===") - let mismatches = 0 - let matches = 0 - - for (const sp of serviceProviders) { - const onChainStake = await getStake(sp.id) - const subgraphStake = BigInt(sp.tokensStaked) - - if (onChainStake !== subgraphStake) { - mismatches++ - const diff = onChainStake - subgraphStake - console.log(`MISMATCH: ${sp.id}`) - console.log(` subgraph: ${formatGRT(subgraphStake)}`) - console.log(` on-chain: ${formatGRT(onChainStake)}`) - console.log(` diff: ${formatGRT(diff > 0n ? diff : -diff)} (${diff > 0n ? "chain higher" : "subgraph higher"})`) - console.log("") - } else { - matches++ - } - - await delay() - } - - // Summary - console.log("=== Summary ===") - console.log(` Total SPs: ${serviceProviders.length}`) - console.log(` Matches: ${matches}`) - console.log(` Mismatches: ${mismatches}`) - - if (mismatches === 0) { - console.log("") - console.log("All service provider stakes match on-chain state!") - } - - process.exit(mismatches > 0 ? 1 : 0) -} - -main().catch((err) => { - console.error("Error:", err) - process.exit(1) -})