diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 1bc35c2..4043be3 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -21,7 +21,12 @@ "Bash(pnpm test:*)", "Bash(pnpm --filter @graphprotocol/graph-horizon-subgraph test)", "Bash(pnpm --filter @graphprotocol/graph-horizon-validation exec tsc:*)", - "Bash(pnpm add:*)" + "Bash(pnpm add:*)", + "Bash(npx tsx:*)", + "Bash(cast sig:*)", + "Bash(npx tsc:*)", + "Bash(npm run build:*)", + "Bash(npm run test:*)" ] } } diff --git a/packages/subgraph/schema.graphql b/packages/subgraph/schema.graphql index bb0faa1..8428621 100644 --- a/packages/subgraph/schema.graphql +++ b/packages/subgraph/schema.graphql @@ -5,10 +5,18 @@ type GraphNetwork @entity(immutable: false) { # Counts "Active service providers" countServiceProviders: Int! + "Active provisions" + countProvisions: Int! + "Active delegation pools" + countDelegationPools: Int! # Stake aggregates "Total tokens staked by service providers" tokensStaked: BigInt! + "Total tokens provisioned to data services" + tokensProvisioned: BigInt! + "Total tokens delegated to service providers" + tokensDelegated: BigInt! } type ServiceProvider @entity(immutable: false) { @@ -22,6 +30,86 @@ type ServiceProvider @entity(immutable: false) { tokensProvisioned: BigInt! "Tokens that are not locked in provisions" tokensIdle: BigInt! + "Tokens delegated to this service provider" + tokensDelegated: BigInt! + + # Provisions + "Provisions created by this service provider" + provisions: [Provision!]! @derivedFrom(field: "serviceProvider") + + # Delegation pools + "Delegation pools for this service provider" + delegationPools: [DelegationPool!]! @derivedFrom(field: "serviceProvider") + + # Metadata + "Block number when entity was created" + createdAtBlock: BigInt! + "Timestamp when entity was created" + createdAt: BigInt! + "Block number when entity was last updated" + updatedAtBlock: BigInt! + "Timestamp when entity was last updated" + updatedAt: BigInt! +} + +type DelegationPool @entity(immutable: false) { + "Composite ID: serviceProvider-verifier" + id: Bytes! + + # Relationships + "Service provider that owns this pool" + serviceProvider: ServiceProvider! + "Verifier address (data service)" + verifier: Bytes! + + # Pool state + "Total tokens in the pool" + tokens: BigInt! + "Total shares in the pool" + shares: BigInt! + "Tokens currently thawing" + tokensThawing: BigInt! + + # Metadata + "Block number when entity was created" + createdAtBlock: BigInt! + "Timestamp when entity was created" + createdAt: BigInt! + "Block number when entity was last updated" + updatedAtBlock: BigInt! + "Timestamp when entity was last updated" + updatedAt: BigInt! +} + +type Provision @entity(immutable: false) { + "Composite ID: serviceProvider-verifier" + id: Bytes! + + # Relationships + "Service provider that created this provision" + serviceProvider: ServiceProvider! + "Verifier address (data service)" + verifier: Bytes! + + # Tokens + "Tokens currently provisioned" + tokens: BigInt! + "Tokens currently thawing" + tokensThawing: BigInt! + + # Parameters + "Maximum cut the verifier can take (PPM)" + maxVerifierCut: BigInt! + "Thawing period in seconds" + thawingPeriod: BigInt! + + # Staged parameters (pending acceptance) + "Staged max verifier cut" + maxVerifierCutPending: BigInt! + "Staged thawing period" + thawingPeriodPending: BigInt! + "Timestamp when parameters were last staged" + lastParametersStagedAt: BigInt! # Metadata "Block number when entity was created" diff --git a/packages/subgraph/scripts/fetch-indexers.sh b/packages/subgraph/scripts/fetch-indexers.sh deleted file mode 100755 index 71d70b6..0000000 --- a/packages/subgraph/scripts/fetch-indexers.sh +++ /dev/null @@ -1,31 +0,0 @@ -#!/bin/bash - -# Fetches all indexer addresses from the Graph Network subgraph at a specific block -# and outputs them in AssemblyScript array format -# -# Usage: GRAPH_API_KEY=your_key ./scripts/fetch-indexers.sh > src/config/arbitrum-one/seed.ts -# -# Set BLOCK_NUMBER env var to query at a specific block (defaults to Horizon Arbitrum One genesis: 408825706) - -if [ -z "$GRAPH_API_KEY" ]; then - echo "Error: GRAPH_API_KEY environment variable is required" >&2 - exit 1 -fi - -BLOCK_NUMBER=${BLOCK_NUMBER:-408825706} - -echo "Fetching indexers at block $BLOCK_NUMBER..." >&2 - -curl -sX POST \ - -H "Content-Type: application/json" \ - -H "Authorization: Bearer $GRAPH_API_KEY" \ - -d "{\"query\": \"{ indexers(block: { number: $BLOCK_NUMBER }, where: { stakedTokens_gt: \\\"0\\\" }) { id } }\", \"variables\": {}}" \ - https://gateway.thegraph.com/api/subgraphs/id/DZz4kDTdmzWLWsV373w2bSmoar3umKKH9y82SUKr5qmp \ - | jq -r '.data.indexers[].id' \ - | awk -v block="$BLOCK_NUMBER" 'BEGIN{ - print "// AUTO-GENERATED FILE - DO NOT EDIT MANUALLY" - print "// Regenerate with: GRAPH_API_KEY=your_key ./scripts/fetch-indexers.sh > src/config/arbitrum-one/seed.ts" - print "// Generated at block: " block - print "" - print "export const SERVICE_PROVIDER_ADDRESSES: string[] = [" - } {print " \""$0"\","} END{print "]"}' diff --git a/packages/subgraph/src/common/multicall.ts b/packages/subgraph/src/common/multicall.ts new file mode 100644 index 0000000..dee0842 --- /dev/null +++ b/packages/subgraph/src/common/multicall.ts @@ -0,0 +1,85 @@ +import { Address, Bytes, BigInt, crypto, ethereum } from "@graphprotocol/graph-ts" + +// Function selectors (first 4 bytes of keccak256 of function signature) +// getStake(address) -> keccak256("getStake(address)")[0:4] +export const GET_STAKE_SELECTOR = Bytes.fromHexString("0x7a766460") as Bytes +// getDelegationPool(address,address) -> keccak256("getDelegationPool(address,address)")[0:4] +export const GET_DELEGATION_POOL_SELECTOR = Bytes.fromHexString("0x561285e4") as Bytes +// getDelegation(address,address,address) -> keccak256("getDelegation(address,address,address)")[0:4] +export const GET_DELEGATION_SELECTOR = Bytes.fromHexString("0x15049a5a") as Bytes + +/** + * Encodes a getStake(address) call + */ +export function encodeGetStake(serviceProvider: Address): Bytes { + let encoded = ethereum.encode(ethereum.Value.fromAddress(serviceProvider))! + return GET_STAKE_SELECTOR.concat(encoded) +} + +/** + * Encodes a getDelegationPool(address,address) call + */ +export function encodeGetDelegationPool(serviceProvider: Address, verifier: Address): Bytes { + let tuple = new ethereum.Tuple() + tuple.push(ethereum.Value.fromAddress(serviceProvider)) + tuple.push(ethereum.Value.fromAddress(verifier)) + let encoded = ethereum.encode(ethereum.Value.fromTuple(tuple))! + return GET_DELEGATION_POOL_SELECTOR.concat(encoded) +} + +/** + * Encodes a getDelegation(address,address,address) call + */ +export function encodeGetDelegation( + serviceProvider: Address, + verifier: Address, + delegator: Address +): Bytes { + let tuple = new ethereum.Tuple() + tuple.push(ethereum.Value.fromAddress(serviceProvider)) + tuple.push(ethereum.Value.fromAddress(verifier)) + tuple.push(ethereum.Value.fromAddress(delegator)) + let encoded = ethereum.encode(ethereum.Value.fromTuple(tuple))! + return GET_DELEGATION_SELECTOR.concat(encoded) +} + +/** + * Decodes a getStake result (uint256) + */ +export function decodeGetStakeResult(data: Bytes): BigInt { + let decoded = ethereum.decode("(uint256)", data) + if (decoded == null) { + return BigInt.zero() + } + return decoded.toTuple()[0].toBigInt() +} + +/** + * Decodes a getDelegationPool result (uint256,uint256,uint256,uint256,uint256) + * Returns: [tokens, shares, tokensThawing, sharesThawing, thawingNonce] + */ +export function decodeGetDelegationPoolResult(data: Bytes): BigInt[] { + let decoded = ethereum.decode("(uint256,uint256,uint256,uint256,uint256)", data) + if (decoded == null) { + return [BigInt.zero(), BigInt.zero(), BigInt.zero(), BigInt.zero(), BigInt.zero()] + } + let tuple = decoded.toTuple() + return [ + tuple[0].toBigInt(), + tuple[1].toBigInt(), + tuple[2].toBigInt(), + tuple[3].toBigInt(), + tuple[4].toBigInt(), + ] +} + +/** + * Decodes a getDelegation result (uint256) - just shares + */ +export function decodeGetDelegationResult(data: Bytes): BigInt { + let decoded = ethereum.decode("(uint256)", data) + if (decoded == null) { + return BigInt.zero() + } + return decoded.toTuple()[0].toBigInt() +} diff --git a/packages/subgraph/src/config/arbitrum-one/delegation-seed.ts b/packages/subgraph/src/config/arbitrum-one/delegation-seed.ts new file mode 100644 index 0000000..be1a053 --- /dev/null +++ b/packages/subgraph/src/config/arbitrum-one/delegation-seed.ts @@ -0,0 +1,147 @@ +// 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 +// Network: arbitrum-one +// Block: 408825706 +// +// Indexers with delegations: 135 +// Note: Individual delegators/delegations are lazy-initialized, not seeded at genesis + +// Indexer addresses with delegations (for DelegationPool seeding) +export const DELEGATED_INDEXER_ADDRESSES: string[] = [ + "0x0058223c6617cca7ce76fc929ec9724cd43d4542", + "0x01e110178f15aeec1cccc507939109175dc9c121", + "0x01f17c392614c7ea586e7272ed348efee21b90a3", + "0x047739cb42c227489cccd11aa5f28aa4eb46d2cf", + "0x066636093e6c3417a0b46c3ecfbd34b5bda00092", + "0x07ca020fdde5c57c1c3a783befdb08929cf77fec", + "0x089f78d8cf0a5ae1b7a581b1910a73f8cb3e4774", + "0x0b9d582b7fdd387ba13ad7f453d49af255a8ed5e", + "0x0df89dd9c34f78f70eb6a528a1eeac9a6238a2af", + "0x0ee8e5dfea4315fe69a4e0fe84f50cbfce8f1021", + "0x0fd8fd1dc8162148cb9413062fe6c6b144335dbf", + "0x1254cecc9da2b8c8ea40d3bc702ad7c17c9de7ad", + "0x17def1a43a323c711c7a32101ecf41e58eff54a2", + "0x19a4fe7d0c76490cca77b45580846cdb38b9a406", + "0x1b7e0068ca1d7929c8c56408d766e1510e54d98d", + "0x1b92e4cba0f82c85c1298af861247849988c788c", + "0x1bb75c83a3d25afe7dc216177d876b19a4d59ed5", + "0x1c151923cf6c381c4af6c3071a2773b3cdbbf704", + "0x2121bc6437100fc21d19a9eea30898419e020afa", + "0x269ebeee083ce6f70486a67dc8036a889bf322a9", + "0x2b3c7d1ef5fdfc0557934019c531d3e70d6200ae", + "0x2e15f3f0d37b191c33ee06e953c8cce4c493b47a", + "0x2e8d26e9b0d280738728e71c37bf05e70a636238", + "0x2f09092aacd80196fc984908c5a9a7ab3ee4f1ce", + "0x3185992ac24824097047f2eb9af066bde07919b8", + "0x326c584e0f0eab1f1f83c93cc6ae1acc0feba0bc", + "0x32bbd16a94ebb289edceebe77f35acc82664157b", + "0x345c9a221e4caa15f78fb8078c441745de48a225", + "0x35917c0eb91d2e21bef40940d028940484230c06", + "0x3717cef8020bddee7a18f4efb2bfa88fefdcb1bc", + "0x3863a65ce278a240f9aa2a4b4a48493be59e6139", + "0x38f412c8d6346a17a53ff9ceecd2e01acecd27c0", + "0x3b9ba748691f135b71582dc3292e5e3ed7e13341", + "0x3e1536fc83cd5bed83a521a26034ff3e59c6a7c4", + "0x3f74870f80ff7449fe4c6ff257da5fa72734c970", + "0x40a8acdebaf6c0ae0ef04ad375b0869f885c8d82", + "0x4207df52f7ff229364899b21e9c4ad36d3d42981", + "0x474e571ab6dd77489ec3c7ddf9cbc893fcba684c", + "0x475a34f498b0d63a90f9a930913c9a66d79198c9", + "0x4c2bcfd78a30931b7ca73c84f05670bb34ce58ee", + "0x4d67938e9b07681526fe0345a45b770bba88c659", + "0x4e5c87772c29381bcabc58c3f182b6633b5a274a", + "0x4f45530236e1c6603870301010dcab7b561125b3", + "0x4fc5a6f02862ce1d663cd031a60a6d9149a441e2", + "0x51637a35f7f054c98ed51904de939b9561d37885", + "0x53dbbc9d916b1840a2c4c26b150ba2e13f36e10f", + "0x550c1f4814a85aa10f5f061ca8c45e2ee9620226", + "0x563040fa6547054620b9c02ba288b6f7267ae6ff", + "0x594f52d3fccda979bb036bf14ea43a9d6f307f35", + "0x59749d1fa9635cd0413aeff5ee295490a7e87f54", + "0x5af569b692b0598721461027dbbecde74d465d99", + "0x5b3c8f7245dfbd9bea22d9c4b975df60a638e5a3", + "0x5d7c6e41ca423c4877e0124a9a3de0817dcb43f1", + "0x5ddee9720e17aad28febb55643cd8ab50c51c60b", + "0x600f2b53719e1dbacf340572b31a9df9921b82fa", + "0x63c9dc729ba7a22bb8605216b24a34b902e5fe94", + "0x65a077ebd29e5e7ed8997a4b923dc7dbdb7c98a3", + "0x665e4bc3571ebf27ea282fa4b2be995501ec883c", + "0x6c7c332a090c8d2085857cf3220ea01c6d45a723", + "0x6f3ce93a09f30f18d728d2364268b5fe9444b89e", + "0x6f8a032b4b1ee622ef2f0fc091bdbb98cfae81a3", + "0x6f9bb7e454f5b3eb2310343f0e99269dc2bb8a1d", + "0x748aefb5c1c30c4a4ea963a6c49e90c00bdca15b", + "0x74dbb201ecc0b16934e68377bc13013883d9417b", + "0x7bb834017672b1135466661d8dd69c5dd0b3bf51", + "0x80451b373f865f8143ee6a75445228c6c097ae7e", + "0x863e1fc588651c432a1bbb7c8afdf2c3d34921ea", + "0x8bbe94c2894f76406568dfb44e905dac4b7df699", + "0x8cc22436ba6f07a4d5dd2043e3109267eee5aab8", + "0x8d632dfc2454d624910fe982e85a5b15d2ae93c5", + "0x8d9b156dfe8fd8191b7a4e1c0f5903b0984afcd3", + "0x8f689a83dd52eaa1d5ad6a40c46189b4a0d70b06", + "0x9082f497bdc512d08ffde50d6fce28e72c2addcf", + "0x915b75d0f2a05efbf34342756942c55ece0944ac", + "0x918fcc24e6b7f5ec73b4cf766e2393d8fe707541", + "0x91f137715ef8e141d6a8074cbbbc53a0f76b09c5", + "0x920fdeb00ee04dd72f62d8a8f80f13c82ef76c1e", + "0x958ad15ea21fb79b935524cf90c90276031bee20", + "0x995349b0bba3300049f2e794920151f64a78c691", + "0x9a8be56015d32995e82745e14cb9d5dfb0cfac9d", + "0x9ada64ca2fa91318e2de0ff678a8a935859954f4", + "0x9d2adf4bbbe140020baf520ed084f8a1f38f643c", + "0x9da1017766bfeb2835db4f811516eea68996538b", + "0xa01b06b0e9feb016d5ab669ce89d059bc666e569", + "0xa181d0f242b3730f8a244cc94eda05faf17a43e8", + "0xa4d629ba2ffb3321008d8cec37cca077696bf24d", + "0xa959b5afe73c6faa803541b5c4edc0492dfda294", + "0xa9b5da22440dedd72535ec0525582ca0b63be5e1", + "0xaa988dcb035518bc0e20082a3148a5d3dfd1776d", + "0xaddd3e23599d2b7267067afbcd18830aefca640a", + "0xae9bfdf9eeec808f4f3f6f455cb1968445cc6f2f", + "0xb4b4570df6f7fe320f10fdfb702dba7e35244550", + "0xb54c7c9fa1a51300e6054b70ae9952c1fb2800b4", + "0xb8950c47e8b9e539601cb47a167de8bf4cb1289e", + "0xb8ca929e2bd96548cabcabc56cfc9a5147cef0ff", + "0xbb31364ed8192494371d94a6c1cab02351ebfcbb", + "0xbdfb5ee5a2abf4fc7bb1bd1221067aef7f9de491", + "0xc55c63563efb36f7cc65ac3060c52987c6694b37", + "0xc7c3c9586a10e4b40373f2f2f223b3a3c04cc6c6", + "0xc9014686f6336ad558b539565d5dff840b339082", + "0xcba919cf1ee9c537545975d9968718a09d20f071", + "0xcfd990b8297e7ff46f8acef32bec516f4395ac6b", + "0xd25b44bad8812d1c028e0db075f0466285424c37", + "0xd6a51a6cd8abc896f560e2bc9e1bdfc5ef4fa45b", + "0xda20dde459c8d918f81566995d899a046d4d8503", + "0xdc7daef4d0751a9f6ec28b06d6d9475b13eb0918", + "0xdeb712db301285ed483ef9e02dd08a1980f273f1", + "0xdec965f0604125be05cd8a136c85d02ef344d61a", + "0xdecba5154aab37ae5e381a19f804f3af4d1bcbb5", + "0xe0ceec6daa59cc951f3f71d6fc4221e55ef6c386", + "0xe13840a2e92e0cb17a246609b432d0fa2e418774", + "0xe2571c87f1433ea06be389e427af2a17bfd37fc0", + "0xe338631b024afcc15aad7c987cda6aa09bd50ad0", + "0xe4012a6392527d6e24df2b90b56c3fdb3f5598be", + "0xe63e935fba572784d5aa40715e372e7948bbdb12", + "0xe6de2325ef1aac1f058fae59d3c38a472f569846", + "0xe91273727203bcc827521fc8b0c762d435c3c5d0", + "0xe9e284277648fcdb09b8efc1832c73c09b5ecf59", + "0xeccdf8231326a9c5aad32df76a633aaa4c49b104", + "0xedca8740873152ff30a2696add66d1ab41882beb", + "0xeeeee689aa442c607105f29f06d00d2f748776b2", + "0xf00f7157fa8fd0420b87956d46058a16b2f23adc", + "0xf03e9a7e40f09772c3c368b9de14c6d7370717b9", + "0xf0e33495afe697dbe32168bd0743ba32ebb978a3", + "0xf435dee64819590c1a3f5913822e1c04afebe695", + "0xf57dcd881ebd1d81df99194abdfa90f9e0e92f11", + "0xf61dafa923a6cbcee6b73683c3f3ff099b713436", + "0xf671c6b83f44ead14ca1c5f4a629f1b9b18c8f29", + "0xf6a9bad58e74b5165dc31ef24be4377b192f274a", + "0xf7793bf9561c32ffbac603ce572fa55643f9cf72", + "0xf9123292b4d958c53aaad8c5df0138ee0e62944b", + "0xf92f430dd8567b0d466358c79594ab58d919a6d4", + "0xfb168335f5a3868a03696904ed38fa95fd167c0e", + "0xfc842f81490dcb37e82d416b2d28327dfb24ba9a", + "0xfeff9093f6b32d0e5cddba743b06a1fedb87c004", +] diff --git a/packages/subgraph/src/config/arbitrum-one/index.ts b/packages/subgraph/src/config/arbitrum-one/index.ts index 794df9f..e1e1cdc 100644 --- a/packages/subgraph/src/config/arbitrum-one/index.ts +++ b/packages/subgraph/src/config/arbitrum-one/index.ts @@ -1,10 +1,13 @@ import { Address } from "@graphprotocol/graph-ts" import { NetworkConfig } from "../types" -import { SERVICE_PROVIDER_ADDRESSES } from "./seed" +import { SERVICE_PROVIDER_ADDRESSES } from "./indexer-seed" +import { DELEGATED_INDEXER_ADDRESSES } from "./delegation-seed" export const config = new NetworkConfig( "arbitrum-one", Address.fromString("0x00669A4CF01450B64E8A2A20E9b1FCB71E61eF03"), + Address.fromString("0xb2Bb92d0DE618878E438b55D5846cfecD9301105"), 408_825_706, - SERVICE_PROVIDER_ADDRESSES + SERVICE_PROVIDER_ADDRESSES, + DELEGATED_INDEXER_ADDRESSES ) diff --git a/packages/subgraph/src/config/arbitrum-one/seed.ts b/packages/subgraph/src/config/arbitrum-one/indexer-seed.ts similarity index 96% rename from packages/subgraph/src/config/arbitrum-one/seed.ts rename to packages/subgraph/src/config/arbitrum-one/indexer-seed.ts index b70b4c2..f68e510 100644 --- a/packages/subgraph/src/config/arbitrum-one/seed.ts +++ b/packages/subgraph/src/config/arbitrum-one/indexer-seed.ts @@ -1,6 +1,9 @@ // AUTO-GENERATED FILE - DO NOT EDIT MANUALLY -// Regenerate with: GRAPH_API_KEY=your_key ./scripts/fetch-indexers.sh > src/config/arbitrum-one/seed.ts -// Generated at block: 408825706 +// Regenerate with: cd packages/tools && NETWORK=arbitrum-one pnpm seed:indexers +// Generated: 2026-05-11T19:57:41.280Z +// Network: arbitrum-one +// Block: 408825706 +// Count: 98 export const SERVICE_PROVIDER_ADDRESSES: string[] = [ "0x0058223c6617cca7ce76fc929ec9724cd43d4542", diff --git a/packages/subgraph/src/config/test/delegation-seed.ts b/packages/subgraph/src/config/test/delegation-seed.ts new file mode 100644 index 0000000..570cd4e --- /dev/null +++ b/packages/subgraph/src/config/test/delegation-seed.ts @@ -0,0 +1,8 @@ +// Test seed data for delegation unit testing +// Individual delegators/delegations are lazy-initialized, not seeded at genesis + +// Indexer addresses with delegations (for DelegationPool seeding) +export const DELEGATED_INDEXER_ADDRESSES: string[] = [ + "0x1111111111111111111111111111111111111111", + "0x2222222222222222222222222222222222222222", +] diff --git a/packages/subgraph/src/config/test/index.ts b/packages/subgraph/src/config/test/index.ts index 1fb6271..396e0d5 100644 --- a/packages/subgraph/src/config/test/index.ts +++ b/packages/subgraph/src/config/test/index.ts @@ -1,10 +1,13 @@ import { Address } from "@graphprotocol/graph-ts" import { NetworkConfig } from "../types" -import { SERVICE_PROVIDER_ADDRESSES } from "./seed" +import { SERVICE_PROVIDER_ADDRESSES } from "./indexer-seed" +import { DELEGATED_INDEXER_ADDRESSES } from "./delegation-seed" export const config = new NetworkConfig( "test", Address.fromString("0x4444444444444444444444444444444444444444"), + Address.fromString("0x5555555555555555555555555555555555555555"), 1, - SERVICE_PROVIDER_ADDRESSES + SERVICE_PROVIDER_ADDRESSES, + DELEGATED_INDEXER_ADDRESSES ) diff --git a/packages/subgraph/src/config/test/seed.ts b/packages/subgraph/src/config/test/indexer-seed.ts similarity index 100% rename from packages/subgraph/src/config/test/seed.ts rename to packages/subgraph/src/config/test/indexer-seed.ts diff --git a/packages/subgraph/src/config/types.ts b/packages/subgraph/src/config/types.ts index baba2c3..12025d2 100644 --- a/packages/subgraph/src/config/types.ts +++ b/packages/subgraph/src/config/types.ts @@ -3,18 +3,24 @@ import { Address } from "@graphprotocol/graph-ts" export class NetworkConfig { network: string horizonStakingAddress: Address + subgraphServiceAddress: Address startBlock: i32 serviceProviderAddresses: string[] + delegatedIndexerAddresses: string[] constructor( network: string, horizonStakingAddress: Address, + subgraphServiceAddress: Address, startBlock: i32, - serviceProviderAddresses: string[] + serviceProviderAddresses: string[], + delegatedIndexerAddresses: string[] ) { this.network = network this.horizonStakingAddress = horizonStakingAddress + this.subgraphServiceAddress = subgraphServiceAddress this.startBlock = startBlock this.serviceProviderAddresses = serviceProviderAddresses + this.delegatedIndexerAddresses = delegatedIndexerAddresses } } diff --git a/packages/subgraph/src/entities/delegationPool.ts b/packages/subgraph/src/entities/delegationPool.ts new file mode 100644 index 0000000..b21a726 --- /dev/null +++ b/packages/subgraph/src/entities/delegationPool.ts @@ -0,0 +1,54 @@ +import { BigInt, Bytes, ethereum } from "@graphprotocol/graph-ts" +import { DelegationPool } from "../../generated/schema" +import { BIGINT_ZERO } from "../common/constants" + +export function getDelegationPoolId(serviceProvider: Bytes, verifier: Bytes): Bytes { + return serviceProvider.concat(verifier) +} + +export class DelegationPoolResult { + entity: DelegationPool + isNew: boolean + + constructor(entity: DelegationPool, isNew: boolean) { + this.entity = entity + this.isNew = isNew + } +} + +/** + * Gets or creates a DelegationPool entity. + * Pools are seeded at genesis for all indexers with delegations. + * New pools after genesis are created via TokensDelegated events. + */ +export function getOrCreateDelegationPool( + serviceProvider: Bytes, + verifier: Bytes, + blockNumber: BigInt, + timestamp: BigInt +): DelegationPoolResult { + let id = getDelegationPoolId(serviceProvider, verifier) + let entity = DelegationPool.load(id) + let isNew = entity == null + + if (entity == null) { + entity = new DelegationPool(id) + entity.serviceProvider = serviceProvider + entity.verifier = verifier + entity.tokens = BIGINT_ZERO + entity.shares = BIGINT_ZERO + entity.tokensThawing = BIGINT_ZERO + entity.createdAtBlock = blockNumber + entity.createdAt = timestamp + entity.updatedAtBlock = blockNumber + entity.updatedAt = timestamp + } + + return new DelegationPoolResult(entity, isNew) +} + +export function saveDelegationPool(pool: DelegationPool, block: ethereum.Block): void { + pool.updatedAtBlock = block.number + pool.updatedAt = block.timestamp + pool.save() +} diff --git a/packages/subgraph/src/entities/graphNetwork.ts b/packages/subgraph/src/entities/graphNetwork.ts index 26c2fa1..7be7349 100644 --- a/packages/subgraph/src/entities/graphNetwork.ts +++ b/packages/subgraph/src/entities/graphNetwork.ts @@ -1,4 +1,3 @@ -import { BigInt } from "@graphprotocol/graph-ts" import { GraphNetwork } from "../../generated/schema" import { BIGINT_ZERO, GRAPH_NETWORK_ID } from "../common/constants" @@ -7,27 +6,15 @@ export function getOrCreateGraphNetwork(): GraphNetwork { if (entity == null) { entity = new GraphNetwork(GRAPH_NETWORK_ID) entity.countServiceProviders = 0 + entity.countProvisions = 0 + entity.countDelegationPools = 0 entity.tokensStaked = BIGINT_ZERO + entity.tokensProvisioned = BIGINT_ZERO + entity.tokensDelegated = BIGINT_ZERO } return entity } -export function updateGraphNetworkOnStakeDeposit( - graphNetwork: GraphNetwork, - tokens: BigInt, - isNewServiceProvider: boolean -): void { - graphNetwork.tokensStaked = graphNetwork.tokensStaked.plus(tokens) - if (isNewServiceProvider) { - graphNetwork.countServiceProviders += 1 - } -} - -export function updateGraphNetworkOnStakeWithdraw( - graphNetwork: GraphNetwork, - tokens: BigInt -): void { - assert(graphNetwork.tokensStaked >= tokens, "Withdraw exceeds total staked") - - graphNetwork.tokensStaked = graphNetwork.tokensStaked.minus(tokens) +export function saveGraphNetwork(graphNetwork: GraphNetwork): void { + graphNetwork.save() } diff --git a/packages/subgraph/src/entities/provision.ts b/packages/subgraph/src/entities/provision.ts new file mode 100644 index 0000000..1b248a6 --- /dev/null +++ b/packages/subgraph/src/entities/provision.ts @@ -0,0 +1,53 @@ +import { BigInt, Bytes, ethereum } from "@graphprotocol/graph-ts" +import { Provision } from "../../generated/schema" +import { BIGINT_ZERO } from "../common/constants" + +export function getProvisionId(serviceProvider: Bytes, verifier: Bytes): Bytes { + return serviceProvider.concat(verifier) +} + +export class ProvisionResult { + entity: Provision + isNew: boolean + + constructor(entity: Provision, isNew: boolean) { + this.entity = entity + this.isNew = isNew + } +} + +export function getOrCreateProvision( + serviceProvider: Bytes, + verifier: Bytes, + blockNumber: BigInt, + timestamp: BigInt +): ProvisionResult { + let id = getProvisionId(serviceProvider, verifier) + let entity = Provision.load(id) + let isNew = entity == null + + if (entity == null) { + entity = new Provision(id) + entity.serviceProvider = serviceProvider + entity.verifier = verifier + entity.tokens = BIGINT_ZERO + entity.tokensThawing = BIGINT_ZERO + entity.maxVerifierCut = BIGINT_ZERO + entity.thawingPeriod = BIGINT_ZERO + entity.maxVerifierCutPending = BIGINT_ZERO + entity.thawingPeriodPending = BIGINT_ZERO + entity.lastParametersStagedAt = BIGINT_ZERO + entity.createdAtBlock = blockNumber + entity.createdAt = timestamp + entity.updatedAtBlock = blockNumber + entity.updatedAt = timestamp + } + + return new ProvisionResult(entity, isNew) +} + +export function saveProvision(provision: Provision, block: ethereum.Block): void { + provision.updatedAtBlock = block.number + provision.updatedAt = block.timestamp + provision.save() +} diff --git a/packages/subgraph/src/entities/serviceProvider.ts b/packages/subgraph/src/entities/serviceProvider.ts index f5a736e..6c04cda 100644 --- a/packages/subgraph/src/entities/serviceProvider.ts +++ b/packages/subgraph/src/entities/serviceProvider.ts @@ -1,4 +1,4 @@ -import { BigInt, Bytes } from "@graphprotocol/graph-ts" +import { BigInt, Bytes, ethereum } from "@graphprotocol/graph-ts" import { ServiceProvider } from "../../generated/schema" import { BIGINT_ZERO } from "../common/constants" @@ -25,6 +25,7 @@ export function getOrCreateServiceProvider( entity.tokensStaked = BIGINT_ZERO entity.tokensProvisioned = BIGINT_ZERO entity.tokensIdle = BIGINT_ZERO + entity.tokensDelegated = BIGINT_ZERO entity.createdAtBlock = blockNumber entity.createdAt = timestamp entity.updatedAtBlock = blockNumber @@ -34,30 +35,8 @@ export function getOrCreateServiceProvider( return new ServiceProviderResult(entity, isNew) } -export function updateServiceProviderOnStakeDeposit( - serviceProvider: ServiceProvider, - tokens: BigInt, - blockNumber: BigInt, - timestamp: BigInt -): void { - serviceProvider.tokensStaked = serviceProvider.tokensStaked.plus(tokens) - // TODO: Update tokensIdle when provision handlers are implemented - // serviceProvider.tokensIdle = serviceProvider.tokensStaked.minus(serviceProvider.tokensProvisioned) - serviceProvider.updatedAtBlock = blockNumber - serviceProvider.updatedAt = timestamp -} - -export function updateServiceProviderOnStakeWithdraw( - serviceProvider: ServiceProvider, - tokens: BigInt, - blockNumber: BigInt, - timestamp: BigInt -): void { - assert(serviceProvider.tokensStaked >= tokens, "Withdraw exceeds staked tokens") - - serviceProvider.tokensStaked = serviceProvider.tokensStaked.minus(tokens) - // TODO: Update tokensIdle when provision handlers are implemented - // serviceProvider.tokensIdle = serviceProvider.tokensStaked.minus(serviceProvider.tokensProvisioned) - serviceProvider.updatedAtBlock = blockNumber - serviceProvider.updatedAt = timestamp +export function saveServiceProvider(sp: ServiceProvider, block: ethereum.Block): void { + sp.updatedAtBlock = block.number + sp.updatedAt = block.timestamp + sp.save() } diff --git a/packages/subgraph/src/handlers/delegation.ts b/packages/subgraph/src/handlers/delegation.ts new file mode 100644 index 0000000..5505d7f --- /dev/null +++ b/packages/subgraph/src/handlers/delegation.ts @@ -0,0 +1,183 @@ +import { Bytes } from "@graphprotocol/graph-ts" +import { + TokensToDelegationPoolAdded, + TokensDelegated, + TokensUndelegated, + DelegatedTokensWithdrawn, + DelegationSlashed, +} from "../../generated/HorizonStaking/HorizonStaking" +import { getOrCreateGraphNetwork, saveGraphNetwork } from "../entities/graphNetwork" +import { getOrCreateServiceProvider, saveServiceProvider } from "../entities/serviceProvider" +import { getOrCreateDelegationPool, saveDelegationPool } from "../entities/delegationPool" + +/** + * Handles TokensDelegated event. + * Emitted when a delegator delegates tokens to a service provider. + */ +export function handleTokensDelegated(event: TokensDelegated): void { + let serviceProviderAddress = event.params.serviceProvider + let verifier = event.params.verifier + let tokens = event.params.tokens + let shares = event.params.shares + + let serviceProviderBytes = Bytes.fromHexString(serviceProviderAddress.toHexString()) as Bytes + let verifierBytes = Bytes.fromHexString(verifier.toHexString()) as Bytes + + // Update DelegationPool + let pool = getOrCreateDelegationPool( + serviceProviderBytes, + verifierBytes, + event.block.number, + event.block.timestamp + ) + pool.entity.tokens = pool.entity.tokens.plus(tokens) + pool.entity.shares = pool.entity.shares.plus(shares) + saveDelegationPool(pool.entity, event.block) + + // Update ServiceProvider + let serviceProvider = getOrCreateServiceProvider(serviceProviderBytes, event.block.number, event.block.timestamp) + serviceProvider.entity.tokensDelegated = serviceProvider.entity.tokensDelegated.plus(tokens) + saveServiceProvider(serviceProvider.entity, event.block) + + // Update GraphNetwork + let graphNetwork = getOrCreateGraphNetwork() + graphNetwork.tokensDelegated = graphNetwork.tokensDelegated.plus(tokens) + if (pool.isNew) { + graphNetwork.countDelegationPools += 1 + } + saveGraphNetwork(graphNetwork) +} + +/** + * Handles TokensUndelegated event. + * Emitted when a delegator initiates undelegation (starts thawing). + */ +export function handleTokensUndelegated(event: TokensUndelegated): void { + let serviceProviderAddress = event.params.serviceProvider + let verifier = event.params.verifier + let tokens = event.params.tokens + let shares = event.params.shares + + let serviceProviderBytes = Bytes.fromHexString(serviceProviderAddress.toHexString()) as Bytes + 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 + ) + pool.entity.shares = pool.entity.shares.minus(shares) + pool.entity.tokensThawing = pool.entity.tokensThawing.plus(tokens) + saveDelegationPool(pool.entity, event.block) +} + +/** + * Handles DelegatedTokensWithdrawn event. + * Emitted when thawed tokens are withdrawn by the delegator. + */ +export function handleDelegatedTokensWithdrawn(event: DelegatedTokensWithdrawn): void { + let serviceProviderAddress = event.params.serviceProvider + let verifier = event.params.verifier + let tokens = event.params.tokens + + let serviceProviderBytes = Bytes.fromHexString(serviceProviderAddress.toHexString()) as Bytes + let verifierBytes = Bytes.fromHexString(verifier.toHexString()) as Bytes + + // Update DelegationPool - tokens leave the pool on withdrawal + let pool = getOrCreateDelegationPool( + serviceProviderBytes, + verifierBytes, + event.block.number, + event.block.timestamp + ) + 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.") + pool.entity.tokensThawing = pool.entity.tokensThawing.minus(tokens) + saveDelegationPool(pool.entity, event.block) + + // Update ServiceProvider + let serviceProvider = getOrCreateServiceProvider(serviceProviderBytes, event.block.number, event.block.timestamp) + 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.tokensDelegated >= tokens, "Withdraw tokens exceed network tokens delegated.") + graphNetwork.tokensDelegated = graphNetwork.tokensDelegated.minus(tokens) + saveGraphNetwork(graphNetwork) +} + +/** + * Handles DelegationSlashed event. + * Emitted when delegated tokens are slashed from a pool. + */ +export function handleDelegationSlashed(event: DelegationSlashed): void { + let serviceProviderAddress = event.params.serviceProvider + let verifier = event.params.verifier + let tokens = event.params.tokens + + let serviceProviderBytes = Bytes.fromHexString(serviceProviderAddress.toHexString()) as Bytes + let verifierBytes = Bytes.fromHexString(verifier.toHexString()) as Bytes + + // Update DelegationPool - reduce tokens (slashing affects the pool ratio) + let pool = getOrCreateDelegationPool( + serviceProviderBytes, + verifierBytes, + event.block.number, + event.block.timestamp + ) + 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.entity.tokensDelegated >= tokens, "Slash 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.tokensDelegated >= tokens, "Slash tokens exceed network tokens delegated.") + graphNetwork.tokensDelegated = graphNetwork.tokensDelegated.minus(tokens) + saveGraphNetwork(graphNetwork) +} + +/** + * Handles TokensToDelegationPoolAdded event. + * Emitted when tokens are added directly to a delegation pool (e.g., payments, rewards). + */ +export function handleTokensToDelegationPoolAdded(event: TokensToDelegationPoolAdded): void { + let serviceProviderAddress = event.params.serviceProvider + let verifier = event.params.verifier + let tokens = event.params.tokens + + let serviceProviderBytes = Bytes.fromHexString(serviceProviderAddress.toHexString()) as Bytes + let verifierBytes = Bytes.fromHexString(verifier.toHexString()) as Bytes + + // Update DelegationPool + let pool = getOrCreateDelegationPool( + serviceProviderBytes, + verifierBytes, + event.block.number, + event.block.timestamp + ) + 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) + serviceProvider.entity.tokensDelegated = serviceProvider.entity.tokensDelegated.plus(tokens) + saveServiceProvider(serviceProvider.entity, event.block) + + // Update GraphNetwork + let graphNetwork = getOrCreateGraphNetwork() + graphNetwork.tokensDelegated = graphNetwork.tokensDelegated.plus(tokens) + saveGraphNetwork(graphNetwork) +} \ No newline at end of file diff --git a/packages/subgraph/src/handlers/migration.ts b/packages/subgraph/src/handlers/migration.ts index cb01432..7b13c06 100644 --- a/packages/subgraph/src/handlers/migration.ts +++ b/packages/subgraph/src/handlers/migration.ts @@ -1,9 +1,19 @@ -import { ethereum, Address, log } from "@graphprotocol/graph-ts" +import { ethereum, Address, log, Bytes } from "@graphprotocol/graph-ts" import { HorizonStaking } from "../../generated/HorizonStaking/HorizonStaking" -import { getOrCreateGraphNetwork } from "../entities/graphNetwork" -import { getOrCreateServiceProvider } from "../entities/serviceProvider" +import { getOrCreateGraphNetwork, saveGraphNetwork } from "../entities/graphNetwork" +import { getOrCreateServiceProvider, saveServiceProvider } from "../entities/serviceProvider" +import { getOrCreateDelegationPool, saveDelegationPool } from "../entities/delegationPool" import { config } from "../config" import { NetworkConfig } from "../config/types" +import { + encodeGetStake, + encodeGetDelegationPool, + decodeGetStakeResult, + decodeGetDelegationPoolResult, +} from "../common/multicall" + +// Batch size for multicall - balance between efficiency and gas limits +const MULTICALL_BATCH_SIZE = 100 /** * Runs once at startBlock via block handler with `filter: kind: once`. @@ -14,53 +24,152 @@ import { NetworkConfig } from "../config/types" * indexing reward and query fee stake gains, slashings, all get combined into * one stake amount which means detailed history pre-Horizon is effectively lost. * - * Address list is generated by querying the legacy network subgraph at genesis - * block (see scripts/fetch-indexers.sh). + * Also seeds DelegationPool, Delegator, and Delegation entities for legacy delegations + * that were auto-migrated to the Subgraph Service without emitting events. + * + * Address lists are generated by querying the legacy network subgraph at genesis + * block (see packages/tools). */ export function handleHorizonGenesisBlock(block: ethereum.Block): void { migrateServiceProviders(block, config) + migrateDelegationPools(block, config) } /** * Core migration logic extracted for testability. - * Seeds ServiceProvider entities by reading stake from the contract. + * Seeds ServiceProvider entities by reading stake from the contract using multicall. */ export function migrateServiceProviders(block: ethereum.Block, networkConfig: NetworkConfig): void { let graphNetwork = getOrCreateGraphNetwork() // Skip if no service providers to migrate if (networkConfig.serviceProviderAddresses.length == 0) { - graphNetwork.save() + saveGraphNetwork(graphNetwork) + return + } + + let stakingContract = HorizonStaking.bind(networkConfig.horizonStakingAddress) + let addresses = networkConfig.serviceProviderAddresses + + // Process in batches using multicall + for (let batchStart = 0; batchStart < addresses.length; batchStart += MULTICALL_BATCH_SIZE) { + let batchEnd = batchStart + MULTICALL_BATCH_SIZE + if (batchEnd > addresses.length) { + batchEnd = addresses.length + } + + // Encode all getStake calls for this batch + let calls: Bytes[] = [] + for (let i = batchStart; i < batchEnd; i++) { + let address = Address.fromString(addresses[i]) + calls.push(encodeGetStake(address)) + } + + // Execute multicall + let multicallResult = stakingContract.try_multicall(calls) + if (multicallResult.reverted) { + log.critical("Multicall failed for service provider batch starting at {}", [batchStart.toString()]) + continue + } + + let results = multicallResult.value + + // Process results + for (let i = 0; i < results.length; i++) { + let address = Address.fromString(addresses[batchStart + i]) + let tokensStaked = decodeGetStakeResult(results[i]) + + // Create service provider + let sp = getOrCreateServiceProvider(address, block.number, block.timestamp) + assert(sp.isNew, "Service provider already exists.") + sp.entity.tokensStaked = tokensStaked + sp.entity.tokensIdle = tokensStaked // No provisions at migration, so all stake is idle + saveServiceProvider(sp.entity, block) + + // Update graph network totals + graphNetwork.tokensStaked = graphNetwork.tokensStaked.plus(tokensStaked) + graphNetwork.countServiceProviders += 1 + } + } + + saveGraphNetwork(graphNetwork) +} + +/** + * Seeds DelegationPool entities using multicall. + * + * Legacy delegations were auto-migrated to Subgraph Service without events. + * This function reads pool state from the contract to seed DelegationPools + * and update ServiceProvider.tokensDelegated. + * + * Individual Delegator and Delegation entities are NOT seeded here - they are + * lazy-initialized when delegators perform any action (delegate, undelegate, withdraw). + */ +export function migrateDelegationPools(block: ethereum.Block, networkConfig: NetworkConfig): void { + // Skip if no delegation pools to migrate + if (networkConfig.delegatedIndexerAddresses.length == 0) { return } let stakingContract = HorizonStaking.bind(networkConfig.horizonStakingAddress) + let verifier = networkConfig.subgraphServiceAddress + let graphNetwork = getOrCreateGraphNetwork() + + let indexerAddresses = networkConfig.delegatedIndexerAddresses - for (let i = 0; i < networkConfig.serviceProviderAddresses.length; i++) { - let address = Address.fromString(networkConfig.serviceProviderAddresses[i]) + // Seed DelegationPools using multicall + for (let batchStart = 0; batchStart < indexerAddresses.length; batchStart += MULTICALL_BATCH_SIZE) { + let batchEnd = batchStart + MULTICALL_BATCH_SIZE + if (batchEnd > indexerAddresses.length) { + batchEnd = indexerAddresses.length + } + + // Encode all getDelegationPool calls for this batch + let calls: Bytes[] = [] + for (let i = batchStart; i < batchEnd; i++) { + let indexerAddress = Address.fromString(indexerAddresses[i]) + calls.push(encodeGetDelegationPool(indexerAddress, verifier)) + } - // Fetch stake from contract - let stakeResult = stakingContract.try_getStake(address) - if (stakeResult.reverted) { - log.critical("Failed to fetch stake for service provider {}", [address.toHexString()]) + // Execute multicall + let multicallResult = stakingContract.try_multicall(calls) + if (multicallResult.reverted) { + log.warning("Multicall failed for delegation pool batch starting at {}", [batchStart.toString()]) + continue } - let tokensStaked = stakeResult.value - - // Create service provider - let serviceProvider = getOrCreateServiceProvider(address, block.number, block.timestamp) - assert(serviceProvider.isNew, "Service provider already exists") - serviceProvider.entity.tokensStaked = tokensStaked - // TODO: Set tokensIdle and tokensProvisioned when provision handlers are implemented - // let idleResult = stakingContract.try_getIdleStake(address) - // serviceProvider.entity.tokensIdle = idleResult.value - // serviceProvider.entity.tokensProvisioned = tokensStaked.minus(idleResult.value) - serviceProvider.entity.save() - - // Update graph network totals - graphNetwork.tokensStaked = graphNetwork.tokensStaked.plus(tokensStaked) - graphNetwork.countServiceProviders += 1 + let results = multicallResult.value + + // Process results + for (let i = 0; i < results.length; i++) { + let indexerAddress = Address.fromString(indexerAddresses[batchStart + i]) + let poolData = decodeGetDelegationPoolResult(results[i]) + let poolTokens = poolData[0] + + let indexerBytes = Bytes.fromHexString(indexerAddress.toHexString()) as Bytes + let verifierBytes = Bytes.fromHexString(verifier.toHexString()) as Bytes + + // Create delegation pool + let pool = getOrCreateDelegationPool(indexerBytes, verifierBytes, block.number, block.timestamp) + assert(pool.isNew, "Delegation pool already exists.") + pool.entity.tokens = poolData[0] + pool.entity.shares = poolData[1] + pool.entity.tokensThawing = poolData[2] + saveDelegationPool(pool.entity, block) + + // Update service provider tokensDelegated - Note that this service provider might not exist + let serviceProvider = getOrCreateServiceProvider(indexerBytes, block.number, block.timestamp) + 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) + } } - graphNetwork.save() + saveGraphNetwork(graphNetwork) } diff --git a/packages/subgraph/src/handlers/provision.ts b/packages/subgraph/src/handlers/provision.ts new file mode 100644 index 0000000..8397eb9 --- /dev/null +++ b/packages/subgraph/src/handlers/provision.ts @@ -0,0 +1,222 @@ +import { + ProvisionCreated, + ProvisionIncreased, + ProvisionThawed, + ProvisionSlashed, + ProvisionParametersStaged, + ProvisionParametersSet, + TokensDeprovisioned +} from "../../generated/HorizonStaking/HorizonStaking" +import { getOrCreateGraphNetwork, saveGraphNetwork } from "../entities/graphNetwork" +import { getOrCreateServiceProvider, saveServiceProvider } from "../entities/serviceProvider" +import { getOrCreateProvision, saveProvision } from "../entities/provision" + +/** + * Emitted when a service provider creates a new provision to a verifier. + */ +export function handleProvisionCreated(event: ProvisionCreated): 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, + event.block.number, + event.block.timestamp + ) + + // Provision + assert(provision.isNew, "Provision already exists.") + provision.entity.tokens = event.params.tokens + provision.entity.maxVerifierCut = event.params.maxVerifierCut + provision.entity.thawingPeriod = event.params.thawingPeriod + provision.entity.maxVerifierCutPending = event.params.maxVerifierCut + provision.entity.thawingPeriodPending = event.params.thawingPeriod + saveProvision(provision.entity, event.block) + + // ServiceProvider + assert(!serviceProvider.isNew, "Service provider does not exist.") + 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) + saveServiceProvider(serviceProvider.entity, event.block) + + // GraphNetwork + graphNetwork.countProvisions += 1 + graphNetwork.tokensProvisioned = graphNetwork.tokensProvisioned.plus(event.params.tokens) + saveGraphNetwork(graphNetwork) +} + +/** + * Emitted when tokens are added to an existing provision. + */ +export function handleProvisionIncreased(event: ProvisionIncreased): 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, + event.block.number, + event.block.timestamp + ) + + // Provision + assert(!provision.isNew, "Provision does not exist.") + provision.entity.tokens = provision.entity.tokens.plus(event.params.tokens) + saveProvision(provision.entity, event.block) + + // ServiceProvider + assert(!serviceProvider.isNew, "Service provider does not exist.") + 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) + saveServiceProvider(serviceProvider.entity, event.block) + + // GraphNetwork + graphNetwork.tokensProvisioned = graphNetwork.tokensProvisioned.plus(event.params.tokens) + saveGraphNetwork(graphNetwork) +} + +/** + * Emitted when tokens begin thawing from a provision. + * Note: Thawing tokens are still considered "provisioned" . + */ +export function handleProvisionThawed(event: ProvisionThawed): void { + let provision = getOrCreateProvision( + event.params.serviceProvider, + event.params.verifier, + event.block.number, + event.block.timestamp + ) + + // 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) +} + +/** + * Emitted when thawed tokens are removed from a provision (after thawing period completes). + */ +export function handleTokensDeprovisioned(event: TokensDeprovisioned): 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, + event.block.number, + event.block.timestamp + ) + + // Provision + assert(!provision.isNew, "Provision does not exist.") + 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) + + // ServiceProvider + assert(!serviceProvider.isNew, "Service provider does not exist.") + 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.") + serviceProvider.entity.tokensIdle = serviceProvider.entity.tokensStaked.minus(serviceProvider.entity.tokensProvisioned) + saveServiceProvider(serviceProvider.entity, event.block) + + // GraphNetwork + assert(graphNetwork.tokensProvisioned >= event.params.tokens, "Deprovision exceeds network tokens provisioned.") + graphNetwork.tokensProvisioned = graphNetwork.tokensProvisioned.minus(event.params.tokens) + saveGraphNetwork(graphNetwork) +} + +/** + * Emitted when a provision is slashed by the verifier. + */ +export function handleProvisionSlashed(event: ProvisionSlashed): 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, + event.block.number, + event.block.timestamp + ) + + // Provision + assert(!provision.isNew, "Provision does not exist.") + assert(provision.entity.tokens >= event.params.tokens, "Slash exceeds provision tokens") + provision.entity.tokens = provision.entity.tokens.minus(event.params.tokens) + saveProvision(provision.entity, event.block) + + // ServiceProvider + assert(!serviceProvider.isNew, "Service provider does not exist.") + assert(serviceProvider.entity.tokensStaked >= event.params.tokens, "Slash exceeds service provider tokens staked.") + serviceProvider.entity.tokensStaked = serviceProvider.entity.tokensStaked.minus(event.params.tokens) + assert(serviceProvider.entity.tokensProvisioned >= event.params.tokens, "Slash 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.") + serviceProvider.entity.tokensIdle = serviceProvider.entity.tokensStaked.minus(serviceProvider.entity.tokensProvisioned) + saveServiceProvider(serviceProvider.entity, event.block) + + // GraphNetwork + assert(graphNetwork.tokensStaked >= event.params.tokens, "Slash exceeds network tokens staked.") + 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) + saveGraphNetwork(graphNetwork) +} + +/** + * Emitted when new provision parameters are staged (pending acceptance). + */ +export function handleProvisionParametersStaged(event: ProvisionParametersStaged): void { + let provision = getOrCreateProvision( + event.params.serviceProvider, + event.params.verifier, + event.block.number, + event.block.timestamp + ) + + // Provision + assert(!provision.isNew, "Provision does not exist.") + provision.entity.maxVerifierCutPending = event.params.maxVerifierCut + provision.entity.thawingPeriodPending = event.params.thawingPeriod + provision.entity.lastParametersStagedAt = event.block.timestamp + saveProvision(provision.entity, event.block) +} + +/** + * Emitted when staged provision parameters are accepted. + */ +export function handleProvisionParametersSet(event: ProvisionParametersSet): void { + let provision = getOrCreateProvision( + event.params.serviceProvider, + event.params.verifier, + event.block.number, + event.block.timestamp + ) + + // Provision + assert(!provision.isNew, "Provision does not exist.") + provision.entity.maxVerifierCut = event.params.maxVerifierCut + provision.entity.thawingPeriod = event.params.thawingPeriod + provision.entity.maxVerifierCutPending = event.params.maxVerifierCut + provision.entity.thawingPeriodPending = event.params.thawingPeriod + saveProvision(provision.entity, event.block) +} diff --git a/packages/subgraph/src/handlers/staking.ts b/packages/subgraph/src/handlers/staking.ts index 6ab55bb..d165fa4 100644 --- a/packages/subgraph/src/handlers/staking.ts +++ b/packages/subgraph/src/handlers/staking.ts @@ -2,16 +2,9 @@ import { HorizonStakeDeposited, HorizonStakeWithdrawn } from "../../generated/HorizonStaking/HorizonStaking" -import { - getOrCreateGraphNetwork, - updateGraphNetworkOnStakeDeposit, - updateGraphNetworkOnStakeWithdraw -} from "../entities/graphNetwork" -import { - getOrCreateServiceProvider, - updateServiceProviderOnStakeDeposit, - updateServiceProviderOnStakeWithdraw -} from "../entities/serviceProvider" +import { BIGINT_ZERO } from "../common/constants" +import { getOrCreateGraphNetwork, saveGraphNetwork } from "../entities/graphNetwork" +import { getOrCreateServiceProvider, saveServiceProvider } from "../entities/serviceProvider" /** * Emitted by: @@ -29,18 +22,18 @@ export function handleHorizonStakeDeposited(event: HorizonStakeDeposited): void event.block.timestamp ) - // Service provider - updateServiceProviderOnStakeDeposit( - serviceProvider.entity, - event.params.tokens, - event.block.number, - event.block.timestamp - ) - serviceProvider.entity.save() + // ServiceProvider + serviceProvider.entity.tokensStaked = serviceProvider.entity.tokensStaked.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) + saveServiceProvider(serviceProvider.entity, event.block) - // Graph network - updateGraphNetworkOnStakeDeposit(graphNetwork, event.params.tokens, serviceProvider.isNew) - graphNetwork.save() + // GraphNetwork + graphNetwork.tokensStaked = graphNetwork.tokensStaked.plus(event.params.tokens) + if (serviceProvider.isNew) { + graphNetwork.countServiceProviders += 1 + } + saveGraphNetwork(graphNetwork) } /** @@ -56,16 +49,18 @@ export function handleHorizonStakeWithdrawn(event: HorizonStakeWithdrawn): void event.block.timestamp ) - // Service provider - updateServiceProviderOnStakeWithdraw( - serviceProvider.entity, - event.params.tokens, - event.block.number, - event.block.timestamp - ) - serviceProvider.entity.save() + // ServiceProvider + 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.") + serviceProvider.entity.tokensIdle = serviceProvider.entity.tokensStaked.minus(serviceProvider.entity.tokensProvisioned) + saveServiceProvider(serviceProvider.entity, event.block) - // Graph network - updateGraphNetworkOnStakeWithdraw(graphNetwork, event.params.tokens) - graphNetwork.save() + // 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)) { + graphNetwork.countServiceProviders -= 1 + } + saveGraphNetwork(graphNetwork) } diff --git a/packages/subgraph/src/mapping.ts b/packages/subgraph/src/mapping.ts index 19986cf..d0c6a9c 100644 --- a/packages/subgraph/src/mapping.ts +++ b/packages/subgraph/src/mapping.ts @@ -1,3 +1,19 @@ // Re-export all handlers export { handleHorizonStakeDeposited, handleHorizonStakeWithdrawn } from "./handlers/staking" export { handleHorizonGenesisBlock } from "./handlers/migration" +export { + handleProvisionCreated, + handleProvisionIncreased, + handleProvisionThawed, + handleProvisionSlashed, + handleProvisionParametersStaged, + handleProvisionParametersSet, + handleTokensDeprovisioned +} from "./handlers/provision" +export { + handleTokensToDelegationPoolAdded, + handleTokensDelegated, + handleTokensUndelegated, + handleDelegatedTokensWithdrawn, + handleDelegationSlashed +} from "./handlers/delegation" diff --git a/packages/subgraph/subgraph.yaml b/packages/subgraph/subgraph.yaml index 74aa7ac..990e81a 100644 --- a/packages/subgraph/subgraph.yaml +++ b/packages/subgraph/subgraph.yaml @@ -18,6 +18,10 @@ dataSources: entities: - GraphNetwork - ServiceProvider + - Provision + - DelegationPool + - Delegator + - Delegation abis: - name: HorizonStaking file: ./abis/HorizonStaking.json @@ -30,4 +34,29 @@ dataSources: handler: handleHorizonStakeDeposited - event: HorizonStakeWithdrawn(indexed address,uint256) handler: handleHorizonStakeWithdrawn + - event: ProvisionCreated(indexed address,indexed address,uint256,uint32,uint64) + handler: handleProvisionCreated + - event: ProvisionIncreased(indexed address,indexed address,uint256) + handler: handleProvisionIncreased + - event: ProvisionThawed(indexed address,indexed address,uint256) + handler: handleProvisionThawed + - event: ProvisionSlashed(indexed address,indexed address,uint256) + handler: handleProvisionSlashed + - event: ProvisionParametersStaged(indexed address,indexed address,uint32,uint64) + handler: handleProvisionParametersStaged + - event: ProvisionParametersSet(indexed address,indexed address,uint32,uint64) + handler: handleProvisionParametersSet + - event: TokensDeprovisioned(indexed address,indexed address,uint256) + handler: handleTokensDeprovisioned + # Delegation events + - event: TokensToDelegationPoolAdded(indexed address,indexed address,uint256) + handler: handleTokensToDelegationPoolAdded + - event: TokensDelegated(indexed address,indexed address,indexed address,uint256,uint256) + handler: handleTokensDelegated + - event: TokensUndelegated(indexed address,indexed address,indexed address,uint256,uint256) + handler: handleTokensUndelegated + - event: DelegatedTokensWithdrawn(indexed address,indexed address,indexed address,uint256) + handler: handleDelegatedTokensWithdrawn + - event: DelegationSlashed(indexed address,indexed address,uint256) + handler: handleDelegationSlashed file: ./src/mapping.ts diff --git a/packages/subgraph/tests/delegation.test.ts b/packages/subgraph/tests/delegation.test.ts new file mode 100644 index 0000000..1f7fa7a --- /dev/null +++ b/packages/subgraph/tests/delegation.test.ts @@ -0,0 +1,433 @@ +import { + describe, + test, + beforeEach, + clearStore, + assert, + newTypedMockEvent, +} from "matchstick-as" +import { Address, BigInt, Bytes, ethereum } from "@graphprotocol/graph-ts" +import { + TokensToDelegationPoolAdded, + TokensDelegated, + TokensUndelegated, + DelegatedTokensWithdrawn, + DelegationSlashed, + HorizonStakeDeposited +} from "../generated/HorizonStaking/HorizonStaking" +import { + handleTokensToDelegationPoolAdded, + handleTokensDelegated, + handleTokensUndelegated, + handleDelegatedTokensWithdrawn, + handleDelegationSlashed +} from "../src/handlers/delegation" +import { handleHorizonStakeDeposited } from "../src/handlers/staking" +import { GRAPH_NETWORK_ID } from "../src/common/constants" +import { getDelegationPoolId } from "../src/entities/delegationPool" + +// Test addresses +const SP_ADDRESS = Address.fromString("0x1234567890123456789012345678901234567890") +const VERIFIER_ADDRESS = Address.fromString("0xabcdefabcdefabcdefabcdefabcdefabcdefabcd") +const DELEGATOR_ADDRESS = Address.fromString("0x9999999999999999999999999999999999999999") +const DELEGATOR_ADDRESS_2 = Address.fromString("0x8888888888888888888888888888888888888888") + +// Helper to create stake deposit (to set up ServiceProvider) +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 TokensDelegated event +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(200) + event.block.timestamp = BigInt.fromI32(2000) + return event +} + +// Helper to create TokensUndelegated event +function createTokensUndelegatedEvent( + serviceProvider: Address, + verifier: Address, + delegator: Address, + tokens: BigInt, + shares: BigInt +): TokensUndelegated { + 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(300) + event.block.timestamp = BigInt.fromI32(3000) + return event +} + +// Helper to create DelegatedTokensWithdrawn event +function createDelegatedTokensWithdrawnEvent( + serviceProvider: Address, + verifier: Address, + delegator: Address, + tokens: BigInt +): DelegatedTokensWithdrawn { + 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.block.number = BigInt.fromI32(400) + event.block.timestamp = BigInt.fromI32(4000) + return event +} + +// Helper to create DelegationSlashed event +function createDelegationSlashedEvent( + serviceProvider: Address, + verifier: Address, + tokens: BigInt +): DelegationSlashed { + 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.block.number = BigInt.fromI32(500) + event.block.timestamp = BigInt.fromI32(5000) + return event +} + +// Helper to create TokensToDelegationPoolAdded event +function createTokensToDelegationPoolAddedEvent( + serviceProvider: Address, + verifier: Address, + tokens: BigInt +): TokensToDelegationPoolAdded { + 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.block.number = BigInt.fromI32(600) + event.block.timestamp = BigInt.fromI32(6000) + return event +} + +function getDelegationPoolIdString(sp: Address, verifier: Address): string { + return getDelegationPoolId(Bytes.fromHexString(sp.toHexString()), Bytes.fromHexString(verifier.toHexString())).toHexString() +} + +describe("TokensToDelegationPoolAdded", () => { + beforeEach(() => { + clearStore() + }) + + test("adds tokens to pool without minting shares", () => { + // Setup: deposit stake and delegate + let stakeTokens = BigInt.fromString("10000000000000000000000") + let depositEvent = createStakeDepositedEvent(SP_ADDRESS, stakeTokens) + handleHorizonStakeDeposited(depositEvent) + + let delegatedTokens = BigInt.fromString("1000000000000000000000") // 1000 GRT + let shares = BigInt.fromString("1000000000000000000000") + let delegateEvent = createTokensDelegatedEvent(SP_ADDRESS, VERIFIER_ADDRESS, DELEGATOR_ADDRESS, delegatedTokens, shares) + handleTokensDelegated(delegateEvent) + + let poolId = getDelegationPoolIdString(SP_ADDRESS, VERIFIER_ADDRESS) + + // Add tokens to pool (e.g., rewards) + let addedTokens = BigInt.fromString("100000000000000000000") // 100 GRT + let event = createTokensToDelegationPoolAddedEvent(SP_ADDRESS, VERIFIER_ADDRESS, addedTokens) + handleTokensToDelegationPoolAdded(event) + + let totalTokens = delegatedTokens.plus(addedTokens) + + // Pool: tokens increased, shares unchanged + assert.fieldEquals("DelegationPool", poolId, "tokens", totalTokens.toString()) + assert.fieldEquals("DelegationPool", poolId, "shares", shares.toString()) + + // ServiceProvider: tokensDelegated increased + assert.fieldEquals("ServiceProvider", SP_ADDRESS.toHexString(), "tokensDelegated", totalTokens.toString()) + + // GraphNetwork: tokensDelegated increased + assert.fieldEquals("GraphNetwork", GRAPH_NETWORK_ID.toHexString(), "tokensDelegated", totalTokens.toString()) + }) +}) + +describe("TokensDelegated", () => { + beforeEach(() => { + clearStore() + }) + + test("creates new DelegationPool entity", () => { + // First deposit stake to create ServiceProvider + let stakeTokens = BigInt.fromString("10000000000000000000000") // 10000 GRT + let depositEvent = createStakeDepositedEvent(SP_ADDRESS, stakeTokens) + handleHorizonStakeDeposited(depositEvent) + + // Delegate tokens + let delegatedTokens = BigInt.fromString("1000000000000000000000") // 1000 GRT + let shares = BigInt.fromString("1000000000000000000000") // 1000 shares (1:1 initially) + let event = createTokensDelegatedEvent(SP_ADDRESS, VERIFIER_ADDRESS, DELEGATOR_ADDRESS, delegatedTokens, shares) + handleTokensDelegated(event) + + let poolId = getDelegationPoolIdString(SP_ADDRESS, VERIFIER_ADDRESS) + + // Check DelegationPool was created + assert.entityCount("DelegationPool", 1) + assert.fieldEquals("DelegationPool", poolId, "tokens", delegatedTokens.toString()) + assert.fieldEquals("DelegationPool", poolId, "shares", shares.toString()) + assert.fieldEquals("DelegationPool", poolId, "tokensThawing", "0") + assert.fieldEquals("DelegationPool", poolId, "createdAtBlock", "200") + assert.fieldEquals("DelegationPool", poolId, "createdAt", "2000") + + // Check ServiceProvider was updated + assert.fieldEquals("ServiceProvider", SP_ADDRESS.toHexString(), "tokensDelegated", delegatedTokens.toString()) + + // Check GraphNetwork was updated + assert.fieldEquals("GraphNetwork", GRAPH_NETWORK_ID.toHexString(), "countDelegationPools", "1") + assert.fieldEquals("GraphNetwork", GRAPH_NETWORK_ID.toHexString(), "tokensDelegated", delegatedTokens.toString()) + }) + + test("adds to existing DelegationPool on subsequent delegations", () => { + // Setup + let stakeTokens = BigInt.fromString("10000000000000000000000") + let depositEvent = createStakeDepositedEvent(SP_ADDRESS, stakeTokens) + handleHorizonStakeDeposited(depositEvent) + + // First delegation + let tokens1 = BigInt.fromString("1000000000000000000000") // 1000 GRT + let shares1 = BigInt.fromString("1000000000000000000000") + let event1 = createTokensDelegatedEvent(SP_ADDRESS, VERIFIER_ADDRESS, DELEGATOR_ADDRESS, tokens1, shares1) + handleTokensDelegated(event1) + + // Second delegation from different delegator + let tokens2 = BigInt.fromString("500000000000000000000") // 500 GRT + let shares2 = BigInt.fromString("500000000000000000000") + let event2 = createTokensDelegatedEvent(SP_ADDRESS, VERIFIER_ADDRESS, DELEGATOR_ADDRESS_2, tokens2, shares2) + event2.block.number = BigInt.fromI32(250) + event2.block.timestamp = BigInt.fromI32(2500) + handleTokensDelegated(event2) + + let poolId = getDelegationPoolIdString(SP_ADDRESS, VERIFIER_ADDRESS) + let totalTokens = tokens1.plus(tokens2) + let totalShares = shares1.plus(shares2) + + // Still only 1 pool (same SP+verifier) + assert.entityCount("DelegationPool", 1) + assert.fieldEquals("DelegationPool", poolId, "tokens", totalTokens.toString()) + assert.fieldEquals("DelegationPool", poolId, "shares", totalShares.toString()) + + // countDelegationPools should still be 1 + assert.fieldEquals("GraphNetwork", GRAPH_NETWORK_ID.toHexString(), "countDelegationPools", "1") + assert.fieldEquals("GraphNetwork", GRAPH_NETWORK_ID.toHexString(), "tokensDelegated", totalTokens.toString()) + assert.fieldEquals("ServiceProvider", SP_ADDRESS.toHexString(), "tokensDelegated", totalTokens.toString()) + }) +}) + +describe("TokensUndelegated", () => { + beforeEach(() => { + clearStore() + }) + + test("burns shares and starts thawing tokens", () => { + // Setup: deposit, delegate + let stakeTokens = BigInt.fromString("10000000000000000000000") + let depositEvent = createStakeDepositedEvent(SP_ADDRESS, stakeTokens) + handleHorizonStakeDeposited(depositEvent) + + let delegatedTokens = BigInt.fromString("1000000000000000000000") + let shares = BigInt.fromString("1000000000000000000000") + let delegateEvent = createTokensDelegatedEvent(SP_ADDRESS, VERIFIER_ADDRESS, DELEGATOR_ADDRESS, delegatedTokens, shares) + handleTokensDelegated(delegateEvent) + + // Undelegate half + let undelegateTokens = BigInt.fromString("500000000000000000000") + let undelegateShares = BigInt.fromString("500000000000000000000") + let event = createTokensUndelegatedEvent(SP_ADDRESS, VERIFIER_ADDRESS, DELEGATOR_ADDRESS, undelegateTokens, undelegateShares) + handleTokensUndelegated(event) + + let poolId = getDelegationPoolIdString(SP_ADDRESS, VERIFIER_ADDRESS) + let remainingShares = shares.minus(undelegateShares) + + // Pool: shares are burned, tokens stay in pool but start thawing + // tokens stays unchanged until withdrawal + assert.fieldEquals("DelegationPool", poolId, "tokens", delegatedTokens.toString()) + assert.fieldEquals("DelegationPool", poolId, "shares", remainingShares.toString()) + assert.fieldEquals("DelegationPool", poolId, "tokensThawing", undelegateTokens.toString()) + + // ServiceProvider: tokensDelegated unchanged (thawing tokens still count as delegated) + assert.fieldEquals("ServiceProvider", SP_ADDRESS.toHexString(), "tokensDelegated", delegatedTokens.toString()) + }) +}) + +describe("DelegatedTokensWithdrawn", () => { + beforeEach(() => { + clearStore() + }) + + test("removes tokens from pool and updates aggregates", () => { + // Setup: deposit, delegate, undelegate + let stakeTokens = BigInt.fromString("10000000000000000000000") + let depositEvent = createStakeDepositedEvent(SP_ADDRESS, stakeTokens) + handleHorizonStakeDeposited(depositEvent) + + let delegatedTokens = BigInt.fromString("1000000000000000000000") + let shares = BigInt.fromString("1000000000000000000000") + let delegateEvent = createTokensDelegatedEvent(SP_ADDRESS, VERIFIER_ADDRESS, DELEGATOR_ADDRESS, delegatedTokens, shares) + handleTokensDelegated(delegateEvent) + + let undelegateTokens = BigInt.fromString("500000000000000000000") + let undelegateShares = BigInt.fromString("500000000000000000000") + let undelegateEvent = createTokensUndelegatedEvent(SP_ADDRESS, VERIFIER_ADDRESS, DELEGATOR_ADDRESS, undelegateTokens, undelegateShares) + handleTokensUndelegated(undelegateEvent) + + // At this point: pool.tokens = 1000, tokensThawing = 500 (tokens haven't left yet) + let poolId = getDelegationPoolIdString(SP_ADDRESS, VERIFIER_ADDRESS) + assert.fieldEquals("DelegationPool", poolId, "tokens", delegatedTokens.toString()) + assert.fieldEquals("DelegationPool", poolId, "tokensThawing", undelegateTokens.toString()) + + // Withdraw after thawing period - tokens now leave the pool + let event = createDelegatedTokensWithdrawnEvent(SP_ADDRESS, VERIFIER_ADDRESS, DELEGATOR_ADDRESS, undelegateTokens) + handleDelegatedTokensWithdrawn(event) + + let remainingTokens = delegatedTokens.minus(undelegateTokens) + + // Pool: tokens now decremented, tokensThawing cleared + assert.fieldEquals("DelegationPool", poolId, "tokens", remainingTokens.toString()) + assert.fieldEquals("DelegationPool", poolId, "tokensThawing", "0") + + // ServiceProvider: tokensDelegated now decremented + assert.fieldEquals("ServiceProvider", SP_ADDRESS.toHexString(), "tokensDelegated", remainingTokens.toString()) + + // GraphNetwork: tokensDelegated decremented + assert.fieldEquals("GraphNetwork", GRAPH_NETWORK_ID.toHexString(), "tokensDelegated", remainingTokens.toString()) + }) +}) + +describe("DelegationSlashed", () => { + beforeEach(() => { + clearStore() + }) + + test("reduces pool tokens and aggregates", () => { + // Setup: deposit, delegate + let stakeTokens = BigInt.fromString("10000000000000000000000") + let depositEvent = createStakeDepositedEvent(SP_ADDRESS, stakeTokens) + handleHorizonStakeDeposited(depositEvent) + + let delegatedTokens = BigInt.fromString("1000000000000000000000") + let shares = BigInt.fromString("1000000000000000000000") + let delegateEvent = createTokensDelegatedEvent(SP_ADDRESS, VERIFIER_ADDRESS, DELEGATOR_ADDRESS, delegatedTokens, shares) + handleTokensDelegated(delegateEvent) + + // Slash delegation + let slashAmount = BigInt.fromString("200000000000000000000") // 200 GRT + let event = createDelegationSlashedEvent(SP_ADDRESS, VERIFIER_ADDRESS, slashAmount) + handleDelegationSlashed(event) + + let poolId = getDelegationPoolIdString(SP_ADDRESS, VERIFIER_ADDRESS) + let remainingTokens = delegatedTokens.minus(slashAmount) + + // Pool: tokens reduced (shares unchanged - this affects the exchange rate) + assert.fieldEquals("DelegationPool", poolId, "tokens", remainingTokens.toString()) + assert.fieldEquals("DelegationPool", poolId, "shares", shares.toString()) + + // ServiceProvider: tokensDelegated reduced + assert.fieldEquals("ServiceProvider", SP_ADDRESS.toHexString(), "tokensDelegated", remainingTokens.toString()) + + // GraphNetwork: tokensDelegated reduced + assert.fieldEquals("GraphNetwork", GRAPH_NETWORK_ID.toHexString(), "tokensDelegated", remainingTokens.toString()) + }) +}) + +describe("Delegation lifecycle", () => { + beforeEach(() => { + clearStore() + }) + + test("tracks delegation correctly through full lifecycle", () => { + // 1. Deposit stake + let stakeTokens = BigInt.fromString("10000000000000000000000") // 10000 GRT + let depositEvent = createStakeDepositedEvent(SP_ADDRESS, stakeTokens) + handleHorizonStakeDeposited(depositEvent) + + assert.fieldEquals("ServiceProvider", SP_ADDRESS.toHexString(), "tokensDelegated", "0") + + // 2. First delegation - 1000 GRT + let tokens1 = BigInt.fromString("1000000000000000000000") + let shares1 = BigInt.fromString("1000000000000000000000") + let event1 = createTokensDelegatedEvent(SP_ADDRESS, VERIFIER_ADDRESS, DELEGATOR_ADDRESS, tokens1, shares1) + handleTokensDelegated(event1) + + let poolId = getDelegationPoolIdString(SP_ADDRESS, VERIFIER_ADDRESS) + assert.fieldEquals("DelegationPool", poolId, "tokens", "1000000000000000000000") + assert.fieldEquals("ServiceProvider", SP_ADDRESS.toHexString(), "tokensDelegated", "1000000000000000000000") + assert.fieldEquals("GraphNetwork", GRAPH_NETWORK_ID.toHexString(), "countDelegationPools", "1") + + // 3. Second delegation - 500 GRT from different delegator + let tokens2 = BigInt.fromString("500000000000000000000") + let shares2 = BigInt.fromString("500000000000000000000") + let event2 = createTokensDelegatedEvent(SP_ADDRESS, VERIFIER_ADDRESS, DELEGATOR_ADDRESS_2, tokens2, shares2) + event2.block.number = BigInt.fromI32(210) + event2.block.timestamp = BigInt.fromI32(2100) + handleTokensDelegated(event2) + + assert.fieldEquals("DelegationPool", poolId, "tokens", "1500000000000000000000") + assert.fieldEquals("ServiceProvider", SP_ADDRESS.toHexString(), "tokensDelegated", "1500000000000000000000") + assert.fieldEquals("GraphNetwork", GRAPH_NETWORK_ID.toHexString(), "countDelegationPools", "1") // Still 1 pool + + // 4. Undelegate 400 GRT - shares are burned, tokens start thawing + let undelegateTokens = BigInt.fromString("400000000000000000000") + let undelegateShares = BigInt.fromString("400000000000000000000") + let undelegateEvent = createTokensUndelegatedEvent(SP_ADDRESS, VERIFIER_ADDRESS, DELEGATOR_ADDRESS, undelegateTokens, undelegateShares) + handleTokensUndelegated(undelegateEvent) + + // pool.tokens stays at 1500 (tokens haven't left yet), shares reduced to 1100 + assert.fieldEquals("DelegationPool", poolId, "tokens", "1500000000000000000000") + assert.fieldEquals("DelegationPool", poolId, "shares", "1100000000000000000000") + assert.fieldEquals("DelegationPool", poolId, "tokensThawing", "400000000000000000000") + // ServiceProvider still shows full 1500 (thawing still counts) + assert.fieldEquals("ServiceProvider", SP_ADDRESS.toHexString(), "tokensDelegated", "1500000000000000000000") + + // 5. Withdraw after thawing - tokens now leave the pool + let withdrawEvent = createDelegatedTokensWithdrawnEvent(SP_ADDRESS, VERIFIER_ADDRESS, DELEGATOR_ADDRESS, undelegateTokens) + handleDelegatedTokensWithdrawn(withdrawEvent) + + // pool.tokens now reduced to 1100 + assert.fieldEquals("DelegationPool", poolId, "tokens", "1100000000000000000000") + assert.fieldEquals("DelegationPool", poolId, "tokensThawing", "0") + // Now tokensDelegated decreases + assert.fieldEquals("ServiceProvider", SP_ADDRESS.toHexString(), "tokensDelegated", "1100000000000000000000") + assert.fieldEquals("GraphNetwork", GRAPH_NETWORK_ID.toHexString(), "tokensDelegated", "1100000000000000000000") + + // 6. Slash 100 GRT + let slashAmount = BigInt.fromString("100000000000000000000") + let slashEvent = createDelegationSlashedEvent(SP_ADDRESS, VERIFIER_ADDRESS, slashAmount) + handleDelegationSlashed(slashEvent) + + assert.fieldEquals("DelegationPool", poolId, "tokens", "1000000000000000000000") + assert.fieldEquals("ServiceProvider", SP_ADDRESS.toHexString(), "tokensDelegated", "1000000000000000000000") + assert.fieldEquals("GraphNetwork", GRAPH_NETWORK_ID.toHexString(), "tokensDelegated", "1000000000000000000000") + }) +}) diff --git a/packages/subgraph/tests/migration.test.ts b/packages/subgraph/tests/migration.test.ts index 1826d32..8cb6242 100644 --- a/packages/subgraph/tests/migration.test.ts +++ b/packages/subgraph/tests/migration.test.ts @@ -11,6 +11,7 @@ import { Address, BigInt, Bytes, ethereum } from "@graphprotocol/graph-ts" import { migrateServiceProviders } from "../src/handlers/migration" import { GRAPH_NETWORK_ID } from "../src/common/constants" import { testConfig, NetworkConfig } from "../src/config" +import { encodeGetStake } from "../src/common/multicall" // Helper to create a mock block function createMockBlock(number: i32, timestamp: i32): ethereum.Block { @@ -33,11 +34,29 @@ function createMockBlock(number: i32, timestamp: i32): ethereum.Block { ) } -// Helper to mock getStake for test addresses -function mockGetStake(address: Address, stake: BigInt): void { - createMockedFunction(testConfig.horizonStakingAddress, "getStake", "getStake(address):(uint256)") - .withArgs([ethereum.Value.fromAddress(address)]) - .returns([ethereum.Value.fromUnsignedBigInt(stake)]) +// Helper to encode a uint256 result for multicall +function encodeUint256Result(value: BigInt): Bytes { + let encoded = ethereum.encode(ethereum.Value.fromUnsignedBigInt(value))! + return Bytes.fromUint8Array(encoded) +} + +// Helper to mock multicall for getStake calls +function mockMulticallGetStakes(addresses: Address[], stakes: BigInt[]): void { + // Build the expected calls array + let calls: Bytes[] = [] + for (let i = 0; i < addresses.length; i++) { + calls.push(encodeGetStake(addresses[i])) + } + + // Build the expected results array + let results: Bytes[] = [] + for (let i = 0; i < stakes.length; i++) { + results.push(encodeUint256Result(stakes[i])) + } + + createMockedFunction(testConfig.horizonStakingAddress, "multicall", "multicall(bytes[]):(bytes[])") + .withArgs([ethereum.Value.fromBytesArray(calls)]) + .returns([ethereum.Value.fromBytesArray(results)]) } describe("migrateServiceProviders", () => { @@ -57,9 +76,13 @@ describe("migrateServiceProviders", () => { let stake3 = BigInt.fromString("3000000000000000000000") // 3000 GRT let totalStake = stake1.plus(stake2).plus(stake3) - mockGetStake(Address.fromString("0x1111111111111111111111111111111111111111"), stake1) - mockGetStake(Address.fromString("0x2222222222222222222222222222222222222222"), stake2) - mockGetStake(Address.fromString("0x3333333333333333333333333333333333333333"), stake3) + let addresses = [ + Address.fromString("0x1111111111111111111111111111111111111111"), + Address.fromString("0x2222222222222222222222222222222222222222"), + Address.fromString("0x3333333333333333333333333333333333333333"), + ] + let stakes = [stake1, stake2, stake3] + mockMulticallGetStakes(addresses, stakes) // Execute let block = createMockBlock(100, 1000) @@ -82,9 +105,13 @@ describe("migrateServiceProviders", () => { }) test("handles zero stake correctly", () => { - mockGetStake(Address.fromString("0x1111111111111111111111111111111111111111"), BigInt.fromI32(0)) - mockGetStake(Address.fromString("0x2222222222222222222222222222222222222222"), BigInt.fromI32(0)) - mockGetStake(Address.fromString("0x3333333333333333333333333333333333333333"), BigInt.fromI32(0)) + let addresses = [ + Address.fromString("0x1111111111111111111111111111111111111111"), + Address.fromString("0x2222222222222222222222222222222222222222"), + Address.fromString("0x3333333333333333333333333333333333333333"), + ] + let stakes = [BigInt.fromI32(0), BigInt.fromI32(0), BigInt.fromI32(0)] + mockMulticallGetStakes(addresses, stakes) let block = createMockBlock(100, 1000) migrateServiceProviders(block, testConfig) @@ -95,9 +122,13 @@ describe("migrateServiceProviders", () => { }) test("sets correct block metadata on ServiceProviders", () => { - mockGetStake(Address.fromString("0x1111111111111111111111111111111111111111"), BigInt.fromI32(100)) - mockGetStake(Address.fromString("0x2222222222222222222222222222222222222222"), BigInt.fromI32(100)) - mockGetStake(Address.fromString("0x3333333333333333333333333333333333333333"), BigInt.fromI32(100)) + let addresses = [ + Address.fromString("0x1111111111111111111111111111111111111111"), + Address.fromString("0x2222222222222222222222222222222222222222"), + Address.fromString("0x3333333333333333333333333333333333333333"), + ] + let stakes = [BigInt.fromI32(100), BigInt.fromI32(100), BigInt.fromI32(100)] + mockMulticallGetStakes(addresses, stakes) let block = createMockBlock(408825706, 1700000000) migrateServiceProviders(block, testConfig) @@ -120,8 +151,10 @@ describe("migrateServiceProviders with empty config", () => { let emptyConfig = new NetworkConfig( "test-empty", testConfig.horizonStakingAddress, + testConfig.subgraphServiceAddress, 1, - [] // empty addresses + [], // empty service provider addresses + [] // empty delegated indexer addresses ) let block = createMockBlock(100, 1000) diff --git a/packages/subgraph/tests/provision.test.ts b/packages/subgraph/tests/provision.test.ts new file mode 100644 index 0000000..adfe8f4 --- /dev/null +++ b/packages/subgraph/tests/provision.test.ts @@ -0,0 +1,535 @@ +import { + describe, + test, + beforeEach, + clearStore, + assert, + newTypedMockEvent, +} from "matchstick-as" +import { Address, BigInt, Bytes, ethereum } from "@graphprotocol/graph-ts" +import { + ProvisionCreated, + ProvisionIncreased, + ProvisionThawed, + ProvisionSlashed, + ProvisionParametersStaged, + ProvisionParametersSet, + TokensDeprovisioned, + HorizonStakeDeposited +} from "../generated/HorizonStaking/HorizonStaking" +import { + handleProvisionCreated, + handleProvisionIncreased, + handleProvisionThawed, + handleProvisionSlashed, + handleProvisionParametersStaged, + handleProvisionParametersSet, + handleTokensDeprovisioned +} from "../src/handlers/provision" +import { handleHorizonStakeDeposited } from "../src/handlers/staking" +import { GRAPH_NETWORK_ID } from "../src/common/constants" +import { getProvisionId } from "../src/entities/provision" + +// Test addresses +const SP_ADDRESS = Address.fromString("0x1234567890123456789012345678901234567890") +const VERIFIER_ADDRESS = Address.fromString("0xabcdefabcdefabcdefabcdefabcdefabcdefabcd") +const VERIFIER_ADDRESS_2 = Address.fromString("0x9999999999999999999999999999999999999999") + +// Helper to create stake deposit (to set up ServiceProvider) +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 ProvisionCreated event +function createProvisionCreatedEvent( + serviceProvider: Address, + verifier: Address, + tokens: BigInt, + maxVerifierCut: BigInt, + thawingPeriod: 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(maxVerifierCut))) + event.parameters.push(new ethereum.EventParam("thawingPeriod", ethereum.Value.fromUnsignedBigInt(thawingPeriod))) + event.block.number = BigInt.fromI32(200) + event.block.timestamp = BigInt.fromI32(2000) + return event +} + +// Helper to create ProvisionIncreased event +function createProvisionIncreasedEvent( + serviceProvider: Address, + verifier: Address, + tokens: BigInt +): ProvisionIncreased { + 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.block.number = BigInt.fromI32(300) + event.block.timestamp = BigInt.fromI32(3000) + return event +} + +// Helper to create ProvisionThawed event +function createProvisionThawedEvent( + serviceProvider: Address, + verifier: Address, + tokens: BigInt +): ProvisionThawed { + 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.block.number = BigInt.fromI32(400) + event.block.timestamp = BigInt.fromI32(4000) + return event +} + +// Helper to create ProvisionSlashed event +function createProvisionSlashedEvent( + serviceProvider: Address, + verifier: Address, + tokens: BigInt +): ProvisionSlashed { + 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.block.number = BigInt.fromI32(500) + event.block.timestamp = BigInt.fromI32(5000) + return event +} + +// Helper to create ProvisionParametersStaged event +function createProvisionParametersStagedEvent( + serviceProvider: Address, + verifier: Address, + maxVerifierCut: BigInt, + thawingPeriod: BigInt +): ProvisionParametersStaged { + 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("maxVerifierCut", ethereum.Value.fromUnsignedBigInt(maxVerifierCut))) + event.parameters.push(new ethereum.EventParam("thawingPeriod", ethereum.Value.fromUnsignedBigInt(thawingPeriod))) + event.block.number = BigInt.fromI32(600) + event.block.timestamp = BigInt.fromI32(6000) + return event +} + +// Helper to create ProvisionParametersSet event +function createProvisionParametersSetEvent( + serviceProvider: Address, + verifier: Address, + maxVerifierCut: BigInt, + thawingPeriod: BigInt +): ProvisionParametersSet { + 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("maxVerifierCut", ethereum.Value.fromUnsignedBigInt(maxVerifierCut))) + event.parameters.push(new ethereum.EventParam("thawingPeriod", ethereum.Value.fromUnsignedBigInt(thawingPeriod))) + event.block.number = BigInt.fromI32(700) + event.block.timestamp = BigInt.fromI32(7000) + return event +} + +// Helper to create TokensDeprovisioned event +function createTokensDeprovisionedEvent( + serviceProvider: Address, + verifier: Address, + tokens: BigInt +): TokensDeprovisioned { + 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.block.number = BigInt.fromI32(800) + event.block.timestamp = BigInt.fromI32(8000) + return event +} + +function getProvisionIdString(sp: Address, verifier: Address): string { + return getProvisionId(Bytes.fromHexString(sp.toHexString()), Bytes.fromHexString(verifier.toHexString())).toHexString() +} + +describe("ProvisionCreated", () => { + beforeEach(() => { + clearStore() + }) + + test("creates new Provision entity", () => { + // First 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 maxVerifierCut = BigInt.fromI32(100000) // 10% in PPM + let thawingPeriod = BigInt.fromI32(2592000) // 30 days in seconds + + let event = createProvisionCreatedEvent(SP_ADDRESS, VERIFIER_ADDRESS, provisionTokens, maxVerifierCut, thawingPeriod) + handleProvisionCreated(event) + + let provisionId = getProvisionIdString(SP_ADDRESS, VERIFIER_ADDRESS) + + // Check Provision was created + assert.entityCount("Provision", 1) + assert.fieldEquals("Provision", provisionId, "tokens", provisionTokens.toString()) + assert.fieldEquals("Provision", provisionId, "tokensThawing", "0") + assert.fieldEquals("Provision", provisionId, "maxVerifierCut", maxVerifierCut.toString()) + assert.fieldEquals("Provision", provisionId, "thawingPeriod", thawingPeriod.toString()) + // Pending values should equal current values on creation + assert.fieldEquals("Provision", provisionId, "maxVerifierCutPending", maxVerifierCut.toString()) + assert.fieldEquals("Provision", provisionId, "thawingPeriodPending", thawingPeriod.toString()) + assert.fieldEquals("Provision", provisionId, "createdAtBlock", "200") + assert.fieldEquals("Provision", provisionId, "createdAt", "2000") + + // Check ServiceProvider was updated + assert.fieldEquals("ServiceProvider", SP_ADDRESS.toHexString(), "tokensProvisioned", provisionTokens.toString()) + let expectedIdle = stakeTokens.minus(provisionTokens) + assert.fieldEquals("ServiceProvider", SP_ADDRESS.toHexString(), "tokensIdle", expectedIdle.toString()) + + // Check GraphNetwork was updated + assert.fieldEquals("GraphNetwork", GRAPH_NETWORK_ID.toHexString(), "countProvisions", "1") + assert.fieldEquals("GraphNetwork", GRAPH_NETWORK_ID.toHexString(), "tokensProvisioned", provisionTokens.toString()) + }) + + test("handles multiple provisions to different verifiers", () => { + // Deposit stake + let stakeTokens = BigInt.fromString("10000000000000000000000") // 10000 GRT + let depositEvent = createStakeDepositedEvent(SP_ADDRESS, stakeTokens) + handleHorizonStakeDeposited(depositEvent) + + // Create first provision + let tokens1 = BigInt.fromString("3000000000000000000000") // 3000 GRT + let event1 = createProvisionCreatedEvent(SP_ADDRESS, VERIFIER_ADDRESS, tokens1, BigInt.fromI32(100000), BigInt.fromI32(2592000)) + handleProvisionCreated(event1) + + // Create second provision to different verifier + let tokens2 = BigInt.fromString("2000000000000000000000") // 2000 GRT + let event2 = createProvisionCreatedEvent(SP_ADDRESS, VERIFIER_ADDRESS_2, tokens2, BigInt.fromI32(50000), BigInt.fromI32(1296000)) + event2.block.number = BigInt.fromI32(250) + event2.block.timestamp = BigInt.fromI32(2500) + handleProvisionCreated(event2) + + assert.entityCount("Provision", 2) + + let totalProvisioned = tokens1.plus(tokens2) + let expectedIdle = stakeTokens.minus(totalProvisioned) + + assert.fieldEquals("ServiceProvider", SP_ADDRESS.toHexString(), "tokensProvisioned", totalProvisioned.toString()) + assert.fieldEquals("ServiceProvider", SP_ADDRESS.toHexString(), "tokensIdle", expectedIdle.toString()) + assert.fieldEquals("GraphNetwork", GRAPH_NETWORK_ID.toHexString(), "countProvisions", "2") + assert.fieldEquals("GraphNetwork", GRAPH_NETWORK_ID.toHexString(), "tokensProvisioned", totalProvisioned.toString()) + }) +}) + +describe("ProvisionIncreased", () => { + beforeEach(() => { + clearStore() + }) + + test("increases tokens in existing provision", () => { + // Setup: deposit and create provision + let stakeTokens = BigInt.fromString("10000000000000000000000") // 10000 GRT + let depositEvent = createStakeDepositedEvent(SP_ADDRESS, stakeTokens) + handleHorizonStakeDeposited(depositEvent) + + let initialTokens = BigInt.fromString("3000000000000000000000") // 3000 GRT + let createEvent = createProvisionCreatedEvent(SP_ADDRESS, VERIFIER_ADDRESS, initialTokens, BigInt.fromI32(100000), BigInt.fromI32(2592000)) + handleProvisionCreated(createEvent) + + // Increase provision + let increaseAmount = BigInt.fromString("2000000000000000000000") // 2000 GRT + let event = createProvisionIncreasedEvent(SP_ADDRESS, VERIFIER_ADDRESS, increaseAmount) + handleProvisionIncreased(event) + + let provisionId = getProvisionIdString(SP_ADDRESS, VERIFIER_ADDRESS) + let totalProvisionTokens = initialTokens.plus(increaseAmount) + + assert.fieldEquals("Provision", provisionId, "tokens", totalProvisionTokens.toString()) + assert.fieldEquals("Provision", provisionId, "updatedAtBlock", "300") + assert.fieldEquals("ServiceProvider", SP_ADDRESS.toHexString(), "tokensProvisioned", totalProvisionTokens.toString()) + assert.fieldEquals("GraphNetwork", GRAPH_NETWORK_ID.toHexString(), "tokensProvisioned", totalProvisionTokens.toString()) + }) +}) + +describe("ProvisionThawed", () => { + beforeEach(() => { + clearStore() + }) + + test("moves tokens from active to thawing (tokensProvisioned unchanged)", () => { + // Setup: deposit and create provision + let stakeTokens = BigInt.fromString("10000000000000000000000") // 10000 GRT + let depositEvent = createStakeDepositedEvent(SP_ADDRESS, stakeTokens) + handleHorizonStakeDeposited(depositEvent) + + let provisionTokens = BigInt.fromString("5000000000000000000000") // 5000 GRT + let createEvent = createProvisionCreatedEvent(SP_ADDRESS, VERIFIER_ADDRESS, provisionTokens, BigInt.fromI32(100000), BigInt.fromI32(2592000)) + handleProvisionCreated(createEvent) + + // Thaw some tokens + let thawAmount = BigInt.fromString("2000000000000000000000") // 2000 GRT + let event = createProvisionThawedEvent(SP_ADDRESS, VERIFIER_ADDRESS, thawAmount) + 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()) + assert.fieldEquals("Provision", provisionId, "tokensThawing", thawAmount.toString()) + + // ServiceProvider & GraphNetwork: tokensProvisioned unchanged (thawing tokens still count as provisioned) + assert.fieldEquals("ServiceProvider", SP_ADDRESS.toHexString(), "tokensProvisioned", provisionTokens.toString()) + assert.fieldEquals("GraphNetwork", GRAPH_NETWORK_ID.toHexString(), "tokensProvisioned", provisionTokens.toString()) + }) +}) + +describe("TokensDeprovisioned", () => { + beforeEach(() => { + clearStore() + }) + + test("removes thawed tokens and decrements tokensProvisioned", () => { + // Setup: deposit, create provision, thaw tokens + let stakeTokens = BigInt.fromString("10000000000000000000000") // 10000 GRT + let depositEvent = createStakeDepositedEvent(SP_ADDRESS, stakeTokens) + handleHorizonStakeDeposited(depositEvent) + + let provisionTokens = BigInt.fromString("5000000000000000000000") // 5000 GRT + let createEvent = createProvisionCreatedEvent(SP_ADDRESS, VERIFIER_ADDRESS, provisionTokens, BigInt.fromI32(100000), BigInt.fromI32(2592000)) + handleProvisionCreated(createEvent) + + let thawAmount = BigInt.fromString("2000000000000000000000") // 2000 GRT + let thawEvent = createProvisionThawedEvent(SP_ADDRESS, VERIFIER_ADDRESS, thawAmount) + handleProvisionThawed(thawEvent) + + // Deprovision the thawed tokens + let event = createTokensDeprovisionedEvent(SP_ADDRESS, VERIFIER_ADDRESS, thawAmount) + handleTokensDeprovisioned(event) + + let provisionId = getProvisionIdString(SP_ADDRESS, VERIFIER_ADDRESS) + let remainingActiveTokens = provisionTokens.minus(thawAmount) + let expectedIdle = stakeTokens.minus(remainingActiveTokens) + + // Provision: tokensThawing should be zero + assert.fieldEquals("Provision", provisionId, "tokens", remainingActiveTokens.toString()) + assert.fieldEquals("Provision", provisionId, "tokensThawing", "0") + + // ServiceProvider: tokensProvisioned decremented, tokensIdle increased + assert.fieldEquals("ServiceProvider", SP_ADDRESS.toHexString(), "tokensProvisioned", remainingActiveTokens.toString()) + assert.fieldEquals("ServiceProvider", SP_ADDRESS.toHexString(), "tokensIdle", expectedIdle.toString()) + + // GraphNetwork: tokensProvisioned decremented + assert.fieldEquals("GraphNetwork", GRAPH_NETWORK_ID.toHexString(), "tokensProvisioned", remainingActiveTokens.toString()) + }) +}) + +describe("ProvisionSlashed", () => { + beforeEach(() => { + clearStore() + }) + + test("reduces both provision and total stake", () => { + // Setup: deposit and create provision + let stakeTokens = BigInt.fromString("10000000000000000000000") // 10000 GRT + let depositEvent = createStakeDepositedEvent(SP_ADDRESS, stakeTokens) + handleHorizonStakeDeposited(depositEvent) + + let provisionTokens = BigInt.fromString("5000000000000000000000") // 5000 GRT + let createEvent = createProvisionCreatedEvent(SP_ADDRESS, VERIFIER_ADDRESS, provisionTokens, BigInt.fromI32(100000), BigInt.fromI32(2592000)) + handleProvisionCreated(createEvent) + + // Slash + let slashAmount = BigInt.fromString("1000000000000000000000") // 1000 GRT + let event = createProvisionSlashedEvent(SP_ADDRESS, VERIFIER_ADDRESS, slashAmount) + handleProvisionSlashed(event) + + let provisionId = getProvisionIdString(SP_ADDRESS, VERIFIER_ADDRESS) + let remainingProvisionTokens = provisionTokens.minus(slashAmount) + let remainingStake = stakeTokens.minus(slashAmount) + let expectedIdle = remainingStake.minus(remainingProvisionTokens) + + // Provision tokens reduced + assert.fieldEquals("Provision", provisionId, "tokens", remainingProvisionTokens.toString()) + + // ServiceProvider stake and provisioned both reduced + assert.fieldEquals("ServiceProvider", SP_ADDRESS.toHexString(), "tokensStaked", remainingStake.toString()) + assert.fieldEquals("ServiceProvider", SP_ADDRESS.toHexString(), "tokensProvisioned", remainingProvisionTokens.toString()) + assert.fieldEquals("ServiceProvider", SP_ADDRESS.toHexString(), "tokensIdle", expectedIdle.toString()) + + // GraphNetwork totals reduced + assert.fieldEquals("GraphNetwork", GRAPH_NETWORK_ID.toHexString(), "tokensStaked", remainingStake.toString()) + assert.fieldEquals("GraphNetwork", GRAPH_NETWORK_ID.toHexString(), "tokensProvisioned", remainingProvisionTokens.toString()) + }) +}) + +describe("ProvisionParametersStaged", () => { + beforeEach(() => { + clearStore() + }) + + test("stages new parameters on provision", () => { + // Setup: deposit and create provision + let stakeTokens = BigInt.fromString("10000000000000000000000") + let depositEvent = createStakeDepositedEvent(SP_ADDRESS, stakeTokens) + handleHorizonStakeDeposited(depositEvent) + + let provisionTokens = BigInt.fromString("5000000000000000000000") + let initialMaxCut = BigInt.fromI32(100000) + let initialThawing = BigInt.fromI32(2592000) + let createEvent = createProvisionCreatedEvent(SP_ADDRESS, VERIFIER_ADDRESS, provisionTokens, initialMaxCut, initialThawing) + handleProvisionCreated(createEvent) + + // Stage new parameters + let newMaxCut = BigInt.fromI32(150000) + let newThawing = BigInt.fromI32(3888000) // 45 days + let event = createProvisionParametersStagedEvent(SP_ADDRESS, VERIFIER_ADDRESS, newMaxCut, newThawing) + handleProvisionParametersStaged(event) + + let provisionId = getProvisionIdString(SP_ADDRESS, VERIFIER_ADDRESS) + + // Current parameters unchanged + assert.fieldEquals("Provision", provisionId, "maxVerifierCut", initialMaxCut.toString()) + assert.fieldEquals("Provision", provisionId, "thawingPeriod", initialThawing.toString()) + + // Pending parameters set + assert.fieldEquals("Provision", provisionId, "maxVerifierCutPending", newMaxCut.toString()) + assert.fieldEquals("Provision", provisionId, "thawingPeriodPending", newThawing.toString()) + assert.fieldEquals("Provision", provisionId, "lastParametersStagedAt", "6000") + }) +}) + +describe("ProvisionParametersSet", () => { + beforeEach(() => { + clearStore() + }) + + test("accepts staged parameters", () => { + // Setup: deposit, create provision, stage parameters + let stakeTokens = BigInt.fromString("10000000000000000000000") + let depositEvent = createStakeDepositedEvent(SP_ADDRESS, stakeTokens) + handleHorizonStakeDeposited(depositEvent) + + let provisionTokens = BigInt.fromString("5000000000000000000000") + let initialMaxCut = BigInt.fromI32(100000) + let initialThawing = BigInt.fromI32(2592000) + let createEvent = createProvisionCreatedEvent(SP_ADDRESS, VERIFIER_ADDRESS, provisionTokens, initialMaxCut, initialThawing) + handleProvisionCreated(createEvent) + + let newMaxCut = BigInt.fromI32(150000) + let newThawing = BigInt.fromI32(3888000) + let stageEvent = createProvisionParametersStagedEvent(SP_ADDRESS, VERIFIER_ADDRESS, newMaxCut, newThawing) + handleProvisionParametersStaged(stageEvent) + + // Accept parameters + let setEvent = createProvisionParametersSetEvent(SP_ADDRESS, VERIFIER_ADDRESS, newMaxCut, newThawing) + handleProvisionParametersSet(setEvent) + + let provisionId = getProvisionIdString(SP_ADDRESS, VERIFIER_ADDRESS) + + // Current parameters updated + assert.fieldEquals("Provision", provisionId, "maxVerifierCut", newMaxCut.toString()) + assert.fieldEquals("Provision", provisionId, "thawingPeriod", newThawing.toString()) + + // Pending parameters now equal current (never zero) + assert.fieldEquals("Provision", provisionId, "maxVerifierCutPending", newMaxCut.toString()) + assert.fieldEquals("Provision", provisionId, "thawingPeriodPending", newThawing.toString()) + }) +}) + +describe("tokensIdle lifecycle", () => { + beforeEach(() => { + clearStore() + }) + + test("tracks tokensIdle correctly through full lifecycle", () => { + // 1. Deposit 10000 GRT - tokensIdle should be 10000 + let stakeTokens = BigInt.fromString("10000000000000000000000") // 10000 GRT + let depositEvent = createStakeDepositedEvent(SP_ADDRESS, stakeTokens) + handleHorizonStakeDeposited(depositEvent) + + assert.fieldEquals("ServiceProvider", SP_ADDRESS.toHexString(), "tokensStaked", "10000000000000000000000") + assert.fieldEquals("ServiceProvider", SP_ADDRESS.toHexString(), "tokensProvisioned", "0") + assert.fieldEquals("ServiceProvider", SP_ADDRESS.toHexString(), "tokensIdle", "10000000000000000000000") + + // 2. Create provision with 4000 GRT - tokensIdle should be 6000 + let provisionTokens = BigInt.fromString("4000000000000000000000") // 4000 GRT + let createEvent = createProvisionCreatedEvent(SP_ADDRESS, VERIFIER_ADDRESS, provisionTokens, BigInt.fromI32(100000), BigInt.fromI32(2592000)) + handleProvisionCreated(createEvent) + + assert.fieldEquals("ServiceProvider", SP_ADDRESS.toHexString(), "tokensStaked", "10000000000000000000000") + assert.fieldEquals("ServiceProvider", SP_ADDRESS.toHexString(), "tokensProvisioned", "4000000000000000000000") + assert.fieldEquals("ServiceProvider", SP_ADDRESS.toHexString(), "tokensIdle", "6000000000000000000000") + + // 3. Increase provision by 2000 GRT - tokensIdle should be 4000 + let increaseAmount = BigInt.fromString("2000000000000000000000") // 2000 GRT + let increaseEvent = createProvisionIncreasedEvent(SP_ADDRESS, VERIFIER_ADDRESS, increaseAmount) + handleProvisionIncreased(increaseEvent) + + assert.fieldEquals("ServiceProvider", SP_ADDRESS.toHexString(), "tokensStaked", "10000000000000000000000") + assert.fieldEquals("ServiceProvider", SP_ADDRESS.toHexString(), "tokensProvisioned", "6000000000000000000000") + assert.fieldEquals("ServiceProvider", SP_ADDRESS.toHexString(), "tokensIdle", "4000000000000000000000") + + // 4. Thaw 1000 GRT - tokensProvisioned stays 6000 (thawing tokens still count as provisioned) + let thawAmount = BigInt.fromString("1000000000000000000000") // 1000 GRT + let thawEvent = createProvisionThawedEvent(SP_ADDRESS, VERIFIER_ADDRESS, thawAmount) + handleProvisionThawed(thawEvent) + + assert.fieldEquals("ServiceProvider", SP_ADDRESS.toHexString(), "tokensStaked", "10000000000000000000000") + assert.fieldEquals("ServiceProvider", SP_ADDRESS.toHexString(), "tokensProvisioned", "6000000000000000000000") + assert.fieldEquals("ServiceProvider", SP_ADDRESS.toHexString(), "tokensIdle", "4000000000000000000000") + + // Verify tokensThawing on the provision + let provisionId = getProvisionIdString(SP_ADDRESS, VERIFIER_ADDRESS) + assert.fieldEquals("Provision", provisionId, "tokens", "5000000000000000000000") + assert.fieldEquals("Provision", provisionId, "tokensThawing", "1000000000000000000000") + + // 5. Deprovision 1000 GRT (after thawing completes) - now tokensProvisioned decreases + let deprovisionEvent = createTokensDeprovisionedEvent(SP_ADDRESS, VERIFIER_ADDRESS, thawAmount) + handleTokensDeprovisioned(deprovisionEvent) + + assert.fieldEquals("ServiceProvider", SP_ADDRESS.toHexString(), "tokensStaked", "10000000000000000000000") + assert.fieldEquals("ServiceProvider", SP_ADDRESS.toHexString(), "tokensProvisioned", "5000000000000000000000") + assert.fieldEquals("ServiceProvider", SP_ADDRESS.toHexString(), "tokensIdle", "5000000000000000000000") + + // tokensThawing should now be 0 + assert.fieldEquals("Provision", provisionId, "tokensThawing", "0") + + // 6. Slash 500 GRT - tokensIdle stays 5000 (both staked and provisioned decrease equally) + let slashAmount = BigInt.fromString("500000000000000000000") // 500 GRT + let slashEvent = createProvisionSlashedEvent(SP_ADDRESS, VERIFIER_ADDRESS, slashAmount) + handleProvisionSlashed(slashEvent) + + assert.fieldEquals("ServiceProvider", SP_ADDRESS.toHexString(), "tokensStaked", "9500000000000000000000") + assert.fieldEquals("ServiceProvider", SP_ADDRESS.toHexString(), "tokensProvisioned", "4500000000000000000000") + assert.fieldEquals("ServiceProvider", SP_ADDRESS.toHexString(), "tokensIdle", "5000000000000000000000") + + // Verify GraphNetwork aggregates + assert.fieldEquals("GraphNetwork", GRAPH_NETWORK_ID.toHexString(), "tokensStaked", "9500000000000000000000") + assert.fieldEquals("GraphNetwork", GRAPH_NETWORK_ID.toHexString(), "tokensProvisioned", "4500000000000000000000") + assert.fieldEquals("GraphNetwork", GRAPH_NETWORK_ID.toHexString(), "countProvisions", "1") + assert.fieldEquals("GraphNetwork", GRAPH_NETWORK_ID.toHexString(), "countServiceProviders", "1") + }) +}) diff --git a/packages/subgraph/tests/staking.test.ts b/packages/subgraph/tests/staking.test.ts index efe7827..6113084 100644 --- a/packages/subgraph/tests/staking.test.ts +++ b/packages/subgraph/tests/staking.test.ts @@ -52,7 +52,8 @@ describe("HorizonStakeDeposited", () => { assert.entityCount("ServiceProvider", 1) assert.fieldEquals("ServiceProvider", SP_ADDRESS.toHexString(), "tokensStaked", tokens.toString()) assert.fieldEquals("ServiceProvider", SP_ADDRESS.toHexString(), "tokensProvisioned", "0") - assert.fieldEquals("ServiceProvider", SP_ADDRESS.toHexString(), "tokensIdle", "0") + // tokensIdle = tokensStaked - tokensProvisioned = 1000 GRT + assert.fieldEquals("ServiceProvider", SP_ADDRESS.toHexString(), "tokensIdle", tokens.toString()) assert.fieldEquals("ServiceProvider", SP_ADDRESS.toHexString(), "createdAtBlock", "100") assert.fieldEquals("ServiceProvider", SP_ADDRESS.toHexString(), "createdAt", "1000") @@ -133,25 +134,32 @@ describe("HorizonStakeWithdrawn", () => { handleHorizonStakeWithdrawn(withdrawEvent) assert.fieldEquals("ServiceProvider", SP_ADDRESS.toHexString(), "tokensStaked", remainingStake.toString()) + assert.fieldEquals("ServiceProvider", SP_ADDRESS.toHexString(), "tokensIdle", remainingStake.toString()) assert.fieldEquals("ServiceProvider", SP_ADDRESS.toHexString(), "updatedAtBlock", "200") assert.fieldEquals("ServiceProvider", SP_ADDRESS.toHexString(), "updatedAt", "2000") assert.fieldEquals("GraphNetwork", GRAPH_NETWORK_ID.toHexString(), "tokensStaked", remainingStake.toString()) - // Count should remain 1 (we don't decrement on withdraw) + // Count should remain 1 (partial withdrawal, still has stake) assert.fieldEquals("GraphNetwork", GRAPH_NETWORK_ID.toHexString(), "countServiceProviders", "1") }) - test("allows full withdrawal", () => { + test("allows full withdrawal and decrements countServiceProviders", () => { let stake = BigInt.fromString("1000000000000000000000") // 1000 GRT let depositEvent = createStakeDepositedEvent(SP_ADDRESS, stake) handleHorizonStakeDeposited(depositEvent) + // Verify count is 1 before withdrawal + assert.fieldEquals("GraphNetwork", GRAPH_NETWORK_ID.toHexString(), "countServiceProviders", "1") + let withdrawEvent = createStakeWithdrawnEvent(SP_ADDRESS, stake) handleHorizonStakeWithdrawn(withdrawEvent) 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 + assert.fieldEquals("GraphNetwork", GRAPH_NETWORK_ID.toHexString(), "countServiceProviders", "0") }) test("handles multiple withdrawals", () => { diff --git a/packages/tools/.env.example b/packages/tools/.env.example new file mode 100644 index 0000000..121910c --- /dev/null +++ b/packages/tools/.env.example @@ -0,0 +1,8 @@ +# Network to use (default: arbitrum-one) +NETWORK=arbitrum-one + +# Override RPC URL +RPC_URL=https://arb1.arbitrum.io/rpc + +# API key to query subgraphs +GRAPH_API_KEY=myapikey diff --git a/packages/tools/package.json b/packages/tools/package.json new file mode 100644 index 0000000..c575b41 --- /dev/null +++ b/packages/tools/package.json @@ -0,0 +1,21 @@ +{ + "name": "@graphprotocol/graph-horizon-tools", + "version": "0.1.0", + "private": true, + "type": "module", + "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" + }, + "devDependencies": { + "@types/node": "22.15.18", + "tsx": "4.19.4", + "typescript": "5.8.3" + }, + "dependencies": { + "dotenv": "^17.4.2" + } +} diff --git a/packages/tools/src/common.ts b/packages/tools/src/common.ts new file mode 100644 index 0000000..351a007 --- /dev/null +++ b/packages/tools/src/common.ts @@ -0,0 +1,53 @@ +/** + * Common utilities shared across tools + */ + +import { getConfig } from "./config" + +export async function querySubgraph(url: string, query: string): Promise { + const response = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ query }), + }) + const json = await response.json() + if (json.errors) { + throw new Error(`Subgraph error: ${JSON.stringify(json.errors)}`) + } + return json.data +} + +export function formatGRT(wei: bigint): string { + const decimals = 18n + const divisor = 10n ** decimals + const whole = wei / divisor + const fraction = wei % divisor + const fractionStr = fraction.toString().padStart(18, "0").slice(0, 4) + return `${whole.toLocaleString()}.${fractionStr} GRT` +} + +// Rate limiting delay between RPC calls +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 { + 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}`) + console.log("") +} + +export function getSubgraphUrlFromArgs(): string { + const url = process.argv[2] + if (!url) { + const scriptName = process.argv[1]?.split("/").pop() || "script" + console.error(`Usage: NETWORK=arbitrum-one pnpm ${scriptName.replace(".ts", "")} `) + process.exit(1) + } + return url +} diff --git a/packages/tools/src/config.ts b/packages/tools/src/config.ts new file mode 100644 index 0000000..d537b17 --- /dev/null +++ b/packages/tools/src/config.ts @@ -0,0 +1,58 @@ +import "dotenv/config" +import * as path from "path" + +export interface NetworkConfig { + name: string + rpcUrl: string + stakingAddress: string + subgraphServiceAddress: string + legacySubgraphId: string + horizonGenesisBlock: number + subgraphConfigPath: string +} + +const configs: Record = { + "arbitrum-one": { + name: "arbitrum-one", + rpcUrl: "https://arb1.arbitrum.io/rpc", + stakingAddress: "0x00669A4CF01450B64E8A2A20E9b1FCB71E61eF03", + subgraphServiceAddress: "0xb2Bb92d0DE618878E438b55D5846cfecD9301105", + legacySubgraphId: "DZz4kDTdmzWLWsV373w2bSmoar3umKKH9y82SUKr5qmp", + horizonGenesisBlock: 408825706, + subgraphConfigPath: path.resolve(process.cwd(), "../subgraph/src/config/arbitrum-one"), + }, + // Add more networks here +} + +export function getConfig(): NetworkConfig { + const network = process.env.NETWORK || "arbitrum-one" + const config = configs[network] + + if (!config) { + const available = Object.keys(configs).join(", ") + throw new Error(`Unknown network: ${network}. Available: ${available}`) + } + + // Allow RPC_URL override + if (process.env.RPC_URL) { + config.rpcUrl = process.env.RPC_URL + } + + return config +} + +export function getGraphApiKey(): string { + const apiKey = process.env.GRAPH_API_KEY + if (!apiKey) { + console.error("Error: GRAPH_API_KEY not set in environment") + console.error("Add GRAPH_API_KEY=your-key to .env file") + process.exit(1) + } + return apiKey +} + +export function getLegacySubgraphUrl(): string { + const config = getConfig() + const apiKey = getGraphApiKey() + return `https://gateway.thegraph.com/api/${apiKey}/subgraphs/id/${config.legacySubgraphId}` +} diff --git a/packages/tools/src/onchain.ts b/packages/tools/src/onchain.ts new file mode 100644 index 0000000..e546a14 --- /dev/null +++ b/packages/tools/src/onchain.ts @@ -0,0 +1,130 @@ +import { getConfig } from "./config" + +// Function selectors (keccak256 of signature, first 4 bytes) +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) + +export interface ServiceProviderData { + tokensStaked: bigint + tokensProvisioned: bigint +} + +export interface ProvisionData { + tokens: bigint + tokensThawing: bigint + sharesThawing: bigint + maxVerifierCut: bigint + thawingPeriod: bigint + createdAt: bigint + maxVerifierCutPending: bigint + thawingPeriodPending: bigint + lastParametersStagedAt: bigint + thawingNonce: bigint +} + +export interface DelegationPoolData { + tokens: bigint + shares: bigint + tokensThawing: bigint + sharesThawing: bigint + thawingNonce: bigint +} + +async function ethCall(to: string, data: string): Promise { + const config = getConfig() + const response = await fetch(config.rpcUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "eth_call", + params: [{ to, data }, "latest"], + }), + }) + + const json = await response.json() + if (json.error) { + throw new Error(`RPC error: ${JSON.stringify(json.error)}`) + } + return json.result +} + +function padAddress(address: string): string { + return address.toLowerCase().replace("0x", "").padStart(64, "0") +} + +export async function getStake(address: string): Promise { + const config = getConfig() + const callData = GET_STAKE_SELECTOR + padAddress(address) + const result = await ethCall(config.stakingAddress, callData) + return BigInt(result) +} + +export async function getServiceProvider(address: string): Promise { + const config = getConfig() + const callData = GET_SERVICE_PROVIDER_SELECTOR + padAddress(address) + const result = await ethCall(config.stakingAddress, callData) + + // Result is two packed uint256 values (tokensStaked, tokensProvisioned) + const hex = result.slice(2) // remove 0x + return { + tokensStaked: BigInt("0x" + hex.slice(0, 64)), + tokensProvisioned: BigInt("0x" + hex.slice(64, 128)), + } +} + +export async function getProvision(serviceProvider: string, verifier: string): Promise { + const config = getConfig() + const callData = GET_PROVISION_SELECTOR + padAddress(serviceProvider) + padAddress(verifier) + const result = await ethCall(config.stakingAddress, callData) + + // Provision struct has 10 fields, but they're packed with different sizes: + // - tokens: uint256 (32 bytes) + // - tokensThawing: uint256 (32 bytes) + // - sharesThawing: uint256 (32 bytes) + // - maxVerifierCut: uint32 (packed) + // - thawingPeriod: uint64 (packed) + // - createdAt: uint64 (packed) + // - maxVerifierCutPending: uint32 (packed) + // - thawingPeriodPending: uint64 (packed) + // - lastParametersStagedAt: uint256 (32 bytes) + // - thawingNonce: uint256 (32 bytes) + // In ABI encoding, each field is padded to 32 bytes + const hex = result.slice(2) + return { + tokens: BigInt("0x" + hex.slice(0, 64)), + tokensThawing: BigInt("0x" + hex.slice(64, 128)), + sharesThawing: BigInt("0x" + hex.slice(128, 192)), + maxVerifierCut: BigInt("0x" + hex.slice(192, 256)), + thawingPeriod: BigInt("0x" + hex.slice(256, 320)), + createdAt: BigInt("0x" + hex.slice(320, 384)), + maxVerifierCutPending: BigInt("0x" + hex.slice(384, 448)), + thawingPeriodPending: BigInt("0x" + hex.slice(448, 512)), + lastParametersStagedAt: BigInt("0x" + hex.slice(512, 576)), + thawingNonce: BigInt("0x" + hex.slice(576, 640)), + } +} + +export async function getDelegationPool(serviceProvider: string, verifier: string): Promise { + const config = getConfig() + const callData = GET_DELEGATION_POOL_SELECTOR + padAddress(serviceProvider) + padAddress(verifier) + const result = await ethCall(config.stakingAddress, callData) + + // DelegationPool struct has 5 uint256 fields: + // - tokens: uint256 + // - shares: uint256 + // - tokensThawing: uint256 + // - sharesThawing: uint256 + // - thawingNonce: uint256 + const hex = result.slice(2) + return { + tokens: BigInt("0x" + hex.slice(0, 64)), + shares: BigInt("0x" + hex.slice(64, 128)), + tokensThawing: BigInt("0x" + hex.slice(128, 192)), + sharesThawing: BigInt("0x" + hex.slice(192, 256)), + thawingNonce: BigInt("0x" + hex.slice(256, 320)), + } +} diff --git a/packages/tools/src/seed/delegations.ts b/packages/tools/src/seed/delegations.ts new file mode 100644 index 0000000..b4b2cd7 --- /dev/null +++ b/packages/tools/src/seed/delegations.ts @@ -0,0 +1,107 @@ +/** + * Exports indexer addresses 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. + * + * Usage: NETWORK=arbitrum-one pnpm seed:delegations + * + * Requires GRAPH_API_KEY in .env + */ + +import * as fs from "fs" +import * as path from "path" +import { getConfig, getLegacySubgraphUrl } from "../config" +import { querySubgraph } from "../common" + +interface Indexer { + id: string + delegatedTokens: string +} + +async function main() { + const config = getConfig() + const subgraphUrl = getLegacySubgraphUrl() + + console.log("=== Delegation Pool Seed Export ===") + console.log(`Network: ${config.name}`) + console.log(`Legacy Subgraph: ${config.legacySubgraphId}`) + console.log(`Block: ${config.horizonGenesisBlock}`) + console.log("") + + // Fetch all indexers with delegations at genesis block + console.log("Fetching indexers with delegations...") + let allIndexers: Indexer[] = [] + let indexerLastId = "" + + while (true) { + const whereClause = indexerLastId + ? `where: { delegatedTokens_gt: "0", id_gt: "${indexerLastId}" }` + : `where: { delegatedTokens_gt: "0" }` + const data = await querySubgraph<{ indexers: Indexer[] }>( + subgraphUrl, + `{ indexers(first: 1000, orderBy: id, block: { number: ${config.horizonGenesisBlock} }, ${whereClause}) { + id + delegatedTokens + } }` + ) + + if (data.indexers.length === 0) break + allIndexers.push(...data.indexers) + indexerLastId = data.indexers[data.indexers.length - 1].id + if (data.indexers.length < 1000) break + } + + // Sort for deterministic output + allIndexers.sort((a, b) => a.id.localeCompare(b.id)) + + console.log(` Found ${allIndexers.length} indexers with delegations`) + console.log("") + + // Generate output file in subgraph package + if (!fs.existsSync(config.subgraphConfigPath)) { + console.error(`Error: Subgraph config directory not found: ${config.subgraphConfigPath}`) + process.exit(1) + } + + const seedFilePath = path.join(config.subgraphConfigPath, "delegation-seed.ts") + + const output = `// AUTO-GENERATED FILE - DO NOT EDIT MANUALLY +// Regenerate with: cd packages/tools && NETWORK=${config.name} pnpm seed:delegations +// Generated: ${new Date().toISOString()} +// Network: ${config.name} +// Block: ${config.horizonGenesisBlock} +// +// Indexers with delegations: ${allIndexers.length} +// Note: Individual delegators/delegations are lazy-initialized, not seeded at genesis + +// Indexer addresses with delegations (for DelegationPool seeding) +export const DELEGATED_INDEXER_ADDRESSES: string[] = [ +${allIndexers.map((i) => ` "${i.id}",`).join("\n")} +] +` + + fs.writeFileSync(seedFilePath, output) + console.log(`Written: ${seedFilePath}`) + + // Summary + console.log("") + console.log("=== Summary ===") + console.log(` Indexers to seed DelegationPools: ${allIndexers.length}`) + console.log("") + console.log(" Estimated data size:") + const indexerBytes = allIndexers.length * 42 + console.log(` Indexer addresses: ${(indexerBytes / 1024).toFixed(1)} KB`) + console.log("") + console.log(" Contract calls at genesis:") + console.log(` getDelegationPool(): ${allIndexers.length} calls`) + console.log(` With multicall (100/batch): ~${Math.ceil(allIndexers.length / 100)} batched calls`) + console.log("") + console.log(" Note: Delegators and delegations are lazy-initialized when they") + console.log(" first interact (delegate, undelegate, withdraw).") +} + +main().catch((err) => { + console.error("Error:", err) + process.exit(1) +}) diff --git a/packages/tools/src/seed/indexers.ts b/packages/tools/src/seed/indexers.ts new file mode 100644 index 0000000..3e8d676 --- /dev/null +++ b/packages/tools/src/seed/indexers.ts @@ -0,0 +1,86 @@ +/** + * Fetches all indexer/service provider addresses from the legacy Graph Network subgraph. + * Outputs to packages/subgraph/src/config/{network}/seed.ts + * + * Usage: NETWORK=arbitrum-one pnpm seed:indexers + * + * Requires GRAPH_API_KEY in .env + */ + +import * as fs from "fs" +import * as path from "path" +import { getConfig, getLegacySubgraphUrl } from "../config" +import { querySubgraph } from "../common" + +interface Indexer { + id: string +} + +async function main() { + const config = getConfig() + const subgraphUrl = getLegacySubgraphUrl() + + console.log("=== Indexer Seed Export ===") + console.log(`Network: ${config.name}`) + console.log(`Legacy Subgraph: ${config.legacySubgraphId}`) + console.log(`Block: ${config.horizonGenesisBlock}`) + console.log("") + + // Fetch all indexers with stake at the specified block + console.log("Fetching indexers...") + let allIndexers: Indexer[] = [] + let lastId = "" + + while (true) { + const whereClause = lastId + ? `where: { stakedTokens_gt: "0", id_gt: "${lastId}" }` + : `where: { stakedTokens_gt: "0" }` + + const data = await querySubgraph<{ indexers: Indexer[] }>( + subgraphUrl, + `{ indexers(first: 1000, orderBy: id, block: { number: ${config.horizonGenesisBlock} }, ${whereClause}) { id } }` + ) + + if (data.indexers.length === 0) break + + allIndexers.push(...data.indexers) + lastId = data.indexers[data.indexers.length - 1].id + + if (data.indexers.length < 1000) break + } + + // Sort addresses for deterministic output + allIndexers.sort((a, b) => a.id.localeCompare(b.id)) + + console.log(`Found ${allIndexers.length} indexers with stake`) + console.log("") + + // Generate output file in subgraph package + if (!fs.existsSync(config.subgraphConfigPath)) { + console.error(`Error: Subgraph config directory not found: ${config.subgraphConfigPath}`) + process.exit(1) + } + + const seedFilePath = path.join(config.subgraphConfigPath, "indexer-seed.ts") + + const output = `// AUTO-GENERATED FILE - DO NOT EDIT MANUALLY +// Regenerate with: cd packages/tools && NETWORK=${config.name} pnpm seed:indexers +// Generated: ${new Date().toISOString()} +// Network: ${config.name} +// Block: ${config.horizonGenesisBlock} +// Count: ${allIndexers.length} + +export const SERVICE_PROVIDER_ADDRESSES: string[] = [ +${allIndexers.map((i) => ` "${i.id}",`).join("\n")} +] +` + + fs.writeFileSync(seedFilePath, output) + console.log(`Written: ${seedFilePath}`) + console.log(` ${allIndexers.length} service provider addresses`) +} + +main().catch((err) => { + console.error("Error:", err) + process.exit(1) +}) diff --git a/packages/tools/src/validation/delegations.ts b/packages/tools/src/validation/delegations.ts new file mode 100644 index 0000000..832c70d --- /dev/null +++ b/packages/tools/src/validation/delegations.ts @@ -0,0 +1,193 @@ +/** + * 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/provisions.ts b/packages/tools/src/validation/provisions.ts new file mode 100644 index 0000000..596925a --- /dev/null +++ b/packages/tools/src/validation/provisions.ts @@ -0,0 +1,199 @@ +/** + * 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/validation/src/validate-stake.ts b/packages/tools/src/validation/stake.ts similarity index 71% rename from packages/validation/src/validate-stake.ts rename to packages/tools/src/validation/stake.ts index bcd6f2c..2edd337 100644 --- a/packages/validation/src/validate-stake.ts +++ b/packages/tools/src/validation/stake.ts @@ -4,10 +4,8 @@ * Usage: NETWORK=arbitrum-one pnpm validate:stake */ -import { getConfig } from "./config" -import { getStake } from "./onchain" - -const config = getConfig() +import { getStake } from "../onchain" +import { querySubgraph, formatGRT, getSubgraphUrlFromArgs, printHeader, delay } from "../common" interface ServiceProvider { id: string @@ -20,40 +18,9 @@ interface GraphNetwork { countServiceProviders: number } -async function querySubgraph(url: string, query: string): Promise { - const response = await fetch(url, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ query }), - }) - const json = await response.json() - if (json.errors) { - throw new Error(`Subgraph error: ${JSON.stringify(json.errors)}`) - } - return json.data -} - -// Format token amount with 18 decimals as GRT -function formatGRT(wei: bigint): string { - const decimals = 18n - const divisor = 10n ** decimals - const whole = wei / divisor - const fraction = wei % divisor - const fractionStr = fraction.toString().padStart(18, "0").slice(0, 4) - return `${whole.toLocaleString()}.${fractionStr} GRT` -} - async function main() { - const subgraphUrl = process.argv[2] - if (!subgraphUrl) { - console.error("Usage: npx tsx scripts/validate-stake.ts ") - process.exit(1) - } - - console.log("Subgraph URL:", subgraphUrl) - console.log("RPC URL:", config.rpcUrl) - console.log("Staking contract:", config.stakingAddress) - console.log("") + const subgraphUrl = getSubgraphUrlFromArgs() + printHeader(subgraphUrl) // Fetch GraphNetwork const networkData = await querySubgraph<{ graphNetwork: GraphNetwork }>( @@ -118,8 +85,7 @@ async function main() { matches++ } - // Rate limiting - small delay between RPC calls - await new Promise((resolve) => setTimeout(resolve, 50)) + await delay() } // Summary diff --git a/packages/validation/tsconfig.json b/packages/tools/tsconfig.json similarity index 100% rename from packages/validation/tsconfig.json rename to packages/tools/tsconfig.json diff --git a/packages/validation/.env.example b/packages/validation/.env.example deleted file mode 100644 index ef0f1c5..0000000 --- a/packages/validation/.env.example +++ /dev/null @@ -1,5 +0,0 @@ -# Network to use (default: arbitrum-one) -NETWORK=arbitrum-one - -# Optional: Override RPC URL -# RPC_URL=https://arb1.arbitrum.io/rpc diff --git a/packages/validation/package.json b/packages/validation/package.json deleted file mode 100644 index 1b386fa..0000000 --- a/packages/validation/package.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "name": "@graphprotocol/graph-horizon-validation", - "version": "0.1.0", - "private": true, - "type": "module", - "scripts": { - "validate:stake": "tsx src/validate-stake.ts" - }, - "devDependencies": { - "@types/node": "22.15.18", - "tsx": "4.19.4", - "typescript": "5.8.3" - }, - "dependencies": { - "dotenv": "^17.4.2" - } -} diff --git a/packages/validation/src/config.ts b/packages/validation/src/config.ts deleted file mode 100644 index 812c638..0000000 --- a/packages/validation/src/config.ts +++ /dev/null @@ -1,33 +0,0 @@ -import "dotenv/config" - -export interface NetworkConfig { - name: string - rpcUrl: string - stakingAddress: string -} - -const configs: Record = { - "arbitrum-one": { - name: "arbitrum-one", - rpcUrl: "https://arb1.arbitrum.io/rpc", - stakingAddress: "0x00669A4CF01450B64E8A2A20E9b1FCB71E61eF03", - }, - // Add more networks here -} - -export function getConfig(): NetworkConfig { - const network = process.env.NETWORK || "arbitrum-one" - const config = configs[network] - - if (!config) { - const available = Object.keys(configs).join(", ") - throw new Error(`Unknown network: ${network}. Available: ${available}`) - } - - // Allow RPC_URL override - if (process.env.RPC_URL) { - config.rpcUrl = process.env.RPC_URL - } - - return config -} diff --git a/packages/validation/src/onchain.ts b/packages/validation/src/onchain.ts deleted file mode 100644 index 7bf749b..0000000 --- a/packages/validation/src/onchain.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { getConfig } from "./config" - -// getStake(address) selector = keccak256("getStake(address)")[:4] -const GET_STAKE_SELECTOR = "0x7a766460" - -export async function getStake(address: string): Promise { - const config = getConfig() - const paddedAddress = address.toLowerCase().replace("0x", "").padStart(64, "0") - const callData = GET_STAKE_SELECTOR + paddedAddress - - const response = await fetch(config.rpcUrl, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - jsonrpc: "2.0", - id: 1, - method: "eth_call", - params: [{ to: config.stakingAddress, data: callData }, "latest"], - }), - }) - - const json = await response.json() - if (json.error) { - throw new Error(`RPC error: ${JSON.stringify(json.error)}`) - } - - return BigInt(json.result) -} diff --git a/plans/IMPLEMENTATION_PLAN.md b/plans/IMPLEMENTATION_PLAN.md index 476f42e..ef4722e 100644 --- a/plans/IMPLEMENTATION_PLAN.md +++ b/plans/IMPLEMENTATION_PLAN.md @@ -4,222 +4,217 @@ Incremental implementation with validation checkpoints between stages. ## High-Level Stages -| Stage | Focus | Entities | Validation | -|-------|-------|----------|------------| -| 1 | Boilerplate | - | Project builds, deploys empty subgraph | -| 2 | Service Provider Stake | `GraphNetwork`, `ServiceProvider` | Query SPs, verify stake totals | -| 3 | Provisions | `Provision`, `DataService` | Query provisions, verify tokensProvisioned | -| 4 | Delegation | `Delegator`, `Delegation`, `DelegationPool` | Query delegations, verify pool math | -| 5 | Thaw Requests | `ProvisionThawRequest`, `DelegationThawRequest` | Query pending thaws, verify lifecycle | -| 6 | Operators | `Operator`, `OperatorAuthorization` | Query authorizations | -| 7 | Payments & Escrow | `Payer`, `Collector`, `EscrowAccount` | Query escrow balances | -| 8 | Slashing & Fees | Slashing fields, `ProvisionFeeCut` | Verify slash accounting | +| Stage | Focus | Entities | Status | +|-------|-------|----------|--------| +| 1 | Boilerplate | - | ✅ Complete | +| 2 | Service Provider Stake | `GraphNetwork`, `ServiceProvider` | ✅ Complete | +| 3 | Provisions | `Provision`, `DataService` | ✅ Complete | +| 4 | Delegation | `Delegator`, `Delegation`, `DelegationPool` | ✅ Complete | +| 5 | Thaw Requests | `ProvisionThawRequest`, `DelegationThawRequest` | Pending | +| 6 | Operators | `Operator`, `OperatorAuthorization` | Pending | +| 7 | Payments & Escrow | `Payer`, `Collector`, `EscrowAccount` | Pending | +| 8 | Slashing & Fees | Slashing fields, `ProvisionFeeCut` | Pending | --- -## Stage 1: Boilerplate +## Stage 4: Delegation ### Goal -Project structure, build pipeline, empty deployable subgraph. +Track delegations to service providers, including migration of ~310k legacy delegators. -### Deliverables +### Migration Challenge -**1. Project structure:** -``` -network-subgraph/ -├── src/ -│ ├── common/ -│ │ ├── constants.ts -│ │ ├── numbers.ts -│ │ └── ids.ts -│ ├── config/ -│ │ ├── index.ts -│ │ └── mainnet.ts -│ ├── entities/ -│ │ └── (empty for now) -│ └── handlers/ -│ └── (empty for now) -├── abis/ -│ └── HorizonStaking.json -├── schema.graphql -├── subgraph.yaml -├── package.json -└── tsconfig.json -``` +Legacy delegations were auto-assigned to **Subgraph Service** without emitting events. Analysis: -**2. Minimal schema** (just enough to deploy): -```graphql -type GraphNetwork @entity { - id: Bytes! -} -``` +| Metric | Count | +|--------|-------| +| Total delegators | ~310,000 | +| Delegations >= 100 GRT | ~23,000 (7%) | +| Delegations < 100 GRT | ~287,000 (93%) - mostly Coinbase Earn dust | -**3. Config setup:** -```typescript -// config/mainnet.ts -export const config = { - network: "mainnet", - horizonStakingAddress: "0x...", - startBlock: 12345678, -} -``` +### Migration Strategy: Hybrid Seeding -**4. Common utilities:** -- `constants.ts`: BIGINT_ZERO, BIGINT_ONE -- `numbers.ts`: bigIntToBigDecimal, safeDiv (if needed) -- `ids.ts`: twoPartId, threePartId - -**5. Manifest with placeholder handler:** -```yaml -specVersion: 1.0.0 -indexerHints: - prune: auto -dataSources: - - kind: ethereum - name: HorizonStaking - source: - address: "0x..." - abi: HorizonStaking - startBlock: 12345678 - mapping: - kind: ethereum/events - apiVersion: 0.0.7 - language: wasm/assemblyscript - entities: - - GraphNetwork - abis: - - name: HorizonStaking - file: ./abis/HorizonStaking.json - eventHandlers: - - event: HorizonStakeDeposited(indexed address,uint256) - handler: handleHorizonStakeDeposited -``` +**Tier 1 - Proactive seeding at genesis:** +- `DelegationPool` entities (~181) - one per service provider for Subgraph Service +- `Delegation` entities (~23k) - delegations >= 100 GRT threshold +- `Delegator` entities (~23k) - corresponding delegators -### Validation Checkpoint -- [ ] `graph codegen` succeeds -- [ ] `graph build` succeeds -- [ ] Deploy to local graph-node or hosted service -- [ ] GraphQL playground loads (empty data is fine) +**Tier 2 - Lazy initialization on interaction:** +- Delegations < 100 GRT created when delegator first interacts with Horizon +- Contract call to `getDelegation()` fetches current state ---- +### Seed Data -## Stage 2: Service Provider Stake +Generated via `packages/tools`: +```bash +cd packages/tools +NETWORK=arbitrum-one pnpm seed:indexers # -> indexer-seed.ts +NETWORK=arbitrum-one pnpm seed:delegations # -> delegation-seed.ts +``` -### Goal -Service providers with stake, including migration of ~180 existing SPs. +Output files in `packages/subgraph/src/config/arbitrum-one/`: +- `indexer-seed.ts` - SERVICE_PROVIDER_ADDRESSES +- `delegation-seed.ts` - DELEGATED_INDEXER_ADDRESSES, DELEGATION_SEED_DATA ### Deliverables **1. Schema additions:** ```graphql -type GraphNetwork @entity { - id: Bytes! - countServiceProviders: Int! - tokensStaked: BigInt! +type DelegationPool @entity { + id: Bytes! # serviceProvider-verifier + serviceProvider: ServiceProvider! + verifier: Bytes! + tokens: BigInt! + shares: BigInt! + tokensThawing: BigInt! + sharesThawing: BigInt! + countDelegators: Int! + createdAtBlock: BigInt! + createdAt: BigInt! + updatedAtBlock: BigInt! + updatedAt: BigInt! } -type ServiceProvider @entity { - id: Bytes! - tokensStaked: BigInt! - tokensProvisioned: BigInt! - tokensIdle: BigInt! +type Delegator @entity { + id: Bytes! # delegator address + tokensDelegated: BigInt! + countDelegations: Int! + delegations: [Delegation!]! @derivedFrom(field: "delegator") createdAtBlock: BigInt! createdAt: BigInt! updatedAtBlock: BigInt! updatedAt: BigInt! } + +type Delegation @entity { + id: Bytes! # delegator-serviceProvider-verifier + delegator: Delegator! + pool: DelegationPool! + shares: BigInt! + tokensLocked: BigInt! + tokensLockedUntil: BigInt! + createdAtBlock: BigInt! + createdAt: BigInt! + updatedAtBlock: BigInt! + updatedAt: BigInt! +} +``` + +**2. Update ServiceProvider:** +```graphql +type ServiceProvider @entity { + # ... existing fields ... + tokensDelegated: BigInt! + countDelegators: Int! + delegationPools: [DelegationPool!]! @derivedFrom(field: "serviceProvider") +} +``` + +**3. Update GraphNetwork:** +```graphql +type GraphNetwork @entity { + # ... existing fields ... + tokensDelegated: BigInt! + countDelegators: Int! + countDelegationPools: Int! +} ``` -**2. Entity helpers:** +**4. Entity helpers:** ``` src/entities/ -├── graphNetwork.ts # getOrCreateGraphNetwork() -└── serviceProvider.ts # getOrCreateServiceProvider() +├── delegationPool.ts # getOrCreateDelegationPool() +├── delegator.ts # getOrCreateDelegator() +└── delegation.ts # getOrCreateDelegation() ``` -**3. Migration handler:** +**5. Migration seeding (update existing handler):** ``` -src/handlers/migration.ts # handleStartBlock() -src/config/serviceProviders.ts # List of 180 addresses +src/handlers/migration.ts +├── seedServiceProviders() # existing +├── seedDelegationPools() # NEW - seed ~181 pools +└── seedDelegations() # NEW - seed ~23k delegations ``` -**4. Event handlers:** +**6. Event handlers:** ``` -src/handlers/staking.ts -├── handleHorizonStakeDeposited() -└── handleHorizonStakeWithdrawn() +src/handlers/delegation.ts +├── handleTokensDelegated() +├── handleTokensUndelegated() +├── handleDelegatedTokensWithdrawn() +└── handleDelegationSlashed() ``` -**5. Manifest updates:** -- Add block handler with `filter: kind: once` -- Add HorizonStakeWithdrawn event handler +### Implementation Steps -### Handler Logic Summary +**Step 1: Update schema** - Add DelegationPool, Delegator, Delegation entities ✅ -**handleStartBlock:** -1. Create GraphNetwork singleton -2. Loop through 180 SP addresses -3. For each: call `getStake()`, `getIdleStake()`, create entity -4. Tally totals on GraphNetwork +**Step 2: Add entity helpers** - Create/load functions with contract call fallback ✅ -**handleHorizonStakeDeposited:** -1. getOrCreateServiceProvider -2. Add tokens to `tokensStaked` -3. Recalculate `tokensIdle` -4. Update GraphNetwork totals -5. Update metadata (updatedAt, updatedAtBlock) +**Step 3: Update genesis seeding** - Seed DelegationPools and Delegations from seed data ✅ -**handleHorizonStakeWithdrawn:** -1. Load ServiceProvider -2. Subtract tokens from `tokensStaked` -3. Recalculate `tokensIdle` -4. Update GraphNetwork totals -5. Update metadata +**Step 4: Add event handlers** - Handle delegation lifecycle events ✅ + +**Step 5: Update subgraph.yaml** - Add delegation event handlers ✅ + +### Seeding Threshold + +Due to AssemblyScript compiler limits, seeding uses a **50,000 GRT threshold**: +- Delegations seeded: 2,579 +- Unique delegators: 1,754 +- Delegations < 50k GRT are created lazily on first interaction + +### Contract Calls + +```typescript +// For seeding +getDelegationPool(serviceProvider, verifier) -> (tokens, shares, tokensThawing, sharesThawing) +getDelegation(serviceProvider, verifier, delegator) -> (shares, tokensLocked, tokensLockedUntil) + +// Built-in multicall for batching +multicall(bytes[] calldata data) -> bytes[] results +``` ### Validation Checkpoint -**Queries to run:** +**Queries:** ```graphql -# Check GraphNetwork totals { - graphNetwork(id: "0x01") { - countServiceProviders - tokensStaked + graphNetwork(id: "0x01000000") { + tokensDelegated + countDelegators + countDelegationPools } } -# Check individual SP { - serviceProvider(id: "0x...known-sp-address...") { - tokensStaked - tokensIdle + delegationPools(first: 10, orderBy: tokens, orderDirection: desc) { + id + tokens + shares + countDelegators } } -# List all SPs { - serviceProviders(first: 10, orderBy: tokensStaked, orderDirection: desc) { + delegators(first: 10, orderBy: tokensDelegated, orderDirection: desc) { id - tokensStaked + tokensDelegated + countDelegations } } ``` -**Validation checks:** -- [ ] `countServiceProviders` = 180 (or current count) -- [ ] `tokensStaked` on GraphNetwork = sum of all SP stakes -- [ ] Known SP addresses have expected stake values (compare to contract) -- [ ] After a new stake event: values update correctly +**Checks:** +- [ ] DelegationPool count matches seeded indexers (~181) +- [ ] Delegation count matches seeded delegations (~23k) +- [ ] Pool tokens/shares match on-chain `getDelegationPool()` values +- [ ] ServiceProvider.tokensDelegated matches sum of pool tokens +- [ ] New delegation events update entities correctly --- -## Stages 3-8: To Be Detailed Later - -Will detail these after Stage 2 is validated. High-level scope: - -**Stage 3 - Provisions:** ProvisionCreated, ProvisionIncreased, ProvisionParametersStaged/Set, TokensDeprovisioned. Links SP to DataService. - -**Stage 4 - Delegation:** Migration of legacy delegations to Subgraph Service. TokensDelegated, TokensUndelegated, DelegatedTokensWithdrawn. +## Stages 5-8: To Be Detailed Later **Stage 5 - Thaw Requests:** ThawRequestCreated, ThawRequestFulfilled, ThawRequestsFulfilled. Both provision and delegation types. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 55aa5e4..0424743 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,7 +23,7 @@ importers: specifier: 0.6.0 version: 0.6.0 - packages/validation: + packages/tools: dependencies: dotenv: specifier: ^17.4.2 diff --git a/specs/NETWORK_SUBGRAPH_MIGRATION.md b/specs/NETWORK_SUBGRAPH_MIGRATION.md index 64c5e24..9e5fdca 100644 --- a/specs/NETWORK_SUBGRAPH_MIGRATION.md +++ b/specs/NETWORK_SUBGRAPH_MIGRATION.md @@ -19,12 +19,13 @@ These entities are new to Horizon - all state is created via Horizon events: | `Provision` | Provisions are a new Horizon concept | | `ProvisionFeeCut` | New concept | | `ProvisionThawRequest` | New thaw request system | -| `DelegationPool` | Per-provision pools are new | | `DelegationThawRequest` | New thaw request system | | `Payer` | PaymentsEscrow is new | | `Collector` | New concept | | `EscrowAccount` | New escrow system | +**Note:** `DelegationPool` entities for **Subgraph Service** DO require migration (see Delegation section below). DelegationPools for other data services are new and don't require migration. + ### State That REQUIRES Migration Pre-existing state falls into three categories: **Stake**, **Delegation**, and **Operators**. @@ -89,6 +90,114 @@ Legacy delegations were auto-assigned to **Subgraph Service** specifically. **Important:** `Delegation`, `DelegationPool`, and `DataService` delegation fields only have pre-existing state for **Subgraph Service**. Other data services start fresh. +### Delegation Analysis + +Analysis of the legacy graph-network-subgraph reveals: + +| Metric | Count | +|--------|-------| +| Total delegators | ~310,000 | +| Total DelegatedStake records | ~323,000 | +| Indexers with delegations | ~181 | + +**Distribution by delegation size:** + +| Range | Count | % | +|-------|-------|---| +| < 1 GRT | ~2,400 | 0.8% | +| 1-10 GRT | ~4,200 | 1.3% | +| 10-100 GRT | ~293,000 | 90.8% | +| 100-1k GRT | ~13,800 | 4.3% | +| 1k-10k GRT | ~4,000 | 1.2% | +| > 10k GRT | ~5,200 | 1.6% | + +**Key finding:** ~91% of delegations are in the 10-100 GRT range, likely from the Coinbase Earn program. These are effectively dust delegators who are unlikely to ever interact with Horizon. + +### Contract Calls for Delegation + +The `HorizonStaking` contract provides: + +| Function | Signature | Returns | +|----------|-----------|---------| +| `getDelegationPool` | `getDelegationPool(address sp, address verifier)` | `(tokens, shares, tokensThawing, sharesThawing, nonce)` | +| `getDelegation` | `getDelegation(address sp, address verifier, address delegator)` | `(shares, tokensLocked, tokensLockedUntil)` | + +The contract also supports batched calls via built-in `multicall(bytes[] calldata data)`. + +### Migration Approach: Hybrid Seeding + +Given the large number of delegators (~310k) but concentration of value in larger delegations (~7%), we use a **hybrid approach**: + +#### Tier 1: Proactive Seeding (at genesis block) + +Seed entities for delegations **>= 100 GRT** (~23,000 delegations): + +1. **DelegationPool** entities (~181) - one per service provider for Subgraph Service +2. **Delegation** entities (~23,000) - for delegators with >= 100 GRT +3. **Delegator** entities (~23,000) - corresponding delegator records + +**Contract calls:** +- `getDelegationPool()` × 181 = 181 calls +- `getDelegation()` × 23,000 = 23,000 calls (batched via multicall, ~47 batches of 500) + +**Data to hardcode:** +- ~181 indexer addresses (~7.6 KB) +- ~23,000 (delegator, indexer) pairs (~1.9 MB) +- Total: ~2 MB (well under WASM limits) + +#### Tier 2: Lazy Initialization (on first interaction) + +For delegations **< 100 GRT** (~300,000 delegations): + +- `Delegator` and `Delegation` entities created when delegator first interacts with Horizon +- Contract call to `getDelegation()` fetches current state at interaction time +- These are mostly Coinbase Earn dust delegators unlikely to ever interact + +**Trade-offs:** +- `countDelegators` on `DelegationPool`/`ServiceProvider`/`GraphNetwork` reflects only seeded delegators (~23k vs ~310k) +- Dust delegators (~92%) won't have entities until they interact +- Service providers get accurate `tokensDelegated` totals immediately (what matters for operations) + +### Implementation + +1. **Export delegation data** from legacy subgraph: + ```bash + cd packages/tools + pnpm seed:delegations 100 # threshold in GRT + ``` + This generates `packages/subgraph/src/config/arbitrum-one/delegation-seed.ts` with indexer addresses and (delegator, indexer) pairs. + +2. **Genesis block handler**: + ```typescript + function handleBlock(block: ethereum.Block): void { + // Seed DelegationPools + for (let i = 0; i < INDEXER_ADDRESSES.length; i++) { + seedDelegationPool(INDEXER_ADDRESSES[i], SUBGRAPH_SERVICE) + } + + // Seed Delegations (batched multicall) + for (let i = 0; i < DELEGATIONS.length; i++) { + seedDelegation(DELEGATIONS[i][0], DELEGATIONS[i][1], SUBGRAPH_SERVICE) + } + } + ``` + +3. **Lazy initialization** in event handlers: + ```typescript + function getOrCreateDelegation(delegator: Address, sp: Address, verifier: Address): Delegation { + let id = delegationId(delegator, sp, verifier) + let delegation = Delegation.load(id) + if (delegation == null) { + // Fetch current state from contract + let onChain = contract.getDelegation(sp, verifier, delegator) + delegation = new Delegation(id) + delegation.shares = onChain.shares + // ... populate fields + } + return delegation + } + ``` + ## 3. Operators Legacy operator authorizations were auto-assigned to **Subgraph Service** specifically. @@ -271,15 +380,27 @@ If capturing providers who never interact with Horizon is important, a hardcoded ## Open Questions -1. What is the exact Subgraph Service address? +1. ~~What is the exact Subgraph Service address?~~ **Answered:** `0xb2Bb92d0DE618878E438b55D5846cfecD9301105` 2. What is the Horizon deployment block number? 3. ~~Is capturing inactive providers (who never interact with Horizon) a requirement?~~ **Decided:** Yes, proactive seeding for all ~180 service providers. -4. ~~If proactive seeding is needed, what's the best source for the address list?~~ **Decided:** Query old subgraph or protocol team records. +4. ~~If proactive seeding is needed, what's the best source for the address list?~~ **Decided:** Query old subgraph via validation scripts. +5. ~~How to handle ~310k delegators?~~ **Decided:** Hybrid approach - seed >= 100 GRT delegations (~23k), lazy-load the rest. + +## Decided Approaches + +| State | Approach | Entities | Contract Calls | +|-------|----------|----------|----------------| +| **Stake** | Proactive seeding | ~181 ServiceProviders | ~181 `getStake()` | +| **Delegation (>= 100 GRT)** | Proactive seeding | ~181 DelegationPools, ~23k Delegations | ~47 multicall batches | +| **Delegation (< 100 GRT)** | Lazy initialization | ~300k (on demand) | On first interaction | +| **Operators** | TBD | TBD | TBD | ## Next Steps -- [ ] Clarify open questions with protocol team -- [ ] Decide on migration approach based on requirements -- [ ] If proactive seeding: obtain and validate address list -- [ ] Document specific implementation for chosen approach +- [x] Analyze delegation distribution (Coinbase Earn impact) +- [x] Decide on hybrid delegation approach +- [x] Create delegation export script +- [ ] Clarify Horizon deployment block number +- [ ] Implement delegation seeding in subgraph +- [ ] Investigate operator migration requirements - [ ] Test migration with known pre-existing participants