Refactor: remove dead relay staking code, add AH reward handlers

- Delete NewEra.ts (all relay staking code was dead)
- Refactor Rewards.ts: remove relay/parachain handlers, add handleAHRewarded/handleAHSlashed for Asset Hub staking rewards
- Add staking::Rewarded and staking::Slashed handlers to pezkuwi-assethub.yaml
- Remove dead cachedStakingRewardEraIndex from Cache.ts
- Clean unused PEZKUWI_RELAY_GENESIS import from PoolRewards.ts
This commit is contained in:
2026-02-21 14:23:58 +03:00
parent fc5d101b75
commit 9259b68a3d
6 changed files with 40 additions and 582 deletions
+12
View File
@@ -61,6 +61,18 @@ dataSources:
filter:
module: nominationPools
method: UnbondingPoolSlashed
# Staking rewards on AH (validators + direct nominators)
- handler: handleAHRewarded
kind: substrate/EventHandler
filter:
module: staking
method: Rewarded
# Staking slashes on AH
- handler: handleAHSlashed
kind: substrate/EventHandler
filter:
module: staking
method: Slashed
# Era changes on AH (staking pallet lives here) - old format
- handler: handleAHNewEra
kind: substrate/EventHandler
-1
View File
@@ -3,7 +3,6 @@ export * from "./mappings/HistoryElements";
export * from "./mappings/Rewards";
export * from "./mappings/PoolRewards";
export * from "./mappings/Transfers";
export * from "./mappings/NewEra";
export * from "./mappings/PoolStakers";
export * from "./mappings/Governance";
import "@pezkuwi/api-augment";
-25
View File
@@ -10,8 +10,6 @@ let rewardDestinationByAddress: {
let controllersByStash: { [blockId: string]: { [address: string]: string } } =
{};
let parachainStakingRewardEra: { [blockId: string]: number } = {};
let poolMembers: {
[blockId: number]: [string, any][];
} = {};
@@ -183,29 +181,6 @@ export async function cachedController(
}
}
export async function cachedStakingRewardEraIndex(
event: SubstrateEvent,
): Promise<number> {
const blockId = blockNumber(event);
let cachedEra = parachainStakingRewardEra[blockId];
if (cachedEra !== undefined) {
return cachedEra;
} else {
const era = await api.query.parachainStaking.round();
const paymentDelay =
api.consts.parachainStaking.rewardPaymentDelay.toHuman();
// HACK: used to get data from object
const eraIndex =
(era.toJSON() as { current: number }).current - Number(paymentDelay);
parachainStakingRewardEra = {};
parachainStakingRewardEra[blockId] = eraIndex;
return eraIndex;
}
}
export async function getPoolMembers(
blockId: number,
): Promise<[string, any][]> {
-452
View File
@@ -1,452 +0,0 @@
import { SubstrateEvent, SubstrateBlock } from "@subql/types";
import { eventId } from "./common";
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";
let relayStakersInitialized = false;
/**
* Block handler: on the FIRST block processed, query the live chain state
* for all current era's elected nominators and validators, then save them
* as ActiveStakers. This ensures existing stakers are captured even if
* StakersElected events were missed or had parsing issues.
*/
export async function handleRelayBlock(block: SubstrateBlock): Promise<void> {
if (relayStakersInitialized) return;
relayStakersInitialized = true;
logger.info("Initializing active relay stakers from live chain state...");
// Safety: staking pallet was removed from relay chain in spec 1_020_006
if (!api.query.staking || !api.query.staking.activeEra) {
logger.info(
"Staking pallet not available on relay chain - skipping relay staker init",
);
return;
}
let activeEraOpt: Option<any>;
try {
activeEraOpt = (await api.query.staking.activeEra()) as Option<any>;
} catch (e) {
logger.warn(`Failed to query staking.activeEra on relay: ${e}`);
return;
}
if (activeEraOpt.isNone) {
logger.info("No active era found on relay chain");
return;
}
const currentEra = activeEraOpt.unwrap().index.toNumber();
logger.info(`Current active era: ${currentEra}`);
const activeNominators = new Set<string>();
const activeValidators = new Set<string>();
// Read all validators from overview (includes validators with only self-stake)
const overviews =
await api.query.staking.erasStakersOverview.entries(currentEra);
for (const [key, ov] of overviews) {
const [, validatorId] = key.args;
activeValidators.add(validatorId.toString());
}
// Read all paged exposure entries for current era (contains nominators)
const pages = await api.query.staking.erasStakersPaged.entries(currentEra);
for (const [key, exp] of pages) {
const [, validatorId] = key.args;
activeValidators.add(validatorId.toString());
let exposure: any;
try {
const asOpt = exp as Option<any>;
if (asOpt.isNone) continue;
exposure = asOpt.unwrap();
} catch {
exposure = exp as any;
}
if (exposure.others) {
for (const other of exposure.others) {
activeNominators.add(other.who.toString());
}
}
}
// Fallback: if overview had no results, try legacy erasStakersClipped
if (activeValidators.size === 0) {
const clipped =
await api.query.staking.erasStakersClipped.entries(currentEra);
for (const [key, exposure] of clipped) {
const [, validatorId] = key.args;
activeValidators.add(validatorId.toString());
const exp = exposure as unknown as Exposure;
for (const other of exp.others) {
activeNominators.add(other.who.toString());
}
}
}
// Save validators as active stakers
for (const address of activeValidators) {
const stakerId = `${PEZKUWI_RELAY_GENESIS}-${STAKING_TYPE_RELAYCHAIN}-${address}`;
const staker = ActiveStaker.create({
id: stakerId,
networkId: PEZKUWI_RELAY_GENESIS,
stakingType: STAKING_TYPE_RELAYCHAIN,
address,
});
await staker.save();
}
// Save nominators as active stakers
for (const address of activeNominators) {
const stakerId = `${PEZKUWI_RELAY_GENESIS}-${STAKING_TYPE_RELAYCHAIN}-${address}`;
const staker = ActiveStaker.create({
id: stakerId,
networkId: PEZKUWI_RELAY_GENESIS,
stakingType: STAKING_TYPE_RELAYCHAIN,
address,
});
await staker.save();
}
logger.info(
`Initialized ${activeValidators.size} validators + ${activeNominators.size} nominators as active relay stakers`,
);
}
export async function handleStakersElected(
event: SubstrateEvent,
): Promise<void> {
await handleNewEra(event);
}
export async function handleNewEra(event: SubstrateEvent): Promise<void> {
// Safety: staking pallet was removed from relay chain in spec 1_020_006
if (!api.query.staking || !api.query.staking.currentEra) {
logger.warn("Staking pallet not available - skipping handleNewEra");
return;
}
let currentEra: number;
try {
currentEra = ((await api.query.staking.currentEra()) as Option<any>)
.unwrap()
.toNumber();
} catch (e) {
logger.warn(`Failed to query staking.currentEra: ${e}`);
return;
}
let validatorExposures: Array<{
address: string;
total: bigint;
own: bigint;
others: IndividualExposure[];
}>;
if (api.query.staking.erasStakersOverview) {
validatorExposures = await processEraStakersPaged(event, currentEra);
} else {
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<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(),
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<ValidatorExposureData[]> {
const overview =
await api.query.staking.erasStakersOverview.entries(currentEra);
const pages = await api.query.staking.erasStakersPaged.entries(currentEra);
interface AccumulatorType {
[key: string]: { [page: number]: IndividualExposure[] };
}
const othersCounted = pages.reduce(
(accumulator: AccumulatorType, [key, exp]) => {
const exposure = (exp as Option<any>).unwrap();
const [, validatorId, pageId] = key.args;
const pageNumber = (pageId as any).toNumber();
const validatorIdString = validatorId.toString();
const others: IndividualExposure[] = exposure.others.map(
({ who, value }: any) => {
return {
who: who.toString(),
value: value.toString(),
} as IndividualExposure;
},
);
(accumulator[validatorIdString] = accumulator[validatorIdString] || {})[
pageNumber
] = others;
return accumulator;
},
{},
);
const result: ValidatorExposureData[] = [];
for (const [key, exp] of overview) {
const exposure = (exp as Option<any>).unwrap();
const [, validatorId] = key.args;
let validatorIdString = validatorId.toString();
let others: IndividualExposure[] = [];
for (let i = 0; i < exposure.pageCount.toNumber(); ++i) {
others.push(...othersCounted[validatorIdString][i]);
}
const eraValidatorInfo = new EraValidatorInfo(
eventId(event) + validatorIdString,
validatorIdString,
currentEra,
exposure.total.toBigInt(),
exposure.own.toBigInt(),
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)`,
);
}
-1
View File
@@ -21,7 +21,6 @@ import {
import { getPoolMembers } from "./Cache";
import { Option } from "@pezkuwi/types";
import {
PEZKUWI_RELAY_GENESIS,
PEZKUWI_ASSET_HUB_GENESIS,
STAKING_TYPE_NOMINATION_POOL,
} from "./constants";
+28 -103
View File
@@ -22,12 +22,11 @@ import {
callFromProxy,
blockNumber,
} from "./common";
import { cachedRewardDestination, cachedController } from "./Cache";
import {
cachedRewardDestination,
cachedController,
cachedStakingRewardEraIndex,
} from "./Cache";
import { PEZKUWI_RELAY_GENESIS, STAKING_TYPE_RELAYCHAIN } from "./constants";
PEZKUWI_ASSET_HUB_GENESIS,
STAKING_TYPE_RELAYCHAIN,
} from "./constants";
function isPayoutStakers(call: any): boolean {
return call.method == "payoutStakers";
@@ -43,13 +42,11 @@ function isPayoutValidator(call: any): boolean {
function extractArgsFromPayoutStakers(call: any): [string, number] {
const [validatorAddressRaw, eraRaw] = call.args;
return [validatorAddressRaw.toString(), (eraRaw as any).toNumber()];
}
function extractArgsFromPayoutStakersByPage(call: any): [string, number] {
const [validatorAddressRaw, eraRaw, _] = call.args;
return [validatorAddressRaw.toString(), (eraRaw as any).toNumber()];
}
@@ -58,17 +55,15 @@ function extractArgsFromPayoutValidator(
sender: string,
): [string, number] {
const [eraRaw] = call.args;
return [sender, (eraRaw as any).toNumber()];
}
export async function handleRewarded(
/**
* Handle staking::Rewarded on Asset Hub
*/
export async function handleAHRewarded(
rewardEvent: SubstrateEvent,
): Promise<void> {
await handleReward(rewardEvent);
}
export async function handleReward(rewardEvent: SubstrateEvent): Promise<void> {
await handleRewardForTxHistory(rewardEvent);
let accumulatedReward = await updateAccumulatedReward(rewardEvent, true);
await updateAccountRewards(
@@ -76,11 +71,23 @@ export async function handleReward(rewardEvent: SubstrateEvent): Promise<void> {
RewardType.reward,
accumulatedReward.amount,
);
await saveMultiStakingReward(
rewardEvent,
RewardType.reward,
STAKING_TYPE_RELAYCHAIN,
await saveMultiStakingReward(rewardEvent, RewardType.reward);
}
/**
* Handle staking::Slashed on Asset Hub
*/
export async function handleAHSlashed(
slashEvent: SubstrateEvent,
): Promise<void> {
await handleSlashForTxHistory(slashEvent);
let accumulatedReward = await updateAccumulatedReward(slashEvent, false);
await updateAccountRewards(
slashEvent,
RewardType.slash,
accumulatedReward.amount,
);
await saveMultiStakingReward(slashEvent, RewardType.slash);
}
async function handleRewardForTxHistory(
@@ -89,7 +96,6 @@ async function handleRewardForTxHistory(
let element = await HistoryElement.get(eventId(rewardEvent));
if (element !== undefined) {
// already processed reward previously
return;
}
@@ -215,32 +221,12 @@ function determinePayoutCallsArgs(
}
}
export async function handleSlashed(slashEvent: SubstrateEvent): Promise<void> {
await handleSlash(slashEvent);
}
export async function handleSlash(slashEvent: SubstrateEvent): Promise<void> {
await handleSlashForTxHistory(slashEvent);
let accumulatedReward = await updateAccumulatedReward(slashEvent, false);
await updateAccountRewards(
slashEvent,
RewardType.slash,
accumulatedReward.amount,
);
await saveMultiStakingReward(
slashEvent,
RewardType.slash,
STAKING_TYPE_RELAYCHAIN,
);
}
async function getValidators(era: number): Promise<Set<string>> {
const eraStakersInSlashEra = await (api.query.staking.erasStakersClipped
? api.query.staking.erasStakersClipped.keys(era)
: api.query.staking.erasStakersOverview.keys(era));
const validatorsInSlashEra = eraStakersInSlashEra.map((key: any) => {
let [, validatorId] = key.args;
return validatorId.toString();
});
return new Set(validatorsInSlashEra);
@@ -252,7 +238,6 @@ async function handleSlashForTxHistory(
let element = await HistoryElement.get(eventId(slashEvent));
if (element !== undefined) {
// already processed reward previously
return;
}
const eraWrapped = await api.query.staking.currentEra();
@@ -396,48 +381,6 @@ async function updateAccountRewards(
await accountReward.save();
}
async function handleParachainRewardForTxHistory(
rewardEvent: SubstrateEvent,
): Promise<void> {
let [account, amount] = decodeDataFromReward(rewardEvent);
handleGenericForTxHistory(
rewardEvent,
account.toString(),
async (element: HistoryElement) => {
const eraIndex = await cachedStakingRewardEraIndex(rewardEvent);
const validatorEvent = rewardEvent.block.events.find(
(event) =>
event.event.section == rewardEvent.event.section &&
event.event.method == rewardEvent.event.method,
);
const validatorId = validatorEvent?.event.data[0].toString();
element.reward = {
eventIdx: rewardEvent.idx,
amount: amount.toString(),
isReward: true,
stash: account.toString(),
validator: validatorId,
era: eraIndex,
};
return element;
},
);
}
export async function handleParachainRewarded(
rewardEvent: SubstrateEvent,
): Promise<void> {
await handleParachainRewardForTxHistory(rewardEvent);
let accumulatedReward = await updateAccumulatedReward(rewardEvent, true);
await updateAccountRewards(
rewardEvent,
RewardType.reward,
accumulatedReward.amount,
);
}
// ============= GENERICS ================
interface AccumulatedInterface {
@@ -500,23 +443,6 @@ export async function handleGenericForTxHistory(
(await fieldCallback(element)).save();
}
interface AccountRewardsInterface {
id: string;
address: string;
blockNumber: number;
timestamp: bigint;
amount: bigint;
accumulatedAmount: bigint;
type: RewardType;
save(): Promise<void>;
}
export function eventRecordToSubstrateEvent(eventRecord: any): SubstrateEvent {
return eventRecord as unknown as SubstrateEvent;
}
@@ -538,19 +464,18 @@ function decodeDataFromReward(event: SubstrateEvent): [any, any] {
* Save a reward/slash to the multi-staking Reward entity
* (used by PezWallet dashboard for rewards aggregation)
*/
export async function saveMultiStakingReward(
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 id = `${eventId(event)}-${accountAddress}-${STAKING_TYPE_RELAYCHAIN}`;
const reward = Reward.create({
id,
networkId: PEZKUWI_RELAY_GENESIS,
stakingType,
networkId: PEZKUWI_ASSET_HUB_GENESIS,
stakingType: STAKING_TYPE_RELAYCHAIN,
address: accountAddress,
type: rewardType,
amount: (amount as any).toBigInt(),