Skip to content

Commit

Permalink
Calculate the rolling average apy (MystenLabs#9880)
Browse files Browse the repository at this point in the history
## Description 

Describe the changes or additions included in this PR.

- Update the APY calculator using RollingAverageApy of the last 30
epochs
Formuler ` // APY_e = (1 + epoch_rewards / stake)^365-1 //
APY_e_30rollingaverage = average(APY_e,APY_e-1,…,APY_e-29);`

- Query the previous epoch for each validator using
`useGetValidatorsEvents`
-  Moved `useGetValidatorsEvents` to `@mysten/core`
-  Added `useGetRollingAverageApys` to `@mysten/core`
- Update wallet and explorer to use the hook 
- Move event query param to the SDK
`0x3::validator_set::ValidatorEpochInfoEvent`


## Test Plan 

How did you test the new or updated feature?

---
If your changes are not user-facing and not a breaking change, you can
skip the following section. Otherwise, please indicate what changed, and
then add to the Release Notes section as highlighted during the release
process.

### Type of Change (Check all that apply)

- [x] user-visible impact
- [ ] breaking change for a client SDKs
- [ ] breaking change for FNs (FN binary must upgrade)
- [ ] breaking change for validators or node operators (must upgrade
binaries)
- [ ] breaking change for on-chain data layout
- [ ] necessitate either a data wipe or data migration

### Release notes
  • Loading branch information
Jibz-Mysten authored Mar 27, 2023
1 parent f4db061 commit 2e0ef59
Show file tree
Hide file tree
Showing 15 changed files with 201 additions and 125 deletions.
5 changes: 5 additions & 0 deletions .changeset/khaki-starfishes-ring.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@mysten/sui.js": minor
---

Added VALIDATORS_EVENTS_QUERY
106 changes: 106 additions & 0 deletions apps/core/src/hooks/useGetRollingAverageApys.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
// Copyright (c) Mysten Labs, Inc.
// SPDX-License-Identifier: Apache-2.0

import { useMemo } from 'react';
import BigNumber from 'bignumber.js';

import { useGetValidatorsEvents } from './useGetValidatorsEvents';
import { roundFloat } from '../utils/roundFloat';

// recentEpochRewards is list of the last 30 epoch rewards for a specific validator
// APY_e = (1 + epoch_rewards / stake)^365-1
// APY_e_30rollingaverage = average(APY_e,APY_e-1,…,APY_e-29);

const ROLLING_AVERAGE = 30;
const DEFAULT_APY_DECIMALS = 4;

// define the type parsedJson response
type ParsedJson = {
commission_rate: string;
epoch: string;
pool_staking_reward: string;
pool_token_exchange_rate: {
pool_token_amount: string;
sui_amount: string;
};
reference_gas_survey_quote: string;
stake: string;
storage_fund_staking_reward: string;
tallying_rule_global_score: string;
tallying_rule_reporters: string[];
validator_address: string;
};

interface ApyGroups {
[validatorAddress: string]: number[];
}

export interface ApyByValidator {
[validatorAddress: string]: number;
}

const calculateApy = (stake: string, poolStakingReward: string) => {
const poolStakingRewardBigNumber = new BigNumber(poolStakingReward);
const stakeBigNumber = new BigNumber(stake);
// Calculate the ratio of pool_staking_reward / stake
const ratio = poolStakingRewardBigNumber.dividedBy(stakeBigNumber);

// Perform the exponentiation and subtraction using BigNumber
const apy = ratio.plus(1).pow(365).minus(1);
return apy.toNumber();
};

export function useGetRollingAverageApys(numberOfValidators: number | null) {
// Set the limit to the number of validators * the rolling average
// Order the response in descending order so that the most recent epoch are at the top
const validatorEpochEvents = useGetValidatorsEvents({
limit: numberOfValidators ? numberOfValidators * ROLLING_AVERAGE : null,
order: 'descending',
});

const apyByValidator =
useMemo<ApyByValidator | null>(() => {
if (
!validatorEpochEvents?.data ||
!validatorEpochEvents?.data?.data
) {
return null;
}
const apyGroups: ApyGroups = {};
validatorEpochEvents.data.data.forEach(({ parsedJson }) => {
const { stake, pool_staking_reward, validator_address } =
parsedJson as ParsedJson;

if (!apyGroups[validator_address]) {
apyGroups[validator_address] = [];
}
const apyFloat = calculateApy(stake, pool_staking_reward);

// If the APY is greater than 10000% or isNAN, set it to 0
apyGroups[validator_address].push(
Number.isNaN(apyFloat) || apyFloat > 10_000 ? 0 : apyFloat
);
});

const apyByValidator: ApyByValidator = Object.entries(
apyGroups
).reduce((acc, [validatorAddr, apyArr]) => {
const apys = apyArr
.slice(0, ROLLING_AVERAGE)
.map((entry) => entry);

const avgApy =
apys.reduce((sum, apy) => sum + apy, 0) / apys.length;
acc[validatorAddr] = roundFloat(avgApy, DEFAULT_APY_DECIMALS);
return acc;
}, {} as ApyByValidator);
// return object with validator address as key and APY as value
// { '0x123': 0.1234, '0x456': 0.4567 }
return apyByValidator;
}, [validatorEpochEvents.data]) || null;

return {
...validatorEpochEvents,
data: apyByValidator,
};
}
Original file line number Diff line number Diff line change
@@ -1,12 +1,9 @@
// Copyright (c) Mysten Labs, Inc.
// SPDX-License-Identifier: Apache-2.0

import { useRpcClient } from '@mysten/core';
import { type PaginatedEvents, type EventId } from '@mysten/sui.js';
import { useQuery, type UseQueryResult } from '@tanstack/react-query';

export const VALIDATORS_EVENTS_QUERY =
'0x3::validator_set::ValidatorEpochInfoEvent';
import { useRpcClient } from '../api/RpcClientContext';
import { type EventId, VALIDATORS_EVENTS_QUERY } from '@mysten/sui.js';
import { useQuery } from '@tanstack/react-query';

type GetValidatorsEvent = {
cursor?: EventId | null;
Expand All @@ -19,24 +16,19 @@ export function useGetValidatorsEvents({
cursor,
limit,
order,
}: GetValidatorsEvent): UseQueryResult<PaginatedEvents> {
}: GetValidatorsEvent) {
const rpc = useRpcClient();
const eventCursor = cursor || null;
const eventLimit = limit || null;

// since we are getting events base on the number of validators, we need to make sure that the limit is not null and cache by the limit
// number of validators can change from network to network
const response = useQuery(
['validatorEvents', limit],
return useQuery(
['validatorEvents', limit, cursor?.txDigest, order],
() =>
rpc.queryEvents({
query: { MoveEventType: VALIDATORS_EVENTS_QUERY },
cursor: eventCursor?.txDigest,
limit: eventLimit,
cursor: cursor?.txDigest,
limit,
order,
}),
{ enabled: !!limit }
);

return response;
}
3 changes: 2 additions & 1 deletion apps/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export * from './api/SentryRpcClient';
export * from './api/RpcClientContext';
export * from './hooks/useFormatCoin';
export * from './hooks/useTimeAgo';
export * from './hooks/useGetValidatorsEvents';
export * from './hooks/useGetRollingAverageApys';
export * from './utils/formatAmount';
export * from './utils/calculateAPY';
export * from './utils/roundFloat';
41 changes: 0 additions & 41 deletions apps/core/src/utils/calculateAPY.ts

This file was deleted.

9 changes: 2 additions & 7 deletions apps/explorer/src/components/validator/ValidatorStats.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
// Copyright (c) Mysten Labs, Inc.
// SPDX-License-Identifier: Apache-2.0

import { calculateAPY } from '@mysten/core';
import { type SuiValidatorSummary } from '@mysten/sui.js';
import { useMemo } from 'react';

import { DelegationAmount } from './DelegationAmount';

Expand All @@ -15,12 +13,13 @@ type StatsCardProps = {
validatorData: SuiValidatorSummary;
epoch: number | string;
epochRewards: number;
apy: number;
};

export function ValidatorStats({
validatorData,
epoch,
epochRewards,
apy,
}: StatsCardProps) {
// TODO: add missing fields
// const numberOfDelegators = 0;
Expand All @@ -29,10 +28,6 @@ export function ValidatorStats({
// const tallyingScore = 0;
// const lastNarwhalRound = 0;

const apy = useMemo(
() => calculateAPY(validatorData, +epoch),
[validatorData, epoch]
);
const totalStake = +validatorData.stakingPoolSuiBalance;
const commission = +validatorData.commissionRate / 100;
const rewardsPoolBalance = +validatorData.rewardsPool;
Expand Down
11 changes: 9 additions & 2 deletions apps/explorer/src/pages/epochs/EpochDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// SPDX-License-Identifier: Apache-2.0

import { useFeature, useGrowthBook } from '@growthbook/growthbook-react';
import { useGetValidatorsEvents, useGetRollingAverageApys } from '@mysten/core';
import { Navigate } from 'react-router-dom';

import { validatorsTableData } from '../validators/Validators';
Expand All @@ -10,7 +11,6 @@ import { EpochStats } from './stats/EpochStats';

import { SuiAmount } from '~/components/transactions/TxCardUtils';
import { useGetSystemObject } from '~/hooks/useGetObject';
import { useGetValidatorsEvents } from '~/hooks/useGetValidatorsEvents';
import { EpochProgress } from '~/pages/epochs/stats/EpochProgress';
import { Banner } from '~/ui/Banner';
import { Card } from '~/ui/Card';
Expand All @@ -26,6 +26,9 @@ function EpochDetail() {
getMockEpochData();

const { data, isError, isLoading } = useGetSystemObject();
const { data: rollingAverageApys } = useGetRollingAverageApys(
data?.activeValidators.length || null
);

const { data: validatorEvents, isLoading: validatorsEventsLoading } =
useGetValidatorsEvents({
Expand All @@ -43,7 +46,11 @@ function EpochDetail() {
if (isLoading || validatorsEventsLoading) return <LoadingSpinner />;
if (!data || !validatorEvents) return null;

const validatorsTable = validatorsTableData(data, validatorEvents.data);
const validatorsTable = validatorsTableData(
data,
validatorEvents.data,
rollingAverageApys
);

return (
<div className="flex flex-col space-y-16">
Expand Down
10 changes: 7 additions & 3 deletions apps/explorer/src/pages/validator/ValidatorDetails.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
// Copyright (c) Mysten Labs, Inc.
// SPDX-License-Identifier: Apache-2.0

import { useGetRollingAverageApys, useGetValidatorsEvents } from '@mysten/core';
import { useMemo } from 'react';
import { useParams } from 'react-router-dom';

import { ValidatorMeta } from '~/components/validator/ValidatorMeta';
import { ValidatorStats } from '~/components/validator/ValidatorStats';
import { useGetSystemObject } from '~/hooks/useGetObject';
import { useGetValidatorsEvents } from '~/hooks/useGetValidatorsEvents';
import { Banner } from '~/ui/Banner';
import { LoadingSpinner } from '~/ui/LoadingSpinner';
import { getValidatorMoveEvent } from '~/utils/getValidatorMoveEvent';
Expand All @@ -22,6 +22,8 @@ function ValidatorDetails() {
}, [id, data]);

const numberOfValidators = data?.activeValidators.length ?? null;
const { data: rollingAverageApys, isLoading: validatorsApysLoading } =
useGetRollingAverageApys(numberOfValidators);

const { data: validatorEvents, isLoading: validatorsEventsLoading } =
useGetValidatorsEvents({
Expand All @@ -38,15 +40,15 @@ function ValidatorDetails() {
return +rewards || 0;
}, [id, validatorEvents]);

if (isLoading || validatorsEventsLoading) {
if (isLoading || validatorsEventsLoading || validatorsApysLoading) {
return (
<div className="mb-10 flex items-center justify-center">
<LoadingSpinner />
</div>
);
}

if (!validatorData || !data || !validatorEvents) {
if (!validatorData || !data || !validatorEvents || !id) {
return (
<div className="mb-10 flex items-center justify-center">
<Banner variant="error" spacing="lg" fullWidth>
Expand All @@ -56,6 +58,7 @@ function ValidatorDetails() {
);
}

const apy = rollingAverageApys?.[id] || 0;
return (
<div className="mb-10">
<div className="flex flex-col flex-nowrap gap-5 md:flex-row md:gap-0">
Expand All @@ -66,6 +69,7 @@ function ValidatorDetails() {
validatorData={validatorData}
epoch={data.epoch}
epochRewards={validatorRewards}
apy={apy}
/>
</div>
</div>
Expand Down
Loading

0 comments on commit 2e0ef59

Please sign in to comment.