diff --git a/pezkuwi-assethub.yaml b/pezkuwi-assethub.yaml index 1563ebb..69f8371 100644 --- a/pezkuwi-assethub.yaml +++ b/pezkuwi-assethub.yaml @@ -61,6 +61,18 @@ dataSources: filter: module: nominationPools method: UnbondingPoolSlashed + # Era changes on AH (staking pallet lives here) - old format + - handler: handleAHNewEra + kind: substrate/EventHandler + filter: + module: staking + method: StakingElection + # Era changes on AH - new format (Polkadot 2.0) + - handler: handleAHStakersElected + kind: substrate/EventHandler + filter: + module: staking + method: StakersElected # Native transfers - handler: handleTransfer kind: substrate/EventHandler diff --git a/src/mappings/NewEra.ts b/src/mappings/NewEra.ts index bc152c7..3ed7988 100644 --- a/src/mappings/NewEra.ts +++ b/src/mappings/NewEra.ts @@ -18,55 +18,6 @@ import { let relayStakersInitialized = false; -/** - * Derive pool bonded (stash) account for a given pool ID. - * Matches Substrate: "modl" + PalletId("py/nopls") + AccountType::Bonded(0) + poolId(LE u32) - * padded to 32 bytes. - */ -function derivePoolStash(poolId: number): string { - const buf = new Uint8Array(32); - // "modl" prefix (4 bytes) - buf[0] = 0x6d; buf[1] = 0x6f; buf[2] = 0x64; buf[3] = 0x6c; - // PalletId: "py/nopls" (8 bytes) - const palletId = [0x70, 0x79, 0x2f, 0x6e, 0x6f, 0x70, 0x6c, 0x73]; - for (let i = 0; i < 8; i++) buf[4 + i] = palletId[i]; - // AccountType::Bonded = 0 - buf[12] = 0; - // Pool ID as u32 LE - buf[13] = poolId & 0xff; - buf[14] = (poolId >> 8) & 0xff; - buf[15] = (poolId >> 16) & 0xff; - buf[16] = (poolId >> 24) & 0xff; - let hex = "0x"; - for (let i = 0; i < 32; i++) hex += buf[i].toString(16).padStart(2, "0"); - return api.registry.createType("AccountId", hex).toString(); -} - -/** - * Save pool stash accounts that appear as active nominators on relay chain - * as AH active stakers (networkId=AH, stakingType=relaychain). - * The wallet queries these to determine ACTIVE status for nomination pools. - */ -async function savePoolStashActiveStakers(activeNominators: Set): Promise { - const MAX_POOLS = 100; - let count = 0; - for (let poolId = 1; poolId <= MAX_POOLS; poolId++) { - const stash = derivePoolStash(poolId); - if (activeNominators.has(stash)) { - const stakerId = `${PEZKUWI_ASSET_HUB_GENESIS}-${STAKING_TYPE_RELAYCHAIN}-${stash}`; - const staker = ActiveStaker.create({ - id: stakerId, - networkId: PEZKUWI_ASSET_HUB_GENESIS, - stakingType: STAKING_TYPE_RELAYCHAIN, - address: stash, - }); - await staker.save(); - count++; - } - } - return count; -} - /** * Block handler: on the FIRST block processed, query the live chain state * for all current era's elected nominators and validators, then save them @@ -156,11 +107,8 @@ export async function handleRelayBlock(block: SubstrateBlock): Promise { await staker.save(); } - // Save pool stash accounts as AH active stakers - const poolCount = await savePoolStashActiveStakers(activeNominators); - logger.info( - `Initialized ${activeValidators.size} validators + ${activeNominators.size} nominators as active relay stakers, ${poolCount} pool stash accounts as AH stakers`, + `Initialized ${activeValidators.size} validators + ${activeNominators.size} nominators as active relay stakers`, ); } @@ -470,10 +418,7 @@ async function updateStakingApyAndActiveStakers( await staker.save(); } - // Save pool stash accounts as AH active stakers - const poolCount = await savePoolStashActiveStakers(activeNominators); - logger.info( - `Era ${currentEra}: saved ${activeNominators.size} active stakers (relay), ${poolCount} pool stash accounts as AH stakers`, + `Era ${currentEra}: saved ${activeNominators.size} active stakers (relay)`, ); } diff --git a/src/mappings/PoolStakers.ts b/src/mappings/PoolStakers.ts index e3de53f..ab56272 100644 --- a/src/mappings/PoolStakers.ts +++ b/src/mappings/PoolStakers.ts @@ -1,9 +1,15 @@ import { SubstrateEvent, SubstrateBlock } from "@subql/types"; -import { ActiveStaker } from "../types"; +import { ActiveStaker, StakingApy } from "../types"; import { Option } from "@pezkuwi/types"; import { 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 poolStakersInitialized = false; @@ -81,6 +87,9 @@ export async function handleBlock(block: SubstrateBlock): Promise { } logger.info(`Initialized ${count} pool stash accounts as active stakers`); + + // Also compute and save APY on first block + await computeAndSaveAPY(); } /** @@ -144,3 +153,88 @@ export async function handlePoolUnbonded( logger.info(`Pool ${poolId} stash removed: ${stashAddress}`); } } + +// ===== APY Computation for Asset Hub ===== + +function calculateYearlyInflation(stakedPortion: number): number { + const idealStake = INFLATION_STAKE_TARGET; + 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); + } +} + +async function computeAndSaveAPY(): Promise { + const totalIssuance = ((await api.query.balances.totalIssuance()) as any).toBigInt(); + if (totalIssuance === BigInt(0)) return; + + const activeEraOpt = (await api.query.staking.activeEra()) as Option; + if (activeEraOpt.isNone) return; + const currentEra = activeEraOpt.unwrap().index.toNumber(); + + // Get all validator exposures for current era + const overviews = await api.query.staking.erasStakersOverview.entries(currentEra); + let totalStaked = BigInt(0); + const validators: { totalStake: bigint; commission: number }[] = []; + const validatorAddresses: string[] = []; + + for (const [key, exp] of overviews) { + const [, validatorId] = key.args; + const exposure = (exp as Option).unwrap(); + const total = exposure.total.toBigInt(); + totalStaked += total; + validatorAddresses.push(validatorId.toString()); + validators.push({ totalStake: total, commission: 0 }); + } + + if (validators.length === 0 || totalStaked === BigInt(0)) return; + + // Get commissions + const prefs = await api.query.staking.validators.multi(validatorAddresses); + for (let i = 0; i < prefs.length; i++) { + const p = prefs[i] as any; + validators[i].commission = p.commission ? Number(p.commission.toString()) / PERBILL_DIVISOR : 0; + } + + // Calculate APY + const SCALE = BigInt(1_000_000_000); + const stakedPortion = Number((totalStaked * SCALE) / totalIssuance) / Number(SCALE); + const yearlyInflation = calculateYearlyInflation(stakedPortion); + const avgRewardPct = yearlyInflation / stakedPortion; + const avgStake = totalStaked / BigInt(validators.length); + + let maxAPY = 0; + for (const v of validators) { + if (v.totalStake === BigInt(0)) continue; + const stakeRatio = Number((avgStake * SCALE) / v.totalStake) / Number(SCALE); + const apy = avgRewardPct * stakeRatio * (1 - v.commission); + if (apy > maxAPY) maxAPY = apy; + } + + // Save APY for AH relaychain staking + const ahRelayApyId = `${PEZKUWI_ASSET_HUB_GENESIS}-${STAKING_TYPE_RELAYCHAIN}`; + await StakingApy.create({ id: ahRelayApyId, networkId: PEZKUWI_ASSET_HUB_GENESIS, stakingType: STAKING_TYPE_RELAYCHAIN, maxAPY }).save(); + + // Save APY for AH nomination-pool staking + const ahPoolApyId = `${PEZKUWI_ASSET_HUB_GENESIS}-${STAKING_TYPE_NOMINATION_POOL}`; + await StakingApy.create({ id: ahPoolApyId, networkId: PEZKUWI_ASSET_HUB_GENESIS, stakingType: STAKING_TYPE_NOMINATION_POOL, maxAPY }).save(); + + logger.info(`AH APY: ${(maxAPY * 100).toFixed(2)}% from ${validators.length} validators, era ${currentEra}`); +} + +/** + * Handle staking.StakersElected on Asset Hub - recompute APY each era + */ +export async function handleAHStakersElected(event: SubstrateEvent): Promise { + await computeAndSaveAPY(); +} + +/** + * Handle staking.StakingElection on Asset Hub (old format) + */ +export async function handleAHNewEra(event: SubstrateEvent): Promise { + await computeAndSaveAPY(); +}