Pārlūkot izejas kodu

feat(staking): add stake accounts and supply routes (#1957)

* feat(staking): add stake accounts route

* go

* go

* wip

* fix

* fix

* fix
Keyvan Khademi 1 gadu atpakaļ
vecāks
revīzija
9a6e7d1f67

+ 2 - 2
apps/staking/src/api.ts

@@ -194,7 +194,7 @@ const loadDataForStakeAccount = async (
   client: PythStakingClient,
   hermesClient: HermesClient,
   stakeAccount: PublicKey,
-) => {
+): Promise<Data> => {
   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: {

+ 95 - 0
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,
+        },
+      );
+}

+ 42 - 0
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));
+  }
+}

+ 1 - 0
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;
 

+ 5 - 3
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";

+ 54 - 5
governance/pyth_staking_sdk/src/pyth-staking-client.ts

@@ -12,6 +12,8 @@ import {
   createTransferInstruction,
   getAccount,
   getAssociatedTokenAddress,
+  getMint,
+  type Mint,
 } from "@solana/spl-token";
 import type { AnchorWallet } from "@solana/wallet-adapter-react";
 import {
@@ -22,7 +24,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,
@@ -36,6 +43,7 @@ import {
   type PoolConfig,
   type PoolDataAccount,
   type StakeAccountPositions,
+  type VestingSchedule,
 } from "./types";
 import { convertBigIntToBN, convertBNToBigInt } from "./utils/bn";
 import { epochToDate, getCurrentEpoch } from "./utils/clock";
@@ -56,7 +64,7 @@ import type { Staking } from "../types/staking";
 
 export type PythStakingClientConfig = {
   connection: Connection;
-  wallet: AnchorWallet | undefined;
+  wallet?: AnchorWallet;
 };
 
 export class PythStakingClient {
@@ -105,7 +113,9 @@ export class PythStakingClient {
   }
 
   /** Gets a users stake accounts */
-  public async getAllStakeAccountPositions(): Promise<PublicKey[]> {
+  public async getAllStakeAccountPositions(
+    owner?: PublicKey,
+  ): Promise<PublicKey[]> {
     const positionDataMemcmp = this.stakingProgram.coder.accounts.memcmp(
       "positionData",
     ) as {
@@ -124,7 +134,7 @@ export class PythStakingClient {
             {
               memcmp: {
                 offset: 8,
-                bytes: this.wallet.publicKey.toBase58(),
+                bytes: owner?.toBase58() ?? this.wallet.publicKey.toBase58(),
               },
             },
           ],
@@ -529,7 +539,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,
     );
@@ -548,7 +561,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(
@@ -705,4 +749,9 @@ export class PythStakingClient {
       undefined,
     );
   }
+
+  public async getPythTokenMint(): Promise<Mint> {
+    const globalConfig = await this.getGlobalConfig();
+    return getMint(this.connection, globalConfig.pythTokenMint);
+  }
 }

+ 6 - 3
governance/pyth_staking_sdk/src/types.ts

@@ -36,9 +36,12 @@ export type VestingScheduleAnchor = IdlTypes<Staking>["vestingSchedule"];
 export type VestingSchedule = ConvertBNToBigInt<VestingScheduleAnchor>;
 
 export type UnlockSchedule = {
-  date: Date;
-  amount: bigint;
-}[];
+  type: "fullyUnlocked" | "periodicUnlockingAfterListing" | "periodicUnlocking";
+  schedule: {
+    date: Date;
+    amount: bigint;
+  }[];
+};
 
 export type StakeAccountPositions = {
   address: PublicKey;

+ 33 - 19
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,