diff --git a/apps/staking/src/api.ts b/apps/staking/src/api.ts index a1dbd269c6..65cc5b7cb4 100644 --- a/apps/staking/src/api.ts +++ b/apps/staking/src/api.ts @@ -194,7 +194,7 @@ const loadDataForStakeAccount = async ( client: PythStakingClient, hermesClient: HermesClient, stakeAccount: PublicKey, -) => { +): Promise => { const [ { publishers, ...baseInfo }, stakeAccountCustody, @@ -240,7 +240,7 @@ const loadDataForStakeAccount = async ( cooldown: filterGovernancePositions(PositionState.PREUNLOCKING), cooldown2: filterGovernancePositions(PositionState.UNLOCKING), }, - unlockSchedule, + unlockSchedule: unlockSchedule.schedule, integrityStakingPublishers: publishers.map((publisher) => ({ ...publisher, positions: { diff --git a/apps/staking/src/app/api/stake-accounts/route.ts b/apps/staking/src/app/api/stake-accounts/route.ts new file mode 100644 index 0000000000..3ca93ff5c7 --- /dev/null +++ b/apps/staking/src/app/api/stake-accounts/route.ts @@ -0,0 +1,95 @@ +import { PythStakingClient } from "@pythnetwork/staking-sdk"; +import { WalletAdapterNetwork } from "@solana/wallet-adapter-base"; +import { clusterApiUrl, Connection, PublicKey } from "@solana/web3.js"; +import type { NextRequest } from "next/server"; +import { z } from "zod"; + +import { IS_MAINNET, RPC } from "../../../config/server"; + +const UnlockScheduleSchema = z.object({ + date: z.date(), + amount: z.number(), +}); + +const LockSchema = z.object({ + type: z.string(), + schedule: z.array(UnlockScheduleSchema), +}); + +const ResponseSchema = z.array( + z.object({ + custodyAccount: z.string(), + actualAmount: z.number(), + lock: LockSchema, + }), +); + +const stakingClient = new PythStakingClient({ + connection: new Connection( + RPC ?? + clusterApiUrl( + IS_MAINNET ? WalletAdapterNetwork.Mainnet : WalletAdapterNetwork.Devnet, + ), + ), +}); + +const isValidPublicKey = (publicKey: string) => { + try { + new PublicKey(publicKey); + return true; + } catch { + return false; + } +}; + +export async function GET(req: NextRequest) { + const owner = req.nextUrl.searchParams.get("owner"); + + if (owner === null || !isValidPublicKey(owner)) { + return Response.json( + { + error: + "Must provide the 'owner' query parameters as a valid base58 public key", + }, + { + status: 400, + }, + ); + } + + const positions = await stakingClient.getAllStakeAccountPositions( + new PublicKey(owner), + ); + + const responseRaw = await Promise.all( + positions.map(async (position) => { + const custodyAccount = + await stakingClient.getStakeAccountCustody(position); + const lock = await stakingClient.getUnlockSchedule(position, true); + return { + custodyAccount: custodyAccount.address.toBase58(), + actualAmount: Number(custodyAccount.amount), + lock: { + type: lock.type, + schedule: lock.schedule.map((unlock) => ({ + date: unlock.date, + amount: Number(unlock.amount), + })), + }, + }; + }), + ); + + const response = ResponseSchema.safeParse(responseRaw); + + return response.success + ? Response.json(response.data) + : Response.json( + { + error: "Internal server error", + }, + { + status: 500, + }, + ); +} diff --git a/apps/staking/src/app/api/supply/route.ts b/apps/staking/src/app/api/supply/route.ts new file mode 100644 index 0000000000..9546571dc7 --- /dev/null +++ b/apps/staking/src/app/api/supply/route.ts @@ -0,0 +1,42 @@ +import { PythStakingClient } from "@pythnetwork/staking-sdk"; +import { WalletAdapterNetwork } from "@solana/wallet-adapter-base"; +import { clusterApiUrl, Connection } from "@solana/web3.js"; +import type { NextRequest } from "next/server"; +import { z } from "zod"; + +import { IS_MAINNET, RPC } from "../../../config/server"; + +const stakingClient = new PythStakingClient({ + connection: new Connection( + RPC ?? + clusterApiUrl( + IS_MAINNET ? WalletAdapterNetwork.Mainnet : WalletAdapterNetwork.Devnet, + ), + ), +}); + +const querySchema = z.enum(["totalSupply", "circulatingSupply"]); + +export async function GET(req: NextRequest) { + const query = querySchema.safeParse(req.nextUrl.searchParams.get("q")); + if (!query.success) { + return Response.json( + { + error: + "The 'q' query parameter must be one of 'totalSupply' or 'circulatingSupply'.", + }, + { + status: 400, + }, + ); + } + const q = query.data; + + if (q === "circulatingSupply") { + const circulatingSupply = await stakingClient.getCirculatingSupply(); + return Response.json(Number(circulatingSupply)); + } else { + const pythMint = await stakingClient.getPythTokenMint(); + return Response.json(Number(pythMint.supply)); + } +} diff --git a/governance/pyth_staking_sdk/src/constants.ts b/governance/pyth_staking_sdk/src/constants.ts index 2954bd39ec..78bbe549a5 100644 --- a/governance/pyth_staking_sdk/src/constants.ts +++ b/governance/pyth_staking_sdk/src/constants.ts @@ -5,6 +5,7 @@ const ONE_MINUTE_IN_SECONDS = 60n; const ONE_HOUR_IN_SECONDS = 60n * ONE_MINUTE_IN_SECONDS; const ONE_DAY_IN_SECONDS = 24n * ONE_HOUR_IN_SECONDS; const ONE_WEEK_IN_SECONDS = 7n * ONE_DAY_IN_SECONDS; +export const ONE_YEAR_IN_SECONDS = 365n * ONE_DAY_IN_SECONDS; export const EPOCH_DURATION = ONE_WEEK_IN_SECONDS; diff --git a/governance/pyth_staking_sdk/src/index.ts b/governance/pyth_staking_sdk/src/index.ts index 19f5f2281b..ed51339770 100644 --- a/governance/pyth_staking_sdk/src/index.ts +++ b/governance/pyth_staking_sdk/src/index.ts @@ -1,6 +1,8 @@ +export * from "./pdas"; export * from "./pyth-staking-client"; +export * from "./types"; +export * from "./utils/apy"; export * from "./utils/clock"; -export * from "./utils/position"; export * from "./utils/pool"; -export * from "./utils/apy"; -export * from "./types"; +export * from "./utils/position"; +export * from "./utils/vesting"; diff --git a/governance/pyth_staking_sdk/src/pyth-staking-client.ts b/governance/pyth_staking_sdk/src/pyth-staking-client.ts index 4c1de84a66..abe781a2a4 100644 --- a/governance/pyth_staking_sdk/src/pyth-staking-client.ts +++ b/governance/pyth_staking_sdk/src/pyth-staking-client.ts @@ -11,6 +11,8 @@ import { createTransferInstruction, getAccount, getAssociatedTokenAddress, + getMint, + type Mint, } from "@solana/spl-token"; import type { AnchorWallet } from "@solana/wallet-adapter-react"; import { @@ -21,7 +23,12 @@ import { TransactionInstruction, } from "@solana/web3.js"; -import { GOVERNANCE_ADDRESS, POSITIONS_ACCOUNT_SIZE } from "./constants"; +import { + FRACTION_PRECISION_N, + GOVERNANCE_ADDRESS, + ONE_YEAR_IN_SECONDS, + POSITIONS_ACCOUNT_SIZE, +} from "./constants"; import { getConfigAddress, getDelegationRecordAddress, @@ -35,6 +42,7 @@ import { type PoolConfig, type PoolDataAccount, type StakeAccountPositions, + type VestingSchedule, } from "./types"; import { convertBigIntToBN, convertBNToBigInt } from "./utils/bn"; import { epochToDate, getCurrentEpoch } from "./utils/clock"; @@ -55,7 +63,7 @@ import type { Staking } from "../types/staking"; export type PythStakingClientConfig = { connection: Connection; - wallet: AnchorWallet | undefined; + wallet?: AnchorWallet; }; export class PythStakingClient { @@ -104,7 +112,9 @@ export class PythStakingClient { } /** Gets a users stake accounts */ - public async getAllStakeAccountPositions(): Promise { + public async getAllStakeAccountPositions( + owner?: PublicKey, + ): Promise { const positionDataMemcmp = this.stakingProgram.coder.accounts.memcmp( "positionData", ) as { @@ -123,7 +133,7 @@ export class PythStakingClient { { memcmp: { offset: 8, - bytes: this.wallet.publicKey.toBase58(), + bytes: owner?.toBase58() ?? this.wallet.publicKey.toBase58(), }, }, ], @@ -511,7 +521,10 @@ export class PythStakingClient { return sendTransaction([instruction], this.connection, this.wallet); } - public async getUnlockSchedule(stakeAccountPositions: PublicKey) { + public async getUnlockSchedule( + stakeAccountPositions: PublicKey, + includePastPeriods = false, + ) { const stakeAccountMetadataAddress = getStakeAccountMetadataAddress( stakeAccountPositions, ); @@ -530,7 +543,38 @@ export class PythStakingClient { return getUnlockSchedule({ vestingSchedule, pythTokenListTime: config.pythTokenListTime, + includePastPeriods, + }); + } + + public async getCirculatingSupply() { + const vestingSchedule: VestingSchedule = { + periodicVestingAfterListing: { + initialBalance: 8_500_000_000n * FRACTION_PRECISION_N, + numPeriods: 4n, + periodDuration: ONE_YEAR_IN_SECONDS, + }, + }; + + const config = await this.getGlobalConfig(); + + if (config.pythTokenListTime === null) { + throw new Error("Pyth token list time not set in global config"); + } + + const unlockSchedule = getUnlockSchedule({ + vestingSchedule, + pythTokenListTime: config.pythTokenListTime, + includePastPeriods: false, }); + + const totalLocked = unlockSchedule.schedule.reduce( + (total, unlock) => total + unlock.amount, + 0n, + ); + + const mint = await this.getPythTokenMint(); + return mint.supply - totalLocked; } async getAdvanceDelegationRecordInstructions( @@ -687,4 +731,9 @@ export class PythStakingClient { undefined, ); } + + public async getPythTokenMint(): Promise { + const globalConfig = await this.getGlobalConfig(); + return getMint(this.connection, globalConfig.pythTokenMint); + } } diff --git a/governance/pyth_staking_sdk/src/types.ts b/governance/pyth_staking_sdk/src/types.ts index c040eb3f51..a03dbfacb8 100644 --- a/governance/pyth_staking_sdk/src/types.ts +++ b/governance/pyth_staking_sdk/src/types.ts @@ -36,9 +36,12 @@ export type VestingScheduleAnchor = IdlTypes["vestingSchedule"]; export type VestingSchedule = ConvertBNToBigInt; export type UnlockSchedule = { - date: Date; - amount: bigint; -}[]; + type: "fullyUnlocked" | "periodicUnlockingAfterListing" | "periodicUnlocking"; + schedule: { + date: Date; + amount: bigint; + }[]; +}; export type StakeAccountPositions = { address: PublicKey; diff --git a/governance/pyth_staking_sdk/src/utils/vesting.ts b/governance/pyth_staking_sdk/src/utils/vesting.ts index 87673e6ee6..bfd2b5c66a 100644 --- a/governance/pyth_staking_sdk/src/utils/vesting.ts +++ b/governance/pyth_staking_sdk/src/utils/vesting.ts @@ -3,26 +3,38 @@ import type { UnlockSchedule, VestingSchedule } from "../types"; export const getUnlockSchedule = (options: { pythTokenListTime: bigint; vestingSchedule: VestingSchedule; + includePastPeriods: boolean; }): UnlockSchedule => { - const { vestingSchedule, pythTokenListTime } = options; + const { vestingSchedule, pythTokenListTime, includePastPeriods } = options; if (vestingSchedule.fullyVested) { - return []; + return { + type: "fullyUnlocked", + schedule: [], + }; } else if (vestingSchedule.periodicVestingAfterListing) { - return getPeriodicUnlockSchedule({ - balance: vestingSchedule.periodicVestingAfterListing.initialBalance, - numPeriods: vestingSchedule.periodicVestingAfterListing.numPeriods, - periodDuration: - vestingSchedule.periodicVestingAfterListing.periodDuration, - startDate: pythTokenListTime, - }); + return { + type: "periodicUnlockingAfterListing", + schedule: getPeriodicUnlockSchedule({ + balance: vestingSchedule.periodicVestingAfterListing.initialBalance, + numPeriods: vestingSchedule.periodicVestingAfterListing.numPeriods, + periodDuration: + vestingSchedule.periodicVestingAfterListing.periodDuration, + startDate: pythTokenListTime, + includePastPeriods, + }), + }; } else { - return getPeriodicUnlockSchedule({ - balance: vestingSchedule.periodicVesting.initialBalance, - numPeriods: vestingSchedule.periodicVesting.numPeriods, - periodDuration: vestingSchedule.periodicVesting.periodDuration, - startDate: vestingSchedule.periodicVesting.startDate, - }); + return { + type: "periodicUnlocking", + schedule: getPeriodicUnlockSchedule({ + balance: vestingSchedule.periodicVesting.initialBalance, + numPeriods: vestingSchedule.periodicVesting.numPeriods, + periodDuration: vestingSchedule.periodicVesting.periodDuration, + startDate: vestingSchedule.periodicVesting.startDate, + includePastPeriods, + }), + }; } }; @@ -31,16 +43,18 @@ export const getPeriodicUnlockSchedule = (options: { startDate: bigint; periodDuration: bigint; numPeriods: bigint; -}): UnlockSchedule => { - const { balance, startDate, periodDuration, numPeriods } = options; + includePastPeriods: boolean; +}): UnlockSchedule["schedule"] => { + const { balance, startDate, periodDuration, numPeriods, includePastPeriods } = + options; - const unlockSchedule: UnlockSchedule = []; + const unlockSchedule: UnlockSchedule["schedule"] = []; const currentTimeStamp = Date.now() / 1000; for (let i = 0; i < numPeriods; i++) { const unlockTimeStamp = Number(startDate) + Number(periodDuration) * (i + 1); - if (currentTimeStamp < unlockTimeStamp) { + if (currentTimeStamp < unlockTimeStamp || includePastPeriods) { unlockSchedule.push({ date: new Date(unlockTimeStamp * 1000), amount: balance / numPeriods,