Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions apps/staking/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,20 +26,21 @@
"@bonfida/spl-name-service": "^3.0.0",
"@heroicons/react": "^2.1.4",
"@next/third-parties": "^14.2.5",
"@pythnetwork/hermes-client": "workspace:*",
"@pythnetwork/staking-sdk": "workspace:*",
"@solana/wallet-adapter-base": "^0.9.20",
"@solana/wallet-adapter-react": "^0.15.28",
"@solana/wallet-adapter-react-ui": "^0.9.27",
"@solana/wallet-adapter-react": "^0.15.28",
"@solana/wallet-adapter-wallets": "0.19.10",
"@solana/web3.js": "^1.95.2",
"clsx": "^2.1.1",
"dnum": "^2.13.1",
"next": "^14.2.5",
"pino": "^9.3.2",
"react": "^18.3.1",
"react-aria": "^3.34.3",
"react-aria-components": "^1.3.3",
"react-aria": "^3.34.3",
"react-dom": "^18.3.1",
"react": "^18.3.1",
"recharts": "^2.12.7",
"swr": "^2.2.5"
},
Expand Down
90 changes: 71 additions & 19 deletions apps/staking/src/api.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
// TODO remove these disables when moving off the mock APIs
/* eslint-disable @typescript-eslint/no-unused-vars, @typescript-eslint/require-await */

import type { HermesClient } from "@pythnetwork/hermes-client";
import {
epochToDate,
extractPublisherData,
getAmountByTargetAndState,
getCurrentEpoch,
PositionState,
Expand All @@ -10,6 +13,13 @@ import {
} from "@pythnetwork/staking-sdk";
import { PublicKey } from "@solana/web3.js";

type PublisherRankings = {
publisher: string;
rank: number;
numSymbols: number;
timestamp: string;
}[];

type Data = {
total: bigint;
availableRewards: bigint;
Expand Down Expand Up @@ -46,6 +56,7 @@ type Data = {
poolUtilization: bigint;
numFeeds: number;
qualityRanking: number;
lastApy: number;
apyHistory: { date: Date; apy: number }[];
positions?:
| {
Expand Down Expand Up @@ -144,22 +155,29 @@ export const getStakeAccounts = async (

export const loadData = async (
client: PythStakingClient,
hermesClient: HermesClient,
stakeAccount: StakeAccountPositions,
): Promise<Data> => {
const [
stakeAccountCustody,
publishers,
poolData,
ownerAtaAccount,
currentEpoch,
unlockSchedule,
] = await Promise.all([
client.getStakeAccountCustody(stakeAccount.address),
client.getPublishers(),
client.getPoolDataAccount(),
client.getOwnerPythAtaAccount(),
getCurrentEpoch(client.connection),
client.getUnlockSchedule(stakeAccount.address),
]);

const publishers = extractPublisherData(poolData);

const publisherRankingsResponse = await fetch("/api/publishers-ranking");
const publisherRankings =
(await publisherRankingsResponse.json()) as PublisherRankings;

const filterGovernancePositions = (positionState: PositionState) =>
getAmountByTargetAndState({
stakeAccountPositions: stakeAccount,
Expand All @@ -179,6 +197,17 @@ export const loadData = async (
epoch: currentEpoch,
});

const publisherCaps = await hermesClient.getLatestPublisherCaps({
parsed: true,
});

const getPublisherCap = (publisher: PublicKey) =>
BigInt(
publisherCaps.parsed?.[0]?.publisher_stake_caps.find(
({ publisher: p }) => p === publisher.toBase58(),
)?.cap ?? 0,
);

return {
lastSlash: undefined, // TODO
availableRewards: 0n, // TODO
Expand All @@ -193,23 +222,46 @@ export const loadData = async (
unlockSchedule,
locked: unlockSchedule.reduce((sum, { amount }) => sum + amount, 0n),
walletAmount: ownerAtaAccount.amount,
integrityStakingPublishers: publishers.map(({ pubkey: publisher }) => ({
apyHistory: [], // TODO
isSelf: false, // TODO
name: undefined, // TODO
numFeeds: 0, // TODO
poolCapacity: 100n, // TODO
poolUtilization: 0n, // TODO
publicKey: publisher,
qualityRanking: 0, // TODO
selfStake: 0n, // TODO
positions: {
warmup: filterOISPositions(publisher, PositionState.LOCKING),
staked: filterOISPositions(publisher, PositionState.LOCKED),
cooldown: filterOISPositions(publisher, PositionState.PREUNLOCKING),
cooldown2: filterOISPositions(publisher, PositionState.UNLOCKED),
},
})),
integrityStakingPublishers: publishers.map((publisherData) => {
const publisherRanking = publisherRankings.find(
(ranking) => ranking.publisher === publisherData.pubkey.toBase58(),
);
const apyHistory = publisherData.apyHistory.map(({ epoch, apy }) => ({
date: epochToDate(epoch + 1n),
apy: Number(apy),
}));
return {
apyHistory,
lastApy: apyHistory.at(-1)?.apy ?? 0,
isSelf:
publisherData.stakeAccount?.equals(stakeAccount.address) ?? false,
name: undefined, // TODO
numFeeds: publisherRanking?.numSymbols ?? 0,
poolCapacity: getPublisherCap(publisherData.pubkey),
poolUtilization: publisherData.totalDelegation,
publicKey: publisherData.pubkey,
qualityRanking: publisherRanking?.rank ?? 0,
selfStake: publisherData.selfDelegation,
positions: {
warmup: filterOISPositions(
publisherData.pubkey,
PositionState.LOCKING,
),
staked: filterOISPositions(
publisherData.pubkey,
PositionState.LOCKED,
),
cooldown: filterOISPositions(
publisherData.pubkey,
PositionState.PREUNLOCKING,
),
cooldown2: filterOISPositions(
publisherData.pubkey,
PositionState.UNLOCKED,
),
},
};
}),
};
};

Expand Down
10 changes: 10 additions & 0 deletions apps/staking/src/app/api/publishers-ranking/route.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { NextResponse } from "next/server";

export async function GET() {
const publisherRankingsResponse = await fetch(
"https://www.pyth.network/api/publishers-ranking?cluster=pythnet",
);

const publisherRankings = (await publisherRankingsResponse.json()) as JSON;
return NextResponse.json(publisherRankings);
}
10 changes: 2 additions & 8 deletions apps/staking/src/components/OracleIntegrityStaking/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -416,6 +416,7 @@ type PublisherProps = {
numFeeds: number;
qualityRanking: number;
apyHistory: { date: Date; apy: number }[];
lastApy: number;
positions?:
| {
warmup?: bigint | undefined;
Expand Down Expand Up @@ -517,14 +518,7 @@ const Publisher = ({
</Meter>
</PublisherTableCell>
<PublisherTableCell className="text-center">
<div>
{calculateApy(
publisher.poolCapacity,
publisher.poolUtilization,
publisher.isSelf,
)}
%
</div>
<div>{publisher.lastApy}%</div>
</PublisherTableCell>
<PublisherTableCell>
<div className="mx-auto h-14 w-28">
Expand Down
4 changes: 2 additions & 2 deletions apps/staking/src/hooks/use-dashboard-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,11 @@ const REFRESH_INTERVAL = 1 * ONE_MINUTE_IN_MS;
export const getCacheKey = (stakeAccount: PublicKey) => stakeAccount.toBase58();

export const useDashboardData = () => {
const { client, account } = useSelectedStakeAccount();
const { client, hermesClient, account } = useSelectedStakeAccount();

const { data, isLoading, mutate, ...rest } = useSWR(
getCacheKey(account.address),
() => loadData(client, account),
() => loadData(client, hermesClient, account),
{
refreshInterval: REFRESH_INTERVAL,
},
Expand Down
14 changes: 13 additions & 1 deletion apps/staking/src/hooks/use-stake-account.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"use client";

import { HermesClient } from "@pythnetwork/hermes-client";
import type { StakeAccountPositions } from "@pythnetwork/staking-sdk";
import { PythStakingClient } from "@pythnetwork/staking-sdk";
import { useConnection, useWallet } from "@solana/wallet-adapter-react";
Expand Down Expand Up @@ -38,12 +39,14 @@ const State = {

Loaded: (
client: PythStakingClient,
hermesClient: HermesClient,
account: StakeAccountPositions,
allAccounts: [StakeAccountPositions, ...StakeAccountPositions[]],
selectAccount: (account: StakeAccountPositions) => void,
) => ({
type: StateType.Loaded as const,
client,
hermesClient,
account,
allAccounts,
selectAccount,
Expand Down Expand Up @@ -83,7 +86,13 @@ const useStakeAccountState = () => {
(account: StakeAccountPositions) => {
setState((cur) =>
cur.type === StateType.Loaded
? State.Loaded(cur.client, account, cur.allAccounts, setAccount)
? State.Loaded(
cur.client,
cur.hermesClient,
account,
cur.allAccounts,
setAccount,
)
: cur,
);
},
Expand All @@ -109,13 +118,16 @@ const useStakeAccountState = () => {
signTransaction: wallet.signTransaction,
},
});
// TODO: use env var to support mainnet
const hermesClient = new HermesClient("https://hermes-beta.pyth.network");
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMO this should be in a separate react context, it doesn't really make sense to be in this context as it's not associated to the wallet state (I should rename this hook as it's no longer just about staking accounts, it's now really a hook for the wallet state, where the list of stake accounts & the selected stake account is part of the wallet state -- and the Pyth Staking Client is here because it is only valid in certain wallet states).

getStakeAccounts(client)
.then((accounts) => {
const [firstAccount, ...otherAccounts] = accounts;
if (firstAccount) {
setState(
State.Loaded(
client,
hermesClient,
firstAccount,
[firstAccount, ...otherAccounts],
setAccount,
Expand Down
1 change: 1 addition & 0 deletions governance/pyth_staking_sdk/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export * from "./pyth-staking-client";
export * from "./utils/clock";
export * from "./utils/position";
export * from "./utils/pool";
export * from "./types";
24 changes: 3 additions & 21 deletions governance/pyth_staking_sdk/src/pyth-staking-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import type {
StakeAccountPositions,
} from "./types";
import { convertBigIntToBN, convertBNToBigInt } from "./utils/bn";
import { extractPublisherData } from "./utils/pool";
import { deserializeStakeAccountPositions } from "./utils/position";
import { sendTransaction } from "./utils/transaction";
import { getUnlockSchedule } from "./utils/vesting";
Expand Down Expand Up @@ -190,26 +191,6 @@ export class PythStakingClient {
return convertBNToBigInt(poolDataAccountAnchor);
}

public async getPublishers(): Promise<
{
pubkey: PublicKey;
stakeAccount: PublicKey | null;
}[]
> {
const poolData = await this.getPoolDataAccount();

return poolData.publishers
.map((publisher, index) => ({
pubkey: publisher,
stakeAccount:
poolData.publisherStakeAccounts[index] === undefined ||
poolData.publisherStakeAccounts[index].equals(PublicKey.default)
? null
: poolData.publisherStakeAccounts[index],
}))
.filter(({ pubkey }) => !pubkey.equals(PublicKey.default));
}

public async stakeToGovernance(
stakeAccountPositions: PublicKey,
amount: bigint,
Expand Down Expand Up @@ -315,7 +296,8 @@ export class PythStakingClient {

public async advanceDelegationRecord(stakeAccountPositions: PublicKey) {
// TODO: optimize to only send transactions for publishers that have positive rewards
const publishers = await this.getPublishers();
const poolData = await this.getPoolDataAccount();
const publishers = extractPublisherData(poolData);

// anchor does not calculate the correct pda for other programs
// therefore we need to manually calculate the pdas
Expand Down
8 changes: 8 additions & 0 deletions governance/pyth_staking_sdk/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,14 @@ export type StakeAccountPositions = {
};
};

export type PublisherData = {
pubkey: PublicKey;
stakeAccount: PublicKey | null;
totalDelegation: bigint;
selfDelegation: bigint;
apyHistory: { epoch: bigint; apy: bigint; selfApy: bigint }[];
}[];

export enum PositionState {
UNLOCKED,
LOCKING,
Expand Down
4 changes: 4 additions & 0 deletions governance/pyth_staking_sdk/src/utils/clock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,7 @@ export const getCurrentEpoch: (
const timestamp = await getCurrentSolanaTimestamp(connection);
return timestamp / EPOCH_DURATION;
};

export const epochToDate = (epoch: bigint): Date => {
return new Date(Number(epoch * EPOCH_DURATION * 1000n));
};
34 changes: 34 additions & 0 deletions governance/pyth_staking_sdk/src/utils/pool.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { PublicKey } from "@solana/web3.js";

import type { PoolDataAccount, PublisherData } from "../types";

export const extractPublisherData = (
poolData: PoolDataAccount,
): PublisherData => {
return poolData.publishers
.filter((publisher) => !publisher.equals(PublicKey.default))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This feels wrong if there were gaps in the publishers (which can't happen in the program though)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes there are a lot of assumptions based on that.

.map((publisher, index) => ({
pubkey: publisher,
stakeAccount:
poolData.publisherStakeAccounts[index] === undefined ||
poolData.publisherStakeAccounts[index].equals(PublicKey.default)
? null
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor preference but I suggest just using undefined instead of mixing undefined and null; the fact that javascript has both is a wart and it's very very easy to misuse them in subtle ways. For all intents and purposes they are typically used interchangeably, so best to just standardize on one and always use it. Due to the semantics of missing values, you generally can't avoid undefined, but null is generally only introduced in user code, so I generally suggest just standardizing on using undefined everywhere.

If you need a true ternary state, I will always recommend using the fake sum types pattern I shared with you earlier, it's far more explicit, easier to understand the intent, and less error-prone.

: poolData.publisherStakeAccounts[index],
totalDelegation:
(poolData.delState[index]?.totalDelegation ?? 0n) +
(poolData.selfDelState[index]?.totalDelegation ?? 0n),
selfDelegation: poolData.selfDelState[index]?.totalDelegation ?? 0n,
apyHistory: poolData.events
.filter((event) => event.epoch > 0n)
.map((event) => ({
epoch: event.epoch,
apy:
(event.y * (event.eventData[index]?.otherRewardRatio ?? 0n)) /
1_000_000n,
selfApy:
(event.y * (event.eventData[index]?.selfRewardRatio ?? 0n)) /
1_000_000n,
}))
.sort((a, b) => Number(a.epoch) - Number(b.epoch)),
}));
};
Loading
Loading