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
This commit is contained in:
2026-02-14 02:38:22 +03:00
parent 9922e8ba89
commit 076b54bdc9
8 changed files with 496 additions and 15 deletions
+19
View File
@@ -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
+79
View File
@@ -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:
+29 -2
View File
@@ -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
}
+227 -11
View File
@@ -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<void> {
.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<void> {
): Promise<ValidatorExposureData[]> {
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<void> {
): Promise<ValidatorExposureData[]> {
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<any>).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<void> {
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<string>();
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)`,
);
}
+50
View File
@@ -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<void> {
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();
}
+34 -1
View File
@@ -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<void> {
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<void> {
RewardType.slash,
accumulatedReward.amount,
);
await saveMultiStakingReward(slashEvent, RewardType.slash, STAKING_TYPE_RELAYCHAIN);
}
async function getValidators(era: number): Promise<Set<string>> {
@@ -297,7 +304,7 @@ async function buildRewardEvents<A>(
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<void> {
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();
}
+40 -1
View File
@@ -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<void> {
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<void> {
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();
}
+18
View File
@@ -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;