From e0552bee484dcaf476f8e89181dd5d73f60dfe7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Migone?= Date: Mon, 18 May 2026 16:42:44 -0300 Subject: [PATCH] feat: add provision fee cuts 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 | 23 ++ .../subgraph/src/entities/provisionFeeCut.ts | 48 ++++ packages/subgraph/src/handlers/feeCut.ts | 32 +++ packages/subgraph/src/mapping.ts | 1 + packages/subgraph/subgraph.yaml | 3 + packages/subgraph/tests/feeCut.test.ts | 254 ++++++++++++++++++ packages/tools/package.json | 3 +- packages/tools/src/onchain.ts | 19 ++ packages/tools/src/validation/internal.ts | 65 ++++- .../tools/src/validation/onchain/fee-cuts.ts | 96 +++++++ 10 files changed, 542 insertions(+), 2 deletions(-) create mode 100644 packages/subgraph/src/entities/provisionFeeCut.ts create mode 100644 packages/subgraph/src/handlers/feeCut.ts create mode 100644 packages/subgraph/tests/feeCut.test.ts create mode 100644 packages/tools/src/validation/onchain/fee-cuts.ts diff --git a/packages/subgraph/schema.graphql b/packages/subgraph/schema.graphql index a278e45..1d7579d 100644 --- a/packages/subgraph/schema.graphql +++ b/packages/subgraph/schema.graphql @@ -189,6 +189,8 @@ type Provision @entity(immutable: false) { dataService: DataService! "Thaw requests for this provision" thawRequests: [ProvisionThawRequest!]! @derivedFrom(field: "provision") + "Fee cuts for delegators by payment type" + feeCuts: [ProvisionFeeCut!]! @derivedFrom(field: "provision") # Tokens "Tokens currently provisioned" @@ -221,6 +223,27 @@ type Provision @entity(immutable: false) { updatedAt: BigInt! } +type ProvisionFeeCut @entity(immutable: false) { + "Composite ID: provision-paymentType" + id: Bytes! + + # Relationships + "Provision this fee cut belongs to" + provision: Provision! + + # State + "Payment type (maps to PaymentTypes enum: 0 = QueryFee, 1 = IndexingFee, 2 = IndexingReward, ...)" + paymentType: Int! + "Fee cut percentage (PPM)" + feeCut: BigInt! + + # Metadata + "Block number when entity was last updated" + updatedAtBlock: BigInt! + "Timestamp when entity was last updated" + updatedAt: BigInt! +} + type ProvisionThawRequest @entity(immutable: false) { "Thaw request ID (bytes32) emitted by ThawRequestCreated event" id: Bytes! diff --git a/packages/subgraph/src/entities/provisionFeeCut.ts b/packages/subgraph/src/entities/provisionFeeCut.ts new file mode 100644 index 0000000..81f894a --- /dev/null +++ b/packages/subgraph/src/entities/provisionFeeCut.ts @@ -0,0 +1,48 @@ +import { BigInt, Bytes, ethereum } from "@graphprotocol/graph-ts" +import { ProvisionFeeCut } from "../../generated/schema" +import { BIGINT_ZERO } from "../common/constants" +import { getProvisionId } from "./provision" + +export function getProvisionFeeCutId(serviceProvider: Bytes, dataService: Bytes, paymentType: i32): Bytes { + let provisionId = getProvisionId(serviceProvider, dataService) + return provisionId.concat(Bytes.fromI32(paymentType)) +} + +export class ProvisionFeeCutResult { + entity: ProvisionFeeCut + isNew: boolean + + constructor(entity: ProvisionFeeCut, isNew: boolean) { + this.entity = entity + this.isNew = isNew + } +} + +export function getOrCreateProvisionFeeCut( + serviceProvider: Bytes, + dataService: Bytes, + paymentType: i32, + blockNumber: BigInt, + timestamp: BigInt +): ProvisionFeeCutResult { + let id = getProvisionFeeCutId(serviceProvider, dataService, paymentType) + let entity = ProvisionFeeCut.load(id) + let isNew = entity == null + + if (entity == null) { + entity = new ProvisionFeeCut(id) + entity.provision = getProvisionId(serviceProvider, dataService) + entity.paymentType = paymentType + entity.feeCut = BIGINT_ZERO + entity.updatedAtBlock = blockNumber + entity.updatedAt = timestamp + } + + return new ProvisionFeeCutResult(entity, isNew) +} + +export function saveProvisionFeeCut(feeCut: ProvisionFeeCut, block: ethereum.Block): void { + feeCut.updatedAtBlock = block.number + feeCut.updatedAt = block.timestamp + feeCut.save() +} diff --git a/packages/subgraph/src/handlers/feeCut.ts b/packages/subgraph/src/handlers/feeCut.ts new file mode 100644 index 0000000..3334972 --- /dev/null +++ b/packages/subgraph/src/handlers/feeCut.ts @@ -0,0 +1,32 @@ +import { Bytes } from "@graphprotocol/graph-ts" +import { DelegationFeeCutSet } from "../../generated/HorizonStaking/HorizonStaking" +import { + getOrCreateProvisionFeeCut, + saveProvisionFeeCut, +} from "../entities/provisionFeeCut" + +/** + * Handles DelegationFeeCutSet event. + * Creates or updates a ProvisionFeeCut entity when a service provider sets + * their fee cut percentage for a specific payment type on a provision. + */ +export function handleDelegationFeeCutSet(event: DelegationFeeCutSet): void { + let serviceProviderBytes = Bytes.fromHexString( + event.params.serviceProvider.toHexString() + ) as Bytes + let dataServiceBytes = Bytes.fromHexString( + event.params.verifier.toHexString() + ) as Bytes + + let feeCut = getOrCreateProvisionFeeCut( + serviceProviderBytes, + dataServiceBytes, + event.params.paymentType, + event.block.number, + event.block.timestamp + ) + + feeCut.entity.feeCut = event.params.feeCut + + saveProvisionFeeCut(feeCut.entity, event.block) +} diff --git a/packages/subgraph/src/mapping.ts b/packages/subgraph/src/mapping.ts index eb46e8b..86299a8 100644 --- a/packages/subgraph/src/mapping.ts +++ b/packages/subgraph/src/mapping.ts @@ -25,3 +25,4 @@ export { handleThawRequestCreated, handleThawRequestFulfilled } from "./handlers/thawRequest" +export { handleDelegationFeeCutSet } from "./handlers/feeCut" diff --git a/packages/subgraph/subgraph.yaml b/packages/subgraph/subgraph.yaml index 7f333da..1b5f7cd 100644 --- a/packages/subgraph/subgraph.yaml +++ b/packages/subgraph/subgraph.yaml @@ -22,6 +22,7 @@ dataSources: - Provision - DelegationPool - ProvisionThawRequest + - ProvisionFeeCut abis: - name: HorizonStaking file: ./abis/HorizonStaking.json @@ -59,6 +60,8 @@ dataSources: handler: handleDelegatedTokensWithdrawn - event: DelegationSlashed(indexed address,indexed address,uint256) handler: handleDelegationSlashed + - event: DelegationFeeCutSet(indexed address,indexed address,indexed uint8,uint256) + handler: handleDelegationFeeCutSet # Thaw request events - event: ThawRequestCreated(indexed uint8,indexed address,indexed address,address,uint256,uint64,bytes32,uint256) handler: handleThawRequestCreated diff --git a/packages/subgraph/tests/feeCut.test.ts b/packages/subgraph/tests/feeCut.test.ts new file mode 100644 index 0000000..8a513fa --- /dev/null +++ b/packages/subgraph/tests/feeCut.test.ts @@ -0,0 +1,254 @@ +import { + describe, + test, + beforeEach, + clearStore, + assert, + newTypedMockEvent, +} from "matchstick-as" +import { Address, BigInt, Bytes, ethereum } from "@graphprotocol/graph-ts" +import { + DelegationFeeCutSet, + HorizonStakeDeposited, + ProvisionCreated, +} from "../generated/HorizonStaking/HorizonStaking" +import { handleDelegationFeeCutSet } from "../src/handlers/feeCut" +import { handleHorizonStakeDeposited } from "../src/handlers/staking" +import { handleProvisionCreated } from "../src/handlers/provision" +import { getProvisionFeeCutId } from "../src/entities/provisionFeeCut" +import { getProvisionId } from "../src/entities/provision" + +// Test addresses +const SP_ADDRESS = Address.fromString("0x1234567890123456789012345678901234567890") +const VERIFIER_ADDRESS = Address.fromString("0xabcdefabcdefabcdefabcdefabcdefabcdefabcd") + +// Payment types (from IGraphPayments.PaymentTypes enum) +const PAYMENT_TYPE_QUERY_FEE = 0 +const PAYMENT_TYPE_INDEXING_FEE = 1 +const PAYMENT_TYPE_INDEXING_REWARD = 2 + +// Helper to create stake deposit +function createStakeDepositedEvent(serviceProvider: Address, tokens: BigInt): HorizonStakeDeposited { + let event = newTypedMockEvent() + event.parameters = new Array() + event.parameters.push(new ethereum.EventParam("serviceProvider", ethereum.Value.fromAddress(serviceProvider))) + event.parameters.push(new ethereum.EventParam("tokens", ethereum.Value.fromUnsignedBigInt(tokens))) + event.block.number = BigInt.fromI32(100) + event.block.timestamp = BigInt.fromI32(1000) + return event +} + +// Helper to create provision +function createProvisionCreatedEvent( + serviceProvider: Address, + verifier: Address, + tokens: BigInt +): ProvisionCreated { + 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("tokens", ethereum.Value.fromUnsignedBigInt(tokens))) + event.parameters.push(new ethereum.EventParam("maxVerifierCut", ethereum.Value.fromUnsignedBigInt(BigInt.fromI32(100000)))) + event.parameters.push(new ethereum.EventParam("thawingPeriod", ethereum.Value.fromUnsignedBigInt(BigInt.fromI32(2592000)))) + event.block.number = BigInt.fromI32(200) + event.block.timestamp = BigInt.fromI32(2000) + return event +} + +// Helper to create DelegationFeeCutSet event +function createDelegationFeeCutSetEvent( + serviceProvider: Address, + verifier: Address, + paymentType: i32, + feeCut: BigInt +): DelegationFeeCutSet { + 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("paymentType", ethereum.Value.fromI32(paymentType))) + event.parameters.push(new ethereum.EventParam("feeCut", ethereum.Value.fromUnsignedBigInt(feeCut))) + event.block.number = BigInt.fromI32(300) + event.block.timestamp = BigInt.fromI32(3000) + return event +} + +function setupServiceProviderAndProvision(): void { + // Deposit stake + let stakeTokens = BigInt.fromString("10000000000000000000000") // 10000 GRT + let depositEvent = createStakeDepositedEvent(SP_ADDRESS, stakeTokens) + handleHorizonStakeDeposited(depositEvent) + + // Create provision + let provisionTokens = BigInt.fromString("5000000000000000000000") // 5000 GRT + let provisionEvent = createProvisionCreatedEvent(SP_ADDRESS, VERIFIER_ADDRESS, provisionTokens) + handleProvisionCreated(provisionEvent) +} + +function getFeeCutIdString(sp: Address, verifier: Address, paymentType: i32): string { + return getProvisionFeeCutId( + Bytes.fromHexString(sp.toHexString()), + Bytes.fromHexString(verifier.toHexString()), + paymentType + ).toHexString() +} + +function getProvisionIdString(sp: Address, verifier: Address): string { + return getProvisionId( + Bytes.fromHexString(sp.toHexString()), + Bytes.fromHexString(verifier.toHexString()) + ).toHexString() +} + +describe("DelegationFeeCutSet", () => { + beforeEach(() => { + clearStore() + }) + + test("creates ProvisionFeeCut entity for query fee type", () => { + setupServiceProviderAndProvision() + + let feeCut = BigInt.fromI32(100000) // 10% in PPM + let event = createDelegationFeeCutSetEvent( + SP_ADDRESS, + VERIFIER_ADDRESS, + PAYMENT_TYPE_QUERY_FEE, + feeCut + ) + handleDelegationFeeCutSet(event) + + let entityId = getFeeCutIdString(SP_ADDRESS, VERIFIER_ADDRESS, PAYMENT_TYPE_QUERY_FEE) + let provisionId = getProvisionIdString(SP_ADDRESS, VERIFIER_ADDRESS) + + assert.entityCount("ProvisionFeeCut", 1) + assert.fieldEquals("ProvisionFeeCut", entityId, "provision", provisionId) + assert.fieldEquals("ProvisionFeeCut", entityId, "paymentType", PAYMENT_TYPE_QUERY_FEE.toString()) + assert.fieldEquals("ProvisionFeeCut", entityId, "feeCut", feeCut.toString()) + assert.fieldEquals("ProvisionFeeCut", entityId, "updatedAtBlock", "300") + assert.fieldEquals("ProvisionFeeCut", entityId, "updatedAt", "3000") + }) + + test("creates separate entities for different payment types", () => { + setupServiceProviderAndProvision() + + // Set query fee cut + let queryFeeCut = BigInt.fromI32(100000) // 10% + let queryEvent = createDelegationFeeCutSetEvent( + SP_ADDRESS, + VERIFIER_ADDRESS, + PAYMENT_TYPE_QUERY_FEE, + queryFeeCut + ) + handleDelegationFeeCutSet(queryEvent) + + // Set indexing fee cut + let indexingFeeCut = BigInt.fromI32(200000) // 20% + let indexingEvent = createDelegationFeeCutSetEvent( + SP_ADDRESS, + VERIFIER_ADDRESS, + PAYMENT_TYPE_INDEXING_FEE, + indexingFeeCut + ) + indexingEvent.block.number = BigInt.fromI32(301) + indexingEvent.block.timestamp = BigInt.fromI32(3100) + handleDelegationFeeCutSet(indexingEvent) + + // Set indexing reward cut + let rewardFeeCut = BigInt.fromI32(50000) // 5% + let rewardEvent = createDelegationFeeCutSetEvent( + SP_ADDRESS, + VERIFIER_ADDRESS, + PAYMENT_TYPE_INDEXING_REWARD, + rewardFeeCut + ) + rewardEvent.block.number = BigInt.fromI32(302) + rewardEvent.block.timestamp = BigInt.fromI32(3200) + handleDelegationFeeCutSet(rewardEvent) + + assert.entityCount("ProvisionFeeCut", 3) + + let queryId = getFeeCutIdString(SP_ADDRESS, VERIFIER_ADDRESS, PAYMENT_TYPE_QUERY_FEE) + let indexingId = getFeeCutIdString(SP_ADDRESS, VERIFIER_ADDRESS, PAYMENT_TYPE_INDEXING_FEE) + let rewardId = getFeeCutIdString(SP_ADDRESS, VERIFIER_ADDRESS, PAYMENT_TYPE_INDEXING_REWARD) + + assert.fieldEquals("ProvisionFeeCut", queryId, "paymentType", PAYMENT_TYPE_QUERY_FEE.toString()) + assert.fieldEquals("ProvisionFeeCut", queryId, "feeCut", queryFeeCut.toString()) + + assert.fieldEquals("ProvisionFeeCut", indexingId, "paymentType", PAYMENT_TYPE_INDEXING_FEE.toString()) + assert.fieldEquals("ProvisionFeeCut", indexingId, "feeCut", indexingFeeCut.toString()) + + assert.fieldEquals("ProvisionFeeCut", rewardId, "paymentType", PAYMENT_TYPE_INDEXING_REWARD.toString()) + assert.fieldEquals("ProvisionFeeCut", rewardId, "feeCut", rewardFeeCut.toString()) + }) + + test("updates existing fee cut for same payment type", () => { + setupServiceProviderAndProvision() + + // Initial fee cut + let initialFeeCut = BigInt.fromI32(100000) // 10% + let event1 = createDelegationFeeCutSetEvent( + SP_ADDRESS, + VERIFIER_ADDRESS, + PAYMENT_TYPE_QUERY_FEE, + initialFeeCut + ) + handleDelegationFeeCutSet(event1) + + let entityId = getFeeCutIdString(SP_ADDRESS, VERIFIER_ADDRESS, PAYMENT_TYPE_QUERY_FEE) + assert.fieldEquals("ProvisionFeeCut", entityId, "feeCut", initialFeeCut.toString()) + assert.fieldEquals("ProvisionFeeCut", entityId, "updatedAtBlock", "300") + + // Update fee cut + let updatedFeeCut = BigInt.fromI32(150000) // 15% + let event2 = createDelegationFeeCutSetEvent( + SP_ADDRESS, + VERIFIER_ADDRESS, + PAYMENT_TYPE_QUERY_FEE, + updatedFeeCut + ) + event2.block.number = BigInt.fromI32(400) + event2.block.timestamp = BigInt.fromI32(4000) + handleDelegationFeeCutSet(event2) + + // Should still be 1 entity, but updated + assert.entityCount("ProvisionFeeCut", 1) + assert.fieldEquals("ProvisionFeeCut", entityId, "feeCut", updatedFeeCut.toString()) + assert.fieldEquals("ProvisionFeeCut", entityId, "updatedAtBlock", "400") + assert.fieldEquals("ProvisionFeeCut", entityId, "updatedAt", "4000") + }) + + test("handles fee cut set to zero", () => { + setupServiceProviderAndProvision() + + // Set fee cut to zero (100% goes to delegators) + let feeCut = BigInt.fromI32(0) + let event = createDelegationFeeCutSetEvent( + SP_ADDRESS, + VERIFIER_ADDRESS, + PAYMENT_TYPE_QUERY_FEE, + feeCut + ) + handleDelegationFeeCutSet(event) + + let entityId = getFeeCutIdString(SP_ADDRESS, VERIFIER_ADDRESS, PAYMENT_TYPE_QUERY_FEE) + assert.fieldEquals("ProvisionFeeCut", entityId, "feeCut", "0") + }) + + test("handles max fee cut (100%)", () => { + setupServiceProviderAndProvision() + + // Set fee cut to 100% (1000000 PPM) + let feeCut = BigInt.fromI32(1000000) // 100% in PPM + let event = createDelegationFeeCutSetEvent( + SP_ADDRESS, + VERIFIER_ADDRESS, + PAYMENT_TYPE_QUERY_FEE, + feeCut + ) + handleDelegationFeeCutSet(event) + + let entityId = getFeeCutIdString(SP_ADDRESS, VERIFIER_ADDRESS, PAYMENT_TYPE_QUERY_FEE) + assert.fieldEquals("ProvisionFeeCut", entityId, "feeCut", feeCut.toString()) + }) +}) diff --git a/packages/tools/package.json b/packages/tools/package.json index 8905aec..997b5e1 100644 --- a/packages/tools/package.json +++ b/packages/tools/package.json @@ -9,7 +9,8 @@ "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" + "validate:onchain:delegations": "tsx src/validation/onchain/delegations.ts", + "validate:onchain:fee-cuts": "tsx src/validation/onchain/fee-cuts.ts" }, "devDependencies": { "@types/node": "22.15.18", diff --git a/packages/tools/src/onchain.ts b/packages/tools/src/onchain.ts index 186aad1..1743887 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 GET_DELEGATION_FEE_CUT_SELECTOR = "0x7573ef4f" // getDelegationFeeCut(address,address,uint8) const MULTICALL_SELECTOR = "0xac9650d8" // multicall(bytes[]) export interface ServiceProviderData { @@ -130,6 +131,19 @@ export async function getDelegationPool(serviceProvider: string, verifier: strin } } +export async function getDelegationFeeCut( + serviceProvider: string, + verifier: string, + paymentType: number +): Promise { + const config = getConfig() + // paymentType is uint8, pad to 32 bytes + const paymentTypePadded = paymentType.toString(16).padStart(64, "0") + const callData = GET_DELEGATION_FEE_CUT_SELECTOR + padAddress(serviceProvider) + padAddress(verifier) + paymentTypePadded + const result = await ethCall(config.stakingAddress, callData) + return BigInt(result) +} + // ============================================================================ // Multicall // ============================================================================ @@ -147,6 +161,11 @@ export function encodeGetDelegationPool(serviceProvider: string, verifier: strin return GET_DELEGATION_POOL_SELECTOR + padAddress(serviceProvider) + padAddress(verifier) } +export function encodeGetDelegationFeeCut(serviceProvider: string, verifier: string, paymentType: number): string { + const paymentTypePadded = paymentType.toString(16).padStart(64, "0") + return GET_DELEGATION_FEE_CUT_SELECTOR + padAddress(serviceProvider) + padAddress(verifier) + paymentTypePadded +} + // Decode result helpers export function decodeServiceProviderResult(hex: string): ServiceProviderData { const data = hex.startsWith("0x") ? hex.slice(2) : hex diff --git a/packages/tools/src/validation/internal.ts b/packages/tools/src/validation/internal.ts index 6dfb9e2..4134d6a 100644 --- a/packages/tools/src/validation/internal.ts +++ b/packages/tools/src/validation/internal.ts @@ -78,6 +78,13 @@ interface ProvisionThawRequest { fulfilled: boolean } +interface ProvisionFeeCut { + id: string + provision: { id: string } + paymentType: number + feeCut: string +} + // ============================================================================ // Queries // ============================================================================ @@ -152,6 +159,15 @@ const PROVISION_THAW_REQUESTS_QUERY = `{ } }` +const PROVISION_FEE_CUTS_QUERY = `{ + provisionFeeCuts(first: 1000) { + id + provision { id } + paymentType + feeCut + } +}` + // ============================================================================ // Main // ============================================================================ @@ -164,13 +180,14 @@ async function main(): Promise { // Fetch all data console.log("=== Fetching subgraph data ===") - const [networkData, spData, dsData, provisionData, poolData, thawRequestData] = await Promise.all([ + const [networkData, spData, dsData, provisionData, poolData, thawRequestData, feeCutData] = await Promise.all([ querySubgraph<{ graphNetwork: GraphNetwork }>(subgraphUrl, GRAPH_NETWORK_QUERY), querySubgraph<{ serviceProviders: ServiceProvider[] }>(subgraphUrl, SERVICE_PROVIDERS_QUERY), querySubgraph<{ dataServices: DataService[] }>(subgraphUrl, DATA_SERVICES_QUERY), querySubgraph<{ provisions: Provision[] }>(subgraphUrl, PROVISIONS_QUERY), querySubgraph<{ delegationPools: DelegationPool[] }>(subgraphUrl, DELEGATION_POOLS_QUERY), querySubgraph<{ provisionThawRequests: ProvisionThawRequest[] }>(subgraphUrl, PROVISION_THAW_REQUESTS_QUERY), + querySubgraph<{ provisionFeeCuts: ProvisionFeeCut[] }>(subgraphUrl, PROVISION_FEE_CUTS_QUERY), ]) const graphNetwork = networkData.graphNetwork @@ -184,6 +201,7 @@ async function main(): Promise { const provisions = provisionData.provisions const pools = poolData.delegationPools const thawRequests = thawRequestData.provisionThawRequests + const feeCuts = feeCutData.provisionFeeCuts // Filter to only SPs with stake > 0 (matches countServiceProviders semantics) const stakedSPs = serviceProviders.filter((sp) => BigInt(sp.tokensStaked) > 0n) @@ -201,6 +219,7 @@ async function main(): Promise { console.log(` Provisions: ${provisions.length}`) console.log(` DelegationPools: ${pools.length} total, ${activePools.length} with tokens`) console.log(` ProvisionThawRequests: ${thawRequests.length} total, ${pendingThawRequests.length} pending, ${fulfilledThawRequests.length} fulfilled`) + console.log(` ProvisionFeeCuts: ${feeCuts.length}`) console.log("") // ============================================================================ @@ -444,6 +463,50 @@ async function main(): Promise { warnings += trWarnings + // ============================================================================ + // ProvisionFeeCut Referential Integrity + // ============================================================================ + + console.log("=== ProvisionFeeCut Referential Integrity ===") + let fcWarnings = 0 + + // Valid payment types (from IGraphPayments.PaymentTypes enum) + const validPaymentTypes = new Set([0, 1, 2]) // QueryFee, IndexingFee, IndexingReward + const MAX_FEE_CUT = 1000000n // 100% in PPM + + for (const fc of feeCuts) { + const issues: string[] = [] + + if (!provisionIds.has(fc.provision.id)) { + issues.push(`references non-existent Provision: ${fc.provision.id}`) + } + + if (!validPaymentTypes.has(fc.paymentType)) { + issues.push(`invalid paymentType: ${fc.paymentType}`) + } + + const feeCutValue = BigInt(fc.feeCut) + if (feeCutValue < 0n || feeCutValue > MAX_FEE_CUT) { + issues.push(`feeCut out of range [0, 1000000]: ${fc.feeCut}`) + } + + if (issues.length > 0) { + fcWarnings++ + console.log(`WARNING: ${fc.id}`) + for (const issue of issues) { + console.log(` ${issue}`) + } + console.log("") + } + } + + if (fcWarnings === 0) { + console.log("All ProvisionFeeCut references are valid!") + console.log("") + } + + warnings += fcWarnings + // ============================================================================ // Summary // ============================================================================ diff --git a/packages/tools/src/validation/onchain/fee-cuts.ts b/packages/tools/src/validation/onchain/fee-cuts.ts new file mode 100644 index 0000000..4a30c03 --- /dev/null +++ b/packages/tools/src/validation/onchain/fee-cuts.ts @@ -0,0 +1,96 @@ +/** + * Validates subgraph ProvisionFeeCut entities against on-chain HorizonStaking.getDelegationFeeCut() + * + * Usage: NETWORK=arbitrum-one pnpm validate:onchain:fee-cuts + */ + +import { getDelegationFeeCut } from "../../onchain" +import { + querySubgraph, + getSubgraphUrlFromArgs, + printHeader, + delay, + runValidation, + compareField, + printValidationSummary, + type ValidationResult, +} from "../../common" + +interface ProvisionFeeCut { + id: string + provision: { + id: string + serviceProvider: { id: string } + dataService: { id: string } + } + paymentType: number + feeCut: string +} + +async function main(): Promise { + const subgraphUrl = getSubgraphUrlFromArgs() + printHeader(subgraphUrl) + + // Fetch all ProvisionFeeCuts + console.log("=== Fetching ProvisionFeeCuts ===") + const feeCutData = await querySubgraph<{ provisionFeeCuts: ProvisionFeeCut[] }>( + subgraphUrl, + `{ provisionFeeCuts(first: 1000) { + id + provision { + id + serviceProvider { id } + dataService { id } + } + paymentType + feeCut + } }` + ) + const feeCuts = feeCutData.provisionFeeCuts + + console.log(` Found ${feeCuts.length} fee cuts`) + console.log("") + + // Compare each ProvisionFeeCut against on-chain + console.log("=== Comparing ProvisionFeeCuts against on-chain state ===") + let mismatches = 0 + let matches = 0 + + for (const feeCut of feeCuts) { + const onChainFeeCut = await getDelegationFeeCut( + feeCut.provision.serviceProvider.id, + feeCut.provision.dataService.id, + feeCut.paymentType + ) + + const fields = [compareField("feeCut", BigInt(feeCut.feeCut), onChainFeeCut)] + + const fieldMismatches = fields.filter((f) => !f.match) + if (fieldMismatches.length > 0) { + mismatches++ + console.log( + `MISMATCH: ${feeCut.provision.serviceProvider.id} -> ${feeCut.provision.dataService.id} (paymentType=${feeCut.paymentType})` + ) + for (const m of fieldMismatches) { + console.log(m.message) + } + console.log("") + } else { + matches++ + } + + await delay() + } + + // Summary + const results: ValidationResult[] = [{ label: "ProvisionFeeCuts", total: feeCuts.length, matches, mismatches }] + printValidationSummary(results) + + if (mismatches === 0) { + console.log("All fee cuts match on-chain state!") + } + + return mismatches > 0 ? 1 : 0 +} + +runValidation(main)