From 076b54bdc97b79f364b2a541fc984e88f326393d Mon Sep 17 00:00:00 2001 From: Kurdistan Tech Ministry Date: Sat, 14 Feb 2026 02:38:22 +0300 Subject: [PATCH] Add multi-staking entities, APY calculation, Asset Hub indexer support - Add StakingApy, ActiveStaker, Reward entities to schema.graphql - Add APY calculation engine in NewEra.ts (inflation curve + validator commission) - Add saveMultiStakingReward to Rewards.ts and PoolRewards.ts - Add handleAssetTransfer for assets.Transferred events - Add constants.ts with genesis hashes and inflation params - Add docker-compose.prod.yml for production deployment (relay + assethub nodes) - Add deploy-vps.yml GitHub Actions workflow for auto-deploy on push --- .github/workflows/deploy-vps.yml | 19 +++ docker-compose.prod.yml | 79 ++++++++++ schema.graphql | 31 +++- src/mappings/NewEra.ts | 238 +++++++++++++++++++++++++++++-- src/mappings/PoolRewards.ts | 50 +++++++ src/mappings/Rewards.ts | 35 ++++- src/mappings/Transfers.ts | 41 +++++- src/mappings/constants.ts | 18 +++ 8 files changed, 496 insertions(+), 15 deletions(-) create mode 100644 .github/workflows/deploy-vps.yml create mode 100644 docker-compose.prod.yml create mode 100644 src/mappings/constants.ts diff --git a/.github/workflows/deploy-vps.yml b/.github/workflows/deploy-vps.yml new file mode 100644 index 0000000..7b30d70 --- /dev/null +++ b/.github/workflows/deploy-vps.yml @@ -0,0 +1,19 @@ +name: Deploy to VPS +on: + push: + branches: [main] +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - name: Deploy via SSH + uses: appleboy/ssh-action@v1 + with: + host: ${{ secrets.VPS_HOST }} + username: root + key: ${{ secrets.VPS_SSH_KEY }} + script: | + cd /opt/subquery + git pull origin main + docker compose -f docker-compose.prod.yml pull + docker compose -f docker-compose.prod.yml up -d diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..0914500 --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,79 @@ +version: "3.8" +services: + postgres: + container_name: postgres-pezkuwi + image: postgres:16-alpine + ports: + - 5432:5432 + volumes: + - pgdata:/var/lib/postgresql/data + - ./docker/init:/docker-entrypoint-initdb.d + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 5s + timeout: 5s + retries: 10 + environment: + POSTGRES_PASSWORD: pezkuwi_subquery_2024 + restart: always + + subquery-node-relay: + container_name: node-pezkuwi-relay + image: onfinality/subql-node:latest + depends_on: + postgres: { condition: service_healthy } + restart: always + environment: + DB_USER: postgres + DB_PASS: pezkuwi_subquery_2024 + DB_DATABASE: postgres + DB_HOST: postgres + DB_PORT: 5432 + volumes: + - ./:/app/project + command: + - -f=/app/project/pezkuwi.yaml + - --disable-historical=true + - --batch-size=30 + + subquery-node-assethub: + container_name: node-pezkuwi-assethub + image: onfinality/subql-node:latest + depends_on: + postgres: { condition: service_healthy } + restart: always + environment: + DB_USER: postgres + DB_PASS: pezkuwi_subquery_2024 + DB_DATABASE: postgres + DB_HOST: postgres + DB_PORT: 5432 + volumes: + - ./:/app/project + command: + - -f=/app/project/pezkuwi-assethub.yaml + - --disable-historical=true + - --batch-size=30 + + graphql-engine: + container_name: query-pezkuwi + image: onfinality/subql-query:v1.5.0 + ports: + - 3000:3000 + depends_on: + - subquery-node-relay + - subquery-node-assethub + restart: always + environment: + DB_USER: postgres + DB_PASS: pezkuwi_subquery_2024 + DB_DATABASE: postgres + DB_HOST: postgres + DB_PORT: 5432 + command: + - --name=subquery-pezkuwi-staking + - --playground + - --indexer=http://subquery-node-relay:3000 + +volumes: + pgdata: diff --git a/schema.graphql b/schema.graphql index 06e960c..cf6fc6b 100644 --- a/schema.graphql +++ b/schema.graphql @@ -17,7 +17,7 @@ type AssetTransfer @jsonField { success: Boolean! } -type Reward @jsonField { +type RewardInfo @jsonField { eventIdx: Int! amount: String! isReward: Boolean! @@ -97,7 +97,7 @@ type HistoryElement @entity { extrinsicHash: String timestamp: BigInt! @index address: String! @index - reward: Reward + reward: RewardInfo poolReward: PoolReward extrinsic: Extrinsic transfer: Transfer @@ -123,3 +123,30 @@ type ErrorEvent @entity { id: ID! description: String! } + +# ===== Multi-staking API entities (used by PezWallet dashboard) ===== + +type StakingApy @entity { + id: ID! + networkId: String! @index + stakingType: String! @index + maxAPY: Float! +} + +type ActiveStaker @entity { + id: ID! + networkId: String! @index + stakingType: String! @index + address: String! @index +} + +type Reward @entity { + id: ID! + networkId: String! @index + stakingType: String! @index + address: String! @index + type: RewardType! @index + amount: BigInt! + timestamp: BigInt! + blockNumber: Int! @index +} diff --git a/src/mappings/NewEra.ts b/src/mappings/NewEra.ts index a3f2ed2..e51d301 100644 --- a/src/mappings/NewEra.ts +++ b/src/mappings/NewEra.ts @@ -1,9 +1,20 @@ import { SubstrateEvent } from "@subql/types"; import { eventId } from "./common"; -import { EraValidatorInfo } from "../types/models/EraValidatorInfo"; +import { EraValidatorInfo, StakingApy, ActiveStaker } from "../types"; import { IndividualExposure } from "../types"; import { Option } from "@pezkuwi/types"; import { Exposure } from "@pezkuwi/types/interfaces/staking"; +import { + PEZKUWI_RELAY_GENESIS, + PEZKUWI_ASSET_HUB_GENESIS, + STAKING_TYPE_RELAYCHAIN, + STAKING_TYPE_NOMINATION_POOL, + INFLATION_FALLOFF, + INFLATION_MAX, + INFLATION_MIN, + INFLATION_STAKE_TARGET, + PERBILL_DIVISOR, +} from "./constants"; export async function handleStakersElected( event: SubstrateEvent, @@ -16,45 +27,75 @@ export async function handleNewEra(event: SubstrateEvent): Promise { .unwrap() .toNumber(); + let validatorExposures: Array<{ + address: string; + total: bigint; + own: bigint; + others: IndividualExposure[]; + }>; + if (api.query.staking.erasStakersOverview) { - await processEraStakersPaged(event, currentEra); + validatorExposures = await processEraStakersPaged(event, currentEra); } else { - await processEraStakersClipped(event, currentEra); + validatorExposures = await processEraStakersClipped(event, currentEra); } + + // Compute and save APY + active stakers + await updateStakingApyAndActiveStakers(currentEra, validatorExposures); +} + +interface ValidatorExposureData { + address: string; + total: bigint; + own: bigint; + others: IndividualExposure[]; } async function processEraStakersClipped( event: SubstrateEvent, currentEra: number, -): Promise { +): Promise { const exposures = await api.query.staking.erasStakersClipped.entries(currentEra); + const result: ValidatorExposureData[] = []; + for (const [key, exposure] of exposures) { const [, validatorId] = key.args; let validatorIdString = validatorId.toString(); const exp = exposure as unknown as Exposure; + const others = exp.others.map((other) => { + return { + who: other.who.toString(), + value: other.value.toString(), + } as IndividualExposure; + }); + const eraValidatorInfo = new EraValidatorInfo( eventId(event) + validatorIdString, validatorIdString, currentEra, exp.total.toBigInt(), exp.own.toBigInt(), - exp.others.map((other) => { - return { - who: other.who.toString(), - value: other.value.toString(), - } as IndividualExposure; - }), + others, ); await eraValidatorInfo.save(); + + result.push({ + address: validatorIdString, + total: exp.total.toBigInt(), + own: exp.own.toBigInt(), + others, + }); } + + return result; } async function processEraStakersPaged( event: SubstrateEvent, currentEra: number, -): Promise { +): Promise { const overview = await api.query.staking.erasStakersOverview.entries(currentEra); const pages = await api.query.staking.erasStakersPaged.entries(currentEra); @@ -87,6 +128,8 @@ async function processEraStakersPaged( {}, ); + const result: ValidatorExposureData[] = []; + for (const [key, exp] of overview) { const exposure = (exp as Option).unwrap(); const [, validatorId] = key.args; @@ -106,5 +149,178 @@ async function processEraStakersPaged( others, ); await eraValidatorInfo.save(); + + result.push({ + address: validatorIdString, + total: exposure.total.toBigInt(), + own: exposure.own.toBigInt(), + others, + }); + } + + return result; +} + +// ===== APY Calculation (Substrate inflation curve) ===== + +function calculateYearlyInflation(stakedPortion: number): number { + const idealStake = INFLATION_STAKE_TARGET; // No parachains on Pezkuwi + const idealInterest = INFLATION_MAX / idealStake; + + if (stakedPortion >= 0 && stakedPortion <= idealStake) { + return ( + INFLATION_MIN + + stakedPortion * (idealInterest - INFLATION_MIN / idealStake) + ); + } else { + return ( + INFLATION_MIN + + (idealInterest * idealStake - INFLATION_MIN) * + Math.pow(2, (idealStake - stakedPortion) / INFLATION_FALLOFF) + ); } } + +interface ValidatorAPYData { + totalStake: bigint; + commission: number; // 0.0 to 1.0 +} + +function calculateMaxAPY( + totalIssuance: bigint, + validators: ValidatorAPYData[], +): number { + if (validators.length === 0 || totalIssuance === BigInt(0)) return 0; + + const totalStaked = validators.reduce( + (sum, v) => sum + v.totalStake, + BigInt(0), + ); + if (totalStaked === BigInt(0)) return 0; + + // Use scaled division for precision with large BigInts + const SCALE = BigInt(1_000_000_000); + const stakedPortion = + Number((totalStaked * SCALE) / totalIssuance) / Number(SCALE); + + const yearlyInflation = calculateYearlyInflation(stakedPortion); + const averageValidatorRewardPercentage = yearlyInflation / stakedPortion; + const averageValidatorStake = totalStaked / BigInt(validators.length); + + let maxAPY = 0; + for (const v of validators) { + if (v.totalStake === BigInt(0)) continue; + const stakeRatio = + Number((averageValidatorStake * SCALE) / v.totalStake) / Number(SCALE); + const yearlyRewardPercentage = + averageValidatorRewardPercentage * stakeRatio; + const apy = yearlyRewardPercentage * (1 - v.commission); + if (apy > maxAPY) maxAPY = apy; + } + + return maxAPY; +} + +async function updateStakingApyAndActiveStakers( + currentEra: number, + validatorExposures: ValidatorExposureData[], +): Promise { + if (validatorExposures.length === 0) return; + + // 1. Get total issuance from the relay chain + const totalIssuance = ( + (await api.query.balances.totalIssuance()) as any + ).toBigInt(); + + // 2. Get validator commissions + const validatorAddresses = validatorExposures.map((v) => v.address); + const validatorPrefs = await api.query.staking.validators.multi( + validatorAddresses, + ); + + const validatorsWithCommission: ValidatorAPYData[] = + validatorExposures.map((v, i) => { + const prefs = validatorPrefs[i] as any; + const commissionPerbill = prefs.commission + ? Number(prefs.commission.toString()) + : 0; + return { + totalStake: v.total, + commission: commissionPerbill / PERBILL_DIVISOR, + }; + }); + + // 3. Calculate maxAPY + const maxAPY = calculateMaxAPY(totalIssuance, validatorsWithCommission); + + // 4. Save StakingApy for relay chain (relaychain staking) + const relayApyId = `${PEZKUWI_RELAY_GENESIS}-${STAKING_TYPE_RELAYCHAIN}`; + const relayApy = StakingApy.create({ + id: relayApyId, + networkId: PEZKUWI_RELAY_GENESIS, + stakingType: STAKING_TYPE_RELAYCHAIN, + maxAPY, + }); + await relayApy.save(); + + // 5. Save StakingApy for Asset Hub (relaychain staking option) + const ahRelayApyId = `${PEZKUWI_ASSET_HUB_GENESIS}-${STAKING_TYPE_RELAYCHAIN}`; + const ahRelayApy = StakingApy.create({ + id: ahRelayApyId, + networkId: PEZKUWI_ASSET_HUB_GENESIS, + stakingType: STAKING_TYPE_RELAYCHAIN, + maxAPY, + }); + await ahRelayApy.save(); + + // 6. Save StakingApy for Asset Hub (nomination-pool staking option) + const ahPoolApyId = `${PEZKUWI_ASSET_HUB_GENESIS}-${STAKING_TYPE_NOMINATION_POOL}`; + const ahPoolApy = StakingApy.create({ + id: ahPoolApyId, + networkId: PEZKUWI_ASSET_HUB_GENESIS, + stakingType: STAKING_TYPE_NOMINATION_POOL, + maxAPY, + }); + await ahPoolApy.save(); + + logger.info( + `Era ${currentEra}: maxAPY=${(maxAPY * 100).toFixed(2)}% validators=${validatorExposures.length} totalIssuance=${totalIssuance}`, + ); + + // 7. Collect all unique nominator addresses from exposures (active stakers) + const activeNominators = new Set(); + for (const v of validatorExposures) { + for (const nominator of v.others) { + activeNominators.add(nominator.who); + } + } + + // 8. Clear previous active stakers and save new ones + // For relay chain direct staking + for (const address of activeNominators) { + const relayStakerId = `${PEZKUWI_RELAY_GENESIS}-${STAKING_TYPE_RELAYCHAIN}-${address}`; + const staker = ActiveStaker.create({ + id: relayStakerId, + networkId: PEZKUWI_RELAY_GENESIS, + stakingType: STAKING_TYPE_RELAYCHAIN, + address, + }); + await staker.save(); + } + + // Also save validators themselves as active stakers + for (const v of validatorExposures) { + const validatorStakerId = `${PEZKUWI_RELAY_GENESIS}-${STAKING_TYPE_RELAYCHAIN}-${v.address}`; + const staker = ActiveStaker.create({ + id: validatorStakerId, + networkId: PEZKUWI_RELAY_GENESIS, + stakingType: STAKING_TYPE_RELAYCHAIN, + address: v.address, + }); + await staker.save(); + } + + logger.info( + `Era ${currentEra}: saved ${activeNominators.size} active stakers (relay)`, + ); +} diff --git a/src/mappings/PoolRewards.ts b/src/mappings/PoolRewards.ts index 2023227..f5985af 100644 --- a/src/mappings/PoolRewards.ts +++ b/src/mappings/PoolRewards.ts @@ -3,10 +3,12 @@ import { AccumulatedReward, AccumulatedPoolReward, HistoryElement, + Reward, RewardType, } from "../types"; import { SubstrateEvent } from "@subql/types"; import { + eventId, eventIdFromBlockAndIdxAndAddress, timestamp, eventIdWithAddress, @@ -18,6 +20,11 @@ import { } from "./Rewards"; import { getPoolMembers } from "./Cache"; import { Option } from "@pezkuwi/types"; +import { + PEZKUWI_RELAY_GENESIS, + PEZKUWI_ASSET_HUB_GENESIS, + STAKING_TYPE_NOMINATION_POOL, +} from "./constants"; export async function handlePoolReward( rewardEvent: SubstrateEvent, @@ -37,6 +44,13 @@ export async function handlePoolReward( RewardType.reward, accumulatedReward.amount, ); + // Save to multi-staking Reward entity for both relay and Asset Hub networkIds + await savePoolMultiStakingReward( + rewardEvent, + accountId.toString(), + (amount as any).toBigInt(), + RewardType.reward, + ); } async function handlePoolRewardForTxHistory( @@ -200,6 +214,13 @@ async function handleRelaychainPooledStakingSlash( RewardType.slash, accumulatedReward.amount, ); + // Save to multi-staking Reward entity + await savePoolMultiStakingReward( + event, + accountId, + personalSlash, + RewardType.slash, + ); } } } @@ -239,3 +260,32 @@ async function handlePoolSlashForTxHistory( await element.save(); } + +/** + * Save pool reward/slash to the multi-staking Reward entity + * Saves for both relay chain and Asset Hub networkIds since nom pools + * are accessible from both contexts + */ +async function savePoolMultiStakingReward( + event: SubstrateEvent, + accountAddress: string, + amount: bigint, + rewardType: RewardType, +): Promise { + const ts = timestamp(event.block); + const bn = blockNumber(event); + const baseId = `${eventId(event)}-${accountAddress}-pool`; + + // Save for Asset Hub networkId (nomination pools are accessed via Asset Hub) + const ahReward = Reward.create({ + id: `${baseId}-ah`, + networkId: PEZKUWI_ASSET_HUB_GENESIS, + stakingType: STAKING_TYPE_NOMINATION_POOL, + address: accountAddress, + type: rewardType, + amount, + timestamp: ts, + blockNumber: bn, + }); + await ahReward.save(); +} diff --git a/src/mappings/Rewards.ts b/src/mappings/Rewards.ts index 3f2d8d4..f439fe3 100644 --- a/src/mappings/Rewards.ts +++ b/src/mappings/Rewards.ts @@ -3,6 +3,7 @@ import { AccumulatedReward, HistoryElement, Reward, + RewardInfo, RewardType, } from "../types"; import { @@ -26,6 +27,10 @@ import { cachedController, cachedStakingRewardEraIndex, } from "./Cache"; +import { + PEZKUWI_RELAY_GENESIS, + STAKING_TYPE_RELAYCHAIN, +} from "./constants"; function isPayoutStakers(call: any): boolean { return call.method == "payoutStakers"; @@ -74,6 +79,7 @@ export async function handleReward(rewardEvent: SubstrateEvent): Promise { RewardType.reward, accumulatedReward.amount, ); + await saveMultiStakingReward(rewardEvent, RewardType.reward, STAKING_TYPE_RELAYCHAIN); } async function handleRewardForTxHistory( @@ -220,6 +226,7 @@ export async function handleSlash(slashEvent: SubstrateEvent): Promise { RewardType.slash, accumulatedReward.amount, ); + await saveMultiStakingReward(slashEvent, RewardType.slash, STAKING_TYPE_RELAYCHAIN); } async function getValidators(era: number): Promise> { @@ -297,7 +304,7 @@ async function buildRewardEvents( eventIdx: number, stash: string, amount: string, - ) => Reward, + ) => RewardInfo, ) { let blockNum = block.block.header.number.toString(); let blockTimestamp = timestamp(block); @@ -521,3 +528,29 @@ function decodeDataFromReward(event: SubstrateEvent): [any, any] { } return [account, amount]; } + +/** + * Save a reward/slash to the multi-staking Reward entity + * (used by PezWallet dashboard for rewards aggregation) + */ +export async function saveMultiStakingReward( + event: SubstrateEvent, + rewardType: RewardType, + stakingType: string, +): Promise { + const [accountId, amount] = decodeDataFromReward(event); + const accountAddress = accountId.toString(); + const id = `${eventId(event)}-${accountAddress}-${stakingType}`; + + const reward = Reward.create({ + id, + networkId: PEZKUWI_RELAY_GENESIS, + stakingType, + address: accountAddress, + type: rewardType, + amount: (amount as any).toBigInt(), + timestamp: timestamp(event.block), + blockNumber: blockNumber(event), + }); + await reward.save(); +} diff --git a/src/mappings/Transfers.ts b/src/mappings/Transfers.ts index 4fa0652..6c30605 100644 --- a/src/mappings/Transfers.ts +++ b/src/mappings/Transfers.ts @@ -1,4 +1,4 @@ -import { HistoryElement, Transfer } from "../types"; +import { HistoryElement, Transfer, AssetTransfer } from "../types"; import { SubstrateEvent } from "@subql/types"; import { blockNumber, @@ -61,3 +61,42 @@ async function createTransfer( element.transfer = transfer; await element.save(); } + +export async function handleAssetTransfer(event: SubstrateEvent): Promise { + const [assetId, from, to, amount] = getEventData(event); + + const assetTransferData: AssetTransfer = { + assetId: assetId.toString(), + amount: amount.toString(), + from: from.toString(), + to: to.toString(), + fee: calculateFeeAsString(event.extrinsic, from.toString()), + eventIdx: event.idx, + success: true, + }; + + await createAssetTransferHistory(event, from.toString(), "-from", assetTransferData); + await createAssetTransferHistory(event, to.toString(), "-to", assetTransferData); +} + +async function createAssetTransferHistory( + event: SubstrateEvent, + address: string, + suffix: string, + data: AssetTransfer, +): Promise { + const element = new HistoryElement( + `${eventId(event)}${suffix}`, + blockNumber(event), + timestamp(event.block), + address, + ); + + if (event.extrinsic !== undefined) { + element.extrinsicHash = event.extrinsic.extrinsic.hash.toString(); + element.extrinsicIdx = event.extrinsic.idx; + } + + element.assetTransfer = data; + await element.save(); +} diff --git a/src/mappings/constants.ts b/src/mappings/constants.ts new file mode 100644 index 0000000..be9ecef --- /dev/null +++ b/src/mappings/constants.ts @@ -0,0 +1,18 @@ +// Pezkuwi chain genesis hashes (with 0x prefix, as the app expects) +export const PEZKUWI_RELAY_GENESIS = + "0xbb4a61ab0c4b8c12f5eab71d0c86c482e03a275ecdafee678dea712474d33d75"; +export const PEZKUWI_ASSET_HUB_GENESIS = + "0x00d0e1d0581c3cd5c5768652d52f4520184018b44f56a2ae1e0dc9d65c00c948"; + +// Staking type identifiers (must match the app's mapStakingTypeToSubQueryId) +export const STAKING_TYPE_RELAYCHAIN = "relaychain"; +export const STAKING_TYPE_NOMINATION_POOL = "nomination-pool"; + +// Substrate default inflation parameters (Kusama-like, no parachains) +export const INFLATION_FALLOFF = 0.05; +export const INFLATION_MAX = 0.1; +export const INFLATION_MIN = 0.025; +export const INFLATION_STAKE_TARGET = 0.75; + +// Commission is stored in perbill (1_000_000_000 = 100%) +export const PERBILL_DIVISOR = 1_000_000_000;