From 9d22677b9c70a977784bdabec040985b7ecdae57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Migone?= Date: Fri, 15 May 2026 13:16:16 -0300 Subject: [PATCH 1/3] fix: service provider bug deducting thawing tokens early 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/provision.ts | 4 ++-- packages/subgraph/tests/provision.test.ts | 13 +++++++------ packages/tools/README.md | 4 ++-- 3 files changed, 11 insertions(+), 10 deletions(-) diff --git a/packages/subgraph/src/handlers/provision.ts b/packages/subgraph/src/handlers/provision.ts index e0145ca..c9c41c6 100644 --- a/packages/subgraph/src/handlers/provision.ts +++ b/packages/subgraph/src/handlers/provision.ts @@ -105,8 +105,6 @@ export function handleProvisionThawed(event: ProvisionThawed): void { // Provision assert(!provision.isNew, "Provision does not exist.") - assert(provision.entity.tokens >= event.params.tokens, "Thaw exceeds provision tokens.") - 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) @@ -139,6 +137,8 @@ export function handleTokensDeprovisioned(event: TokensDeprovisioned): void { // Provision assert(!provision.isNew, "Provision does not exist.") + assert(provision.entity.tokens >= event.params.tokens, "Deprovision exceeds provision tokens.") + provision.entity.tokens = provision.entity.tokens.minus(event.params.tokens) assert(provision.entity.tokensThawing >= event.params.tokens, "Deprovision exceeds thawing tokens.") provision.entity.tokensThawing = provision.entity.tokensThawing.minus(event.params.tokens) saveProvision(provision.entity, event.block) diff --git a/packages/subgraph/tests/provision.test.ts b/packages/subgraph/tests/provision.test.ts index adfe8f4..6c17e07 100644 --- a/packages/subgraph/tests/provision.test.ts +++ b/packages/subgraph/tests/provision.test.ts @@ -278,7 +278,7 @@ describe("ProvisionThawed", () => { clearStore() }) - test("moves tokens from active to thawing (tokensProvisioned unchanged)", () => { + test("adds to tokensThawing without changing tokens (tokensProvisioned unchanged)", () => { // Setup: deposit and create provision let stakeTokens = BigInt.fromString("10000000000000000000000") // 10000 GRT let depositEvent = createStakeDepositedEvent(SP_ADDRESS, stakeTokens) @@ -294,10 +294,10 @@ describe("ProvisionThawed", () => { handleProvisionThawed(event) let provisionId = getProvisionIdString(SP_ADDRESS, VERIFIER_ADDRESS) - let remainingActiveTokens = provisionTokens.minus(thawAmount) - // Provision: tokens move from active to thawing - assert.fieldEquals("Provision", provisionId, "tokens", remainingActiveTokens.toString()) + // Provision: tokens unchanged, only tokensThawing increases + // Contract semantics: thaw only adds to tokensThawing, doesn't decrement tokens + assert.fieldEquals("Provision", provisionId, "tokens", provisionTokens.toString()) assert.fieldEquals("Provision", provisionId, "tokensThawing", thawAmount.toString()) // ServiceProvider & GraphNetwork: tokensProvisioned unchanged (thawing tokens still count as provisioned) @@ -493,6 +493,7 @@ describe("tokensIdle lifecycle", () => { assert.fieldEquals("ServiceProvider", SP_ADDRESS.toHexString(), "tokensIdle", "4000000000000000000000") // 4. Thaw 1000 GRT - tokensProvisioned stays 6000 (thawing tokens still count as provisioned) + // Contract semantics: thaw only adds to tokensThawing, doesn't decrement tokens let thawAmount = BigInt.fromString("1000000000000000000000") // 1000 GRT let thawEvent = createProvisionThawedEvent(SP_ADDRESS, VERIFIER_ADDRESS, thawAmount) handleProvisionThawed(thawEvent) @@ -501,9 +502,9 @@ describe("tokensIdle lifecycle", () => { assert.fieldEquals("ServiceProvider", SP_ADDRESS.toHexString(), "tokensProvisioned", "6000000000000000000000") assert.fieldEquals("ServiceProvider", SP_ADDRESS.toHexString(), "tokensIdle", "4000000000000000000000") - // Verify tokensThawing on the provision + // Verify tokensThawing on the provision - tokens unchanged, only tokensThawing increases let provisionId = getProvisionIdString(SP_ADDRESS, VERIFIER_ADDRESS) - assert.fieldEquals("Provision", provisionId, "tokens", "5000000000000000000000") + assert.fieldEquals("Provision", provisionId, "tokens", "6000000000000000000000") assert.fieldEquals("Provision", provisionId, "tokensThawing", "1000000000000000000000") // 5. Deprovision 1000 GRT (after thawing completes) - now tokensProvisioned decreases diff --git a/packages/tools/README.md b/packages/tools/README.md index 30da04d..c8b32e3 100644 --- a/packages/tools/README.md +++ b/packages/tools/README.md @@ -30,7 +30,7 @@ Validates that the subgraph data is internally consistent. This catches mapping | Field | Expected Value | |-------|----------------| | `tokensStaked` | Sum of `ServiceProvider.tokensStaked` | -| `tokensProvisioned` | Sum of `Provision.tokens` | +| `tokensProvisioned` | Sum of `Provision.tokens` (includes thawing tokens per contract semantics) | | `tokensDelegated` | Sum of `DelegationPool.tokens` | | `tokensThawingFromProvisions` | Sum of `Provision.tokensThawing` | | `tokensThawingFromDelegationPools` | Sum of `DelegationPool.tokensThawing` | @@ -41,7 +41,7 @@ For each ServiceProvider, validates that aggregate fields match the sum of their | Field | Expected Value | |-------|----------------| -| `tokensProvisioned` | Sum of `Provision.tokens` for this SP | +| `tokensProvisioned` | Sum of `Provision.tokens` for this SP (includes thawing tokens) | | `tokensThawing` | Sum of `Provision.tokensThawing` for this SP | | `tokensDelegated` | Sum of `DelegationPool.tokens` for this SP | | `tokensDelegatedThawing` | Sum of `DelegationPool.tokensThawing` for this SP | From 5b2d99a0243a3376727837b8857b00ed046092df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Migone?= Date: Fri, 15 May 2026 15:06:21 -0300 Subject: [PATCH 2/3] fix: query fees and indexing rewards for legacy allos not being tracked on delegation pools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomás Migone --- packages/subgraph/abis/HorizonStaking.json | 128 +++++++++++++++ packages/subgraph/schema.graphql | 4 + .../config/arbitrum-one/delegation-seed.ts | 143 ++++++++++++++++- .../subgraph/src/config/arbitrum-one/index.ts | 5 +- .../src/config/test/delegation-seed.ts | 7 + packages/subgraph/src/config/test/index.ts | 5 +- packages/subgraph/src/config/types.ts | 12 +- packages/subgraph/src/handlers/legacy.ts | 147 ++++++++++++++++++ packages/subgraph/src/handlers/migration.ts | 9 +- packages/subgraph/src/mapping.ts | 4 + packages/subgraph/subgraph.yaml | 6 + packages/subgraph/tests/migration.test.ts | 3 +- packages/tools/src/onchain.ts | 33 +++- packages/tools/src/seed/delegations.ts | 15 +- 14 files changed, 506 insertions(+), 15 deletions(-) create mode 100644 packages/subgraph/src/handlers/legacy.ts diff --git a/packages/subgraph/abis/HorizonStaking.json b/packages/subgraph/abis/HorizonStaking.json index a1bebae..41d1829 100644 --- a/packages/subgraph/abis/HorizonStaking.json +++ b/packages/subgraph/abis/HorizonStaking.json @@ -2468,5 +2468,133 @@ ], "stateMutability": "nonpayable", "type": "function" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "indexer", + "type": "address" + }, + { + "indexed": true, + "internalType": "bytes32", + "name": "subgraphDeploymentID", + "type": "bytes32" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "epoch", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "tokens", + "type": "uint256" + }, + { + "indexed": true, + "internalType": "address", + "name": "allocationID", + "type": "address" + }, + { + "indexed": false, + "internalType": "address", + "name": "sender", + "type": "address" + }, + { + "indexed": false, + "internalType": "bytes32", + "name": "poi", + "type": "bytes32" + }, + { + "indexed": false, + "internalType": "bool", + "name": "isPublic", + "type": "bool" + } + ], + "name": "AllocationClosed", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "address", + "name": "assetHolder", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "indexer", + "type": "address" + }, + { + "indexed": true, + "internalType": "bytes32", + "name": "subgraphDeploymentID", + "type": "bytes32" + }, + { + "indexed": true, + "internalType": "address", + "name": "allocationID", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "epoch", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "tokens", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "protocolTax", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "curationFees", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "queryFees", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "queryRebates", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "delegationRewards", + "type": "uint256" + } + ], + "name": "RebateCollected", + "type": "event" } ] diff --git a/packages/subgraph/schema.graphql b/packages/subgraph/schema.graphql index 5d633a5..12526b9 100644 --- a/packages/subgraph/schema.graphql +++ b/packages/subgraph/schema.graphql @@ -108,6 +108,10 @@ type DelegationPool @entity(immutable: false) { "Tokens currently thawing" tokensThawing: BigInt! + # Legacy delegation parameters (only set for legacy pools migrated from pre-Horizon) + "[Legacy] Percentage of indexing rewards for the indexer, in PPM. Used to calculate delegation rewards from legacy allocations." + legacyIndexingRewardCut: Int + # Metadata "Block number when entity was created" createdAtBlock: BigInt! diff --git a/packages/subgraph/src/config/arbitrum-one/delegation-seed.ts b/packages/subgraph/src/config/arbitrum-one/delegation-seed.ts index be1a053..1de09a2 100644 --- a/packages/subgraph/src/config/arbitrum-one/delegation-seed.ts +++ b/packages/subgraph/src/config/arbitrum-one/delegation-seed.ts @@ -1,6 +1,6 @@ // AUTO-GENERATED FILE - DO NOT EDIT MANUALLY // Regenerate with: cd packages/tools && NETWORK=arbitrum-one pnpm seed:delegations -// Generated: 2026-05-12T13:42:53.536Z +// Generated: 2026-05-15T17:51:09.515Z // Network: arbitrum-one // Block: 408825706 // @@ -8,6 +8,7 @@ // Note: Individual delegators/delegations are lazy-initialized, not seeded at genesis // Indexer addresses with delegations (for DelegationPool seeding) +// IMPORTANT: LEGACY_INDEXER_REWARD_CUTS must be in the same order as this array export const DELEGATED_INDEXER_ADDRESSES: string[] = [ "0x0058223c6617cca7ce76fc929ec9724cd43d4542", "0x01e110178f15aeec1cccc507939109175dc9c121", @@ -145,3 +146,143 @@ export const DELEGATED_INDEXER_ADDRESSES: string[] = [ "0xfc842f81490dcb37e82d416b2d28327dfb24ba9a", "0xfeff9093f6b32d0e5cddba743b06a1fedb87c004", ] + +// Legacy indexing reward cuts in PPM (parallel array, same order as DELEGATED_INDEXER_ADDRESSES) +// Used to calculate delegation rewards from legacy allocations +export const LEGACY_INDEXER_REWARD_CUTS: i32[] = [ + 831000, + 880000, + 780000, + 610000, + 1000000, + 150000, + 200000, + 250000, + 1000000, + 100000, + 230000, + 1000000, + 1000000, + 1000000, + 350000, + 373300, + 1000000, + 1000000, + 1000000, + 100000, + 1000000, + 480000, + 500000, + 295000, + 1000000, + 301000, + 1000000, + 180000, + 800000, + 780700, + 50000, + 900000, + 350000, + 200000, + 1000000, + 1000000, + 490000, + 20, + 500000, + 999950, + 340000, + 280000, + 600000, + 1000000, + 220100, + 540000, + 1000000, + 1000000, + 1000000, + 900000, + 1000000, + 950000, + 1000000, + 425560, + 1000000, + 700000, + 1000000, + 1000000, + 800000, + 1000000, + 899560, + 697300, + 560000, + 0, + 220600, + 1000000, + 900000, + 1000000, + 200000, + 201500, + 1000000, + 300000, + 250000, + 1000000, + 200000, + 220000, + 255340, + 930000, + 1000000, + 1000000, + 1000000, + 70000, + 183610, + 846000, + 1000000, + 1000000, + 1000000, + 1000000, + 1000000, + 610000, + 880000, + 251060, + 126000, + 1000000, + 200000, + 450000, + 1000000, + 530000, + 885000, + 1000000, + 1000000, + 1000000, + 1000000, + 1000000, + 350000, + 418600, + 360000, + 1000000, + 487065, + 200000, + 247100, + 880000, + 1000000, + 1000000, + 150000, + 1000000, + 1000000, + 207500, + 290000, + 273700, + 1000000, + 177500, + 1000000, + 580000, + 300000, + 1000000, + 1000000, + 960000, + 1000000, + 1000000, + 210000, + 255000, + 1000000, + 278000, + 485000, +] diff --git a/packages/subgraph/src/config/arbitrum-one/index.ts b/packages/subgraph/src/config/arbitrum-one/index.ts index e1e1cdc..b818257 100644 --- a/packages/subgraph/src/config/arbitrum-one/index.ts +++ b/packages/subgraph/src/config/arbitrum-one/index.ts @@ -1,7 +1,7 @@ import { Address } from "@graphprotocol/graph-ts" import { NetworkConfig } from "../types" import { SERVICE_PROVIDER_ADDRESSES } from "./indexer-seed" -import { DELEGATED_INDEXER_ADDRESSES } from "./delegation-seed" +import { DELEGATED_INDEXER_ADDRESSES, LEGACY_INDEXER_REWARD_CUTS } from "./delegation-seed" export const config = new NetworkConfig( "arbitrum-one", @@ -9,5 +9,6 @@ export const config = new NetworkConfig( Address.fromString("0xb2Bb92d0DE618878E438b55D5846cfecD9301105"), 408_825_706, SERVICE_PROVIDER_ADDRESSES, - DELEGATED_INDEXER_ADDRESSES + DELEGATED_INDEXER_ADDRESSES, + LEGACY_INDEXER_REWARD_CUTS ) diff --git a/packages/subgraph/src/config/test/delegation-seed.ts b/packages/subgraph/src/config/test/delegation-seed.ts index 570cd4e..dad907d 100644 --- a/packages/subgraph/src/config/test/delegation-seed.ts +++ b/packages/subgraph/src/config/test/delegation-seed.ts @@ -6,3 +6,10 @@ export const DELEGATED_INDEXER_ADDRESSES: string[] = [ "0x1111111111111111111111111111111111111111", "0x2222222222222222222222222222222222222222", ] + +// Legacy indexing reward cuts in PPM (parallel array, same order as DELEGATED_INDEXER_ADDRESSES) +// Used to calculate delegation rewards from legacy allocations +export const LEGACY_INDEXER_REWARD_CUTS: i32[] = [ + 823076, // 82.3076% to indexer, 17.6924% to delegators + 823076, +] diff --git a/packages/subgraph/src/config/test/index.ts b/packages/subgraph/src/config/test/index.ts index 396e0d5..6fbb272 100644 --- a/packages/subgraph/src/config/test/index.ts +++ b/packages/subgraph/src/config/test/index.ts @@ -1,7 +1,7 @@ import { Address } from "@graphprotocol/graph-ts" import { NetworkConfig } from "../types" import { SERVICE_PROVIDER_ADDRESSES } from "./indexer-seed" -import { DELEGATED_INDEXER_ADDRESSES } from "./delegation-seed" +import { DELEGATED_INDEXER_ADDRESSES, LEGACY_INDEXER_REWARD_CUTS } from "./delegation-seed" export const config = new NetworkConfig( "test", @@ -9,5 +9,6 @@ export const config = new NetworkConfig( Address.fromString("0x5555555555555555555555555555555555555555"), 1, SERVICE_PROVIDER_ADDRESSES, - DELEGATED_INDEXER_ADDRESSES + DELEGATED_INDEXER_ADDRESSES, + LEGACY_INDEXER_REWARD_CUTS ) diff --git a/packages/subgraph/src/config/types.ts b/packages/subgraph/src/config/types.ts index 12025d2..ccdec33 100644 --- a/packages/subgraph/src/config/types.ts +++ b/packages/subgraph/src/config/types.ts @@ -5,8 +5,16 @@ export class NetworkConfig { horizonStakingAddress: Address subgraphServiceAddress: Address startBlock: i32 + // List of existing service providers with stake > 0 at Horizon genesis + // Used to trigger state migration serviceProviderAddresses: string[] + // List of existing delegation pools with tokens > 0 at Horizon genesis + // Used to trigger state migration delegatedIndexerAddresses: string[] + // Legacy indexer reward cuts in PPM (parts per million) + // Parallel array with delegatedIndexerAddresses - same index = same indexer + // Used to calculate delegation rewards from legacy indexing rewards + legacyIndexerRewardCuts: i32[] constructor( network: string, @@ -14,7 +22,8 @@ export class NetworkConfig { subgraphServiceAddress: Address, startBlock: i32, serviceProviderAddresses: string[], - delegatedIndexerAddresses: string[] + delegatedIndexerAddresses: string[], + legacyIndexerRewardCuts: i32[] ) { this.network = network this.horizonStakingAddress = horizonStakingAddress @@ -22,5 +31,6 @@ export class NetworkConfig { this.startBlock = startBlock this.serviceProviderAddresses = serviceProviderAddresses this.delegatedIndexerAddresses = delegatedIndexerAddresses + this.legacyIndexerRewardCuts = legacyIndexerRewardCuts } } diff --git a/packages/subgraph/src/handlers/legacy.ts b/packages/subgraph/src/handlers/legacy.ts new file mode 100644 index 0000000..ff421ff --- /dev/null +++ b/packages/subgraph/src/handlers/legacy.ts @@ -0,0 +1,147 @@ +import { Address, Bytes, crypto, ethereum, BigInt, log } from "@graphprotocol/graph-ts" +import { + AllocationClosed, + RebateCollected, +} from "../../generated/HorizonStaking/HorizonStaking" +import { getOrCreateGraphNetwork, saveGraphNetwork } from "../entities/graphNetwork" +import { getOrCreateServiceProvider, saveServiceProvider } from "../entities/serviceProvider" +import { getOrCreateDelegationPool, saveDelegationPool } from "../entities/delegationPool" +import { config } from "../config" + +// Event signature for HorizonRewardsAssigned(address indexed indexer, address indexed allocationID, uint256 amount) +// keccak256("HorizonRewardsAssigned(address,address,uint256)") +const HORIZON_REWARDS_ASSIGNED_TOPIC = Bytes.fromHexString( + "0xa111914d7f2ea8beca61d12f1a1f38c5533de5f1823c3936422df4404ac2ec68" +) + +const PPM_DIVISOR = BigInt.fromI32(1000000) + +/** + * Handles RebateCollected event from HorizonStakingExtension. + * This event is emitted when query fee rebates are collected. + * The delegationRewards parameter represents tokens added to the legacy delegation pool + * that are NOT emitted via TokensToDelegationPoolAdded because they correspond to + * legacy allocations. + */ +export function handleRebateCollected(event: RebateCollected): void { + let indexer = event.params.indexer + let delegationRewards = event.params.delegationRewards + + // Skip if no delegation rewards + if (delegationRewards.equals(BigInt.zero())) { + return + } + + let indexerBytes = Bytes.fromHexString(indexer.toHexString()) as Bytes + let verifier = Bytes.fromHexString(config.subgraphServiceAddress.toHexString()) as Bytes + + // Update legacy DelegationPool + let pool = getOrCreateDelegationPool( + indexerBytes, + verifier, + event.block.number, + event.block.timestamp + ) + assert(!pool.isNew, "Delegation pool does not exist.") + pool.entity.tokens = pool.entity.tokens.plus(delegationRewards) + saveDelegationPool(pool.entity, event.block) + + // Update ServiceProvider + let serviceProvider = getOrCreateServiceProvider(indexerBytes, event.block.number, event.block.timestamp) + assert(!serviceProvider.isNew, "Service provider does not exist.") + serviceProvider.entity.tokensDelegated = serviceProvider.entity.tokensDelegated.plus(delegationRewards) + saveServiceProvider(serviceProvider.entity, event.block) + + // Update GraphNetwork + let graphNetwork = getOrCreateGraphNetwork() + graphNetwork.tokensDelegated = graphNetwork.tokensDelegated.plus(delegationRewards) + saveGraphNetwork(graphNetwork) +} + +/** + * Handles AllocationClosed event from HorizonStakingExtension. + * When an allocation is closed with a proof of indexing, indexing rewards are distributed. + * Part of these rewards go to the legacy delegation pool via _collectDelegationIndexingRewards(), + * but NO event is emitted for this. We need to look for the associated HorizonRewardsAssigned + * event in the transaction receipt and calculate the delegation rewards ourselves. + */ +export function handleAllocationClosed(event: AllocationClosed): void { + let receipt = event.receipt + if (receipt == null) { + log.critical("Could not find receipt for legacy allocation: {}.", [event.params.allocationID.toHexString()]) + return + } + + let indexer = event.params.indexer + let allocationID = event.params.allocationID + let indexerBytes = Bytes.fromHexString(indexer.toHexString()) as Bytes + + // Look for HorizonRewardsAssigned event in the same transaction + let totalRewards = BigInt.zero() + let foundRewardsEvent = false + + for (let i = 0; i < receipt.logs.length; i++) { + let log = receipt.logs[i] + + // Check if this is a HorizonRewardsAssigned event + if (log.topics.length >= 3 && log.topics[0].equals(HORIZON_REWARDS_ASSIGNED_TOPIC)) { + // topic[1] = indexed indexer address (padded to 32 bytes) + // topic[2] = indexed allocationID address (padded to 32 bytes) + // data = uint256 amount + + // Extract allocationID from topic[2] (last 20 bytes of the 32-byte topic) + let logAllocationID = Address.fromBytes(Bytes.fromUint8Array(log.topics[2].slice(12, 32))) + + // Check if this event is for our allocation + if (logAllocationID.equals(allocationID)) { + // Decode the rewards amount from data + if (log.data.length >= 32) { + totalRewards = ethereum.decode("uint256", log.data)!.toBigInt() + foundRewardsEvent = true + break + } + } + } + } + + // Crash if not found + assert(foundRewardsEvent && totalRewards.notEqual(BigInt.zero()), "Could not found rewards event for allocation.") + + let verifier = Bytes.fromHexString(config.subgraphServiceAddress.toHexString()) as Bytes + + // Get the legacy DelegationPool to read the indexer's reward cut + let pool = getOrCreateDelegationPool( + indexerBytes, + verifier, + event.block.number, + event.block.timestamp + ) + assert(!pool.isNew, "Delegation pool does not exist.") + + // Calculate delegation rewards using the indexer's configured cut + // delegationRewards = totalRewards - (totalRewards * indexerCut / PPM) + let indexerCut = BigInt.fromI32(pool.entity.legacyIndexingRewardCut) + let indexerRewards = totalRewards.times(indexerCut).div(PPM_DIVISOR) + let delegationRewards = totalRewards.minus(indexerRewards) + + // Skip if no delegation rewards + if (delegationRewards.equals(BigInt.zero())) { + return + } + + // Update pool tokens + pool.entity.tokens = pool.entity.tokens.plus(delegationRewards) + saveDelegationPool(pool.entity, event.block) + + // Update ServiceProvider + let serviceProvider = getOrCreateServiceProvider(indexerBytes, event.block.number, event.block.timestamp) + assert(!serviceProvider.isNew, "Service provider does not exist.") + serviceProvider.entity.tokensDelegated = serviceProvider.entity.tokensDelegated.plus(delegationRewards) + saveServiceProvider(serviceProvider.entity, event.block) + + // Update GraphNetwork + let graphNetwork = getOrCreateGraphNetwork() + graphNetwork.tokensDelegated = graphNetwork.tokensDelegated.plus(delegationRewards) + saveGraphNetwork(graphNetwork) +} + diff --git a/packages/subgraph/src/handlers/migration.ts b/packages/subgraph/src/handlers/migration.ts index 04af41b..62486ef 100644 --- a/packages/subgraph/src/handlers/migration.ts +++ b/packages/subgraph/src/handlers/migration.ts @@ -142,7 +142,8 @@ export function migrateDelegationPools(block: ethereum.Block, networkConfig: Net // Process results for (let i = 0; i < results.length; i++) { - let indexerAddress = Address.fromString(indexerAddresses[batchStart + i]) + let globalIndex = batchStart + i + let indexerAddress = Address.fromString(indexerAddresses[globalIndex]) let poolData = decodeGetDelegationPoolResult(results[i]) let poolTokens = poolData[0] @@ -155,6 +156,12 @@ export function migrateDelegationPools(block: ethereum.Block, networkConfig: Net pool.entity.tokens = poolData[0] pool.entity.shares = poolData[1] pool.entity.tokensThawing = poolData[2] + + // Set legacy indexing reward cut from config (parallel array) + if (globalIndex < networkConfig.legacyIndexerRewardCuts.length) { + pool.entity.legacyIndexingRewardCut = networkConfig.legacyIndexerRewardCuts[globalIndex] + } + saveDelegationPool(pool.entity, block) // Update service provider tokensDelegated - Note that this service provider might not exist diff --git a/packages/subgraph/src/mapping.ts b/packages/subgraph/src/mapping.ts index d0c6a9c..14591ae 100644 --- a/packages/subgraph/src/mapping.ts +++ b/packages/subgraph/src/mapping.ts @@ -17,3 +17,7 @@ export { handleDelegatedTokensWithdrawn, handleDelegationSlashed } from "./handlers/delegation" +export { + handleRebateCollected, + handleAllocationClosed +} from "./handlers/legacy" diff --git a/packages/subgraph/subgraph.yaml b/packages/subgraph/subgraph.yaml index 990e81a..b028979 100644 --- a/packages/subgraph/subgraph.yaml +++ b/packages/subgraph/subgraph.yaml @@ -59,4 +59,10 @@ dataSources: handler: handleDelegatedTokensWithdrawn - event: DelegationSlashed(indexed address,indexed address,uint256) handler: handleDelegationSlashed + # Legacy delegation reward events (HorizonStakingExtension) + - event: RebateCollected(address,indexed address,indexed bytes32,indexed address,uint256,uint256,uint256,uint256,uint256,uint256,uint256) + handler: handleRebateCollected + - event: AllocationClosed(indexed address,indexed bytes32,uint256,uint256,indexed address,address,bytes32,bool) + handler: handleAllocationClosed + receipt: true file: ./src/mapping.ts diff --git a/packages/subgraph/tests/migration.test.ts b/packages/subgraph/tests/migration.test.ts index 8cb6242..2350f8e 100644 --- a/packages/subgraph/tests/migration.test.ts +++ b/packages/subgraph/tests/migration.test.ts @@ -154,7 +154,8 @@ describe("migrateServiceProviders with empty config", () => { testConfig.subgraphServiceAddress, 1, [], // empty service provider addresses - [] // empty delegated indexer addresses + [], // empty delegated indexer addresses + [] // empty legacy indexer reward cuts ) let block = createMockBlock(100, 1000) diff --git a/packages/tools/src/onchain.ts b/packages/tools/src/onchain.ts index 7afbd54..186aad1 100644 --- a/packages/tools/src/onchain.ts +++ b/packages/tools/src/onchain.ts @@ -150,6 +150,9 @@ export function encodeGetDelegationPool(serviceProvider: string, verifier: strin // Decode result helpers export function decodeServiceProviderResult(hex: string): ServiceProviderData { const data = hex.startsWith("0x") ? hex.slice(2) : hex + if (!data || data.length < 128) { + return { tokensStaked: 0n, tokensProvisioned: 0n } + } return { tokensStaked: BigInt("0x" + data.slice(0, 64)), tokensProvisioned: BigInt("0x" + data.slice(64, 128)), @@ -158,6 +161,20 @@ export function decodeServiceProviderResult(hex: string): ServiceProviderData { export function decodeProvisionResult(hex: string): ProvisionData { const data = hex.startsWith("0x") ? hex.slice(2) : hex + if (!data || data.length < 640) { + return { + tokens: 0n, + tokensThawing: 0n, + sharesThawing: 0n, + maxVerifierCut: 0n, + thawingPeriod: 0n, + createdAt: 0n, + maxVerifierCutPending: 0n, + thawingPeriodPending: 0n, + lastParametersStagedAt: 0n, + thawingNonce: 0n, + } + } return { tokens: BigInt("0x" + data.slice(0, 64)), tokensThawing: BigInt("0x" + data.slice(64, 128)), @@ -174,6 +191,9 @@ export function decodeProvisionResult(hex: string): ProvisionData { export function decodeDelegationPoolResult(hex: string): DelegationPoolData { const data = hex.startsWith("0x") ? hex.slice(2) : hex + if (!data || data.length < 320) { + return { tokens: 0n, shares: 0n, tokensThawing: 0n, sharesThawing: 0n, thawingNonce: 0n } + } return { tokens: BigInt("0x" + data.slice(0, 64)), shares: BigInt("0x" + data.slice(64, 128)), @@ -233,18 +253,19 @@ export async function multicall(calls: string[]): Promise { 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 + // ABI encoding of bytes[]: + // - bytes 0-31: offset to array data (value 32) + // - bytes 32-63: array length + // - bytes 64+: offset pointers (32 bytes each), relative to byte 64 + // - then: each bytes element as (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 offsetPos = 128 + i * 64 // Position of offset[i] (byte 64 + i*32) const offset = parseInt(resultHex.slice(offsetPos, offsetPos + 64), 16) * 2 - const lengthPos = 64 + offset // relative to after the initial offset + const lengthPos = 128 + offset // Offsets are relative to byte 64 (hex pos 128) const length = parseInt(resultHex.slice(lengthPos, lengthPos + 64), 16) const dataStart = lengthPos + 64 const data = resultHex.slice(dataStart, dataStart + length * 2) diff --git a/packages/tools/src/seed/delegations.ts b/packages/tools/src/seed/delegations.ts index b4b2cd7..32edc09 100644 --- a/packages/tools/src/seed/delegations.ts +++ b/packages/tools/src/seed/delegations.ts @@ -1,5 +1,6 @@ /** - * Exports indexer addresses for seeding DelegationPools in the Network Subgraph. + * Exports indexer addresses and legacy indexing reward cuts for seeding + * DelegationPools in the Network Subgraph. * * Only exports indexer addresses - individual delegators and delegations are * lazy-initialized when they first interact with the subgraph. @@ -17,6 +18,7 @@ import { querySubgraph } from "../common" interface Indexer { id: string delegatedTokens: string + legacyIndexingRewardCut: number } async function main() { @@ -43,6 +45,7 @@ async function main() { `{ indexers(first: 1000, orderBy: id, block: { number: ${config.horizonGenesisBlock} }, ${whereClause}) { id delegatedTokens + legacyIndexingRewardCut } }` ) @@ -76,9 +79,16 @@ async function main() { // Note: Individual delegators/delegations are lazy-initialized, not seeded at genesis // Indexer addresses with delegations (for DelegationPool seeding) +// IMPORTANT: LEGACY_INDEXER_REWARD_CUTS must be in the same order as this array export const DELEGATED_INDEXER_ADDRESSES: string[] = [ ${allIndexers.map((i) => ` "${i.id}",`).join("\n")} ] + +// Legacy indexing reward cuts in PPM (parallel array, same order as DELEGATED_INDEXER_ADDRESSES) +// Used to calculate delegation rewards from legacy allocations +export const LEGACY_INDEXER_REWARD_CUTS: i32[] = [ +${allIndexers.map((i) => ` ${i.legacyIndexingRewardCut},`).join("\n")} +] ` fs.writeFileSync(seedFilePath, output) @@ -88,10 +98,13 @@ ${allIndexers.map((i) => ` "${i.id}",`).join("\n")} console.log("") console.log("=== Summary ===") console.log(` Indexers to seed DelegationPools: ${allIndexers.length}`) + console.log(` Legacy indexing reward cuts exported: ${allIndexers.length}`) console.log("") console.log(" Estimated data size:") const indexerBytes = allIndexers.length * 42 + const rewardCutBytes = allIndexers.length * 8 // i32 as string ~8 bytes console.log(` Indexer addresses: ${(indexerBytes / 1024).toFixed(1)} KB`) + console.log(` Reward cuts: ${(rewardCutBytes / 1024).toFixed(1)} KB`) console.log("") console.log(" Contract calls at genesis:") console.log(` getDelegationPool(): ${allIndexers.length} calls`) From 896c0a44eaeccc8f3d8a3e32e538034e2104541e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Migone?= Date: Fri, 15 May 2026 16:08:25 -0300 Subject: [PATCH 3/3] fix: legacy handler bugs 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/legacy.ts | 28 +++++++++++++++++------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/packages/subgraph/src/handlers/legacy.ts b/packages/subgraph/src/handlers/legacy.ts index ff421ff..aad464b 100644 --- a/packages/subgraph/src/handlers/legacy.ts +++ b/packages/subgraph/src/handlers/legacy.ts @@ -42,13 +42,18 @@ export function handleRebateCollected(event: RebateCollected): void { event.block.number, event.block.timestamp ) - assert(!pool.isNew, "Delegation pool does not exist.") + // If pool doesn't exist, indexer has no delegators to receive rewards + if (pool.isNew) { + return + } pool.entity.tokens = pool.entity.tokens.plus(delegationRewards) saveDelegationPool(pool.entity, event.block) // Update ServiceProvider let serviceProvider = getOrCreateServiceProvider(indexerBytes, event.block.number, event.block.timestamp) - assert(!serviceProvider.isNew, "Service provider does not exist.") + if (serviceProvider.isNew) { + return + } serviceProvider.entity.tokensDelegated = serviceProvider.entity.tokensDelegated.plus(delegationRewards) saveServiceProvider(serviceProvider.entity, event.block) @@ -104,8 +109,11 @@ export function handleAllocationClosed(event: AllocationClosed): void { } } - // Crash if not found - assert(foundRewardsEvent && totalRewards.notEqual(BigInt.zero()), "Could not found rewards event for allocation.") + // If no rewards event found or rewards are zero, nothing to do + // This can happen when allocation is closed without valid POI or force-closed + if (!foundRewardsEvent || totalRewards.equals(BigInt.zero())) { + return + } let verifier = Bytes.fromHexString(config.subgraphServiceAddress.toHexString()) as Bytes @@ -116,7 +124,10 @@ export function handleAllocationClosed(event: AllocationClosed): void { event.block.number, event.block.timestamp ) - assert(!pool.isNew, "Delegation pool does not exist.") + // If pool doesn't exist, indexer has no delegators to receive rewards + if (pool.isNew) { + return + } // Calculate delegation rewards using the indexer's configured cut // delegationRewards = totalRewards - (totalRewards * indexerCut / PPM) @@ -135,9 +146,10 @@ export function handleAllocationClosed(event: AllocationClosed): void { // Update ServiceProvider let serviceProvider = getOrCreateServiceProvider(indexerBytes, event.block.number, event.block.timestamp) - assert(!serviceProvider.isNew, "Service provider does not exist.") - serviceProvider.entity.tokensDelegated = serviceProvider.entity.tokensDelegated.plus(delegationRewards) - saveServiceProvider(serviceProvider.entity, event.block) + if (!serviceProvider.isNew) { + serviceProvider.entity.tokensDelegated = serviceProvider.entity.tokensDelegated.plus(delegationRewards) + saveServiceProvider(serviceProvider.entity, event.block) + } // Update GraphNetwork let graphNetwork = getOrCreateGraphNetwork()