Procházet zdrojové kódy

feat: add a staking endpoint to return a stake summary per account

Connor Prussin před 7 měsíci
rodič
revize
3d461e5220

+ 53 - 0
apps/staking/src/app/api/v1/amount_staked_per_account/route.ts

@@ -0,0 +1,53 @@
+import type { PositionState } from "@pythnetwork/staking-sdk";
+import {
+  PythStakingClient,
+  summarizeAccountPositions,
+  getCurrentEpoch,
+} from "@pythnetwork/staking-sdk";
+import { WalletAdapterNetwork } from "@solana/wallet-adapter-base";
+import { clusterApiUrl, Connection } from "@solana/web3.js";
+
+import {
+  AMOUNT_STAKED_PER_ACCOUNT_SECRET,
+  MAINNET_API_RPC,
+} from "../../../../config/server";
+
+export const maxDuration = 800;
+
+export const GET = async (req: Request) => {
+  if (
+    AMOUNT_STAKED_PER_ACCOUNT_SECRET === undefined ||
+    req.headers.get("authorization") ===
+      `Bearer ${AMOUNT_STAKED_PER_ACCOUNT_SECRET}`
+  ) {
+    const [accounts, epoch] = await Promise.all([
+      client.getAllStakeAccountPositionsAllOwners(),
+      getCurrentEpoch(client.connection),
+    ]);
+    return Response.json(
+      accounts.map((account) => {
+        const summary = summarizeAccountPositions(account, epoch);
+        return [
+          account.data.owner,
+          {
+            voting: stringifySummaryValues(summary.voting),
+            integrityPool: stringifySummaryValues(summary.integrityPool),
+          },
+        ];
+      }),
+    );
+  } else {
+    return new Response("Unauthorized", { status: 400 });
+  }
+};
+
+const stringifySummaryValues = (values: Record<PositionState, bigint>) =>
+  Object.fromEntries(
+    Object.entries(values).map(([state, value]) => [state, value.toString()]),
+  );
+
+const client = new PythStakingClient({
+  connection: new Connection(
+    MAINNET_API_RPC ?? clusterApiUrl(WalletAdapterNetwork.Mainnet),
+  ),
+});

+ 4 - 0
apps/staking/src/config/server.ts

@@ -80,6 +80,10 @@ export const SIMULATION_PAYER_ADDRESS = getOr(
   "SIMULATION_PAYER_ADDRESS",
   "E5KR7yfb9UyVB6ZhmhQki1rM1eBcxHvyGKFZakAC5uc",
 );
+export const AMOUNT_STAKED_PER_ACCOUNT_SECRET = demandInProduction(
+  "AMOUNT_STAKED_PER_ACCOUNT_SECRET",
+);
+
 class MissingEnvironmentError extends Error {
   constructor(name: string) {
     super(`Missing environment variable: ${name}!`);

+ 2 - 1
apps/staking/turbo.json

@@ -12,7 +12,8 @@
         "MAINNET_API_RPC",
         "BLOCKED_REGIONS",
         "AMPLITUDE_API_KEY",
-        "GOOGLE_ANALYTICS_ID"
+        "GOOGLE_ANALYTICS_ID",
+        "AMOUNT_STAKED_PER_ACCOUNT_SECRET"
       ]
     },
     "start:dev": {

+ 6 - 1
governance/pyth_staking_sdk/package.json

@@ -8,6 +8,9 @@
   "files": [
     "dist/**/*"
   ],
+  "engines": {
+    "node": "22"
+  },
   "publishConfig": {
     "access": "public"
   },
@@ -39,6 +42,8 @@
     "@pythnetwork/solana-utils": "workspace:*",
     "@solana/spl-governance": "^0.3.28",
     "@solana/spl-token": "^0.3.7",
-    "@solana/web3.js": "catalog:"
+    "@solana/web3.js": "catalog:",
+    "@streamparser/json": "^0.0.22",
+    "zod": "catalog:"
   }
 }

+ 116 - 0
governance/pyth_staking_sdk/src/pyth-staking-client.ts

@@ -21,6 +21,8 @@ import {
   Transaction,
   TransactionInstruction,
 } from "@solana/web3.js";
+import { JSONParser } from "@streamparser/json";
+import { z } from "zod";
 
 import {
   GOVERNANCE_ADDRESS,
@@ -1031,4 +1033,118 @@ export class PythStakingClient {
 
     return getAccount(this.connection, rewardCustodyAccountAddress);
   }
+
+  /**
+   * Return all stake account positions for all owners.  Note that this method
+   * is unique in a few ways:
+   *
+   * 1. It's very, very expensive.  Don't call it if you don't _really_ need it,
+   *    and expect it to take a few minutes to respond.
+   * 2. Because the full positionData is so large, json parsing it with a
+   *    typical json parser would involve buffering to a string that's too large
+   *    for node.  So instead we use `stream-json` to parse it as a stream.
+   */
+  public async getAllStakeAccountPositionsAllOwners(): Promise<
+    StakeAccountPositions[]
+  > {
+    const res = await fetch(this.connection.rpcEndpoint, {
+      method: "POST",
+      headers: {
+        "Content-Type": "application/json",
+      },
+      body: JSON.stringify({
+        jsonrpc: "2.0",
+        id: 1,
+        method: "getProgramAccounts",
+        params: [
+          this.stakingProgram.programId.toBase58(),
+          {
+            encoding: "base64",
+            filters: [
+              {
+                memcmp: this.stakingProgram.coder.accounts.memcmp(
+                  "positionData",
+                ) as {
+                  offset: number;
+                  bytes: string;
+                },
+              },
+            ],
+          },
+        ],
+      }),
+    });
+
+    if (res.ok) {
+      const { body } = res;
+      if (body) {
+        const accounts = await new Promise<unknown>((resolve, reject) => {
+          const jsonparser = new JSONParser({ paths: ["$.result"] });
+          jsonparser.onValue = ({ value }) => {
+            resolve(value);
+          };
+          const parse = async () => {
+            const reader = body.getReader();
+            // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
+            while (true) {
+              const res = await reader.read();
+              if (res.done) break;
+              if (typeof res.value === "string") {
+                jsonparser.write(res.value);
+              }
+            }
+          };
+
+          parse().catch((error: unknown) => {
+            reject(error instanceof Error ? error : new Error("Unknown Error"));
+          });
+        });
+
+        return accountSchema
+          .parse(accounts)
+          .map(({ pubkey, account }) =>
+            deserializeStakeAccountPositions(
+              pubkey,
+              account.data,
+              this.stakingProgram.idl,
+            ),
+          );
+      } else {
+        throw new NoBodyError();
+      }
+    } else {
+      throw new NotOKError(res);
+    }
+  }
+}
+
+const accountSchema = z.array(
+  z.object({
+    account: z.object({
+      data: z
+        .array(z.string())
+        .min(1)
+        .transform((data) =>
+          // Safe because `min(1)` guarantees that `data` is nonempty
+          // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+          Buffer.from(data[0]!, "base64"),
+        ),
+    }),
+    pubkey: z.string().transform((value) => new PublicKey(value)),
+  }),
+);
+
+class NotOKError extends Error {
+  constructor(result: Response) {
+    super(`Received a ${result.status.toString()} response for ${result.url}`);
+    this.cause = result;
+    this.name = "NotOKError";
+  }
+}
+
+class NoBodyError extends Error {
+  constructor() {
+    super("Response did not contain a body!");
+    this.name = "NoBodyError";
+  }
 }

+ 30 - 0
governance/pyth_staking_sdk/src/utils/position.ts

@@ -111,3 +111,33 @@ export const getVotingTokenAmount = (
   );
   return totalVotingTokenAmount;
 };
+
+export const summarizeAccountPositions = (
+  positions: StakeAccountPositions,
+  epoch: bigint,
+) => {
+  const summary = {
+    voting: {
+      [PositionState.LOCKED]: 0n,
+      [PositionState.LOCKING]: 0n,
+      [PositionState.PREUNLOCKING]: 0n,
+      [PositionState.UNLOCKED]: 0n,
+      [PositionState.UNLOCKING]: 0n,
+    },
+    integrityPool: {
+      [PositionState.LOCKED]: 0n,
+      [PositionState.LOCKING]: 0n,
+      [PositionState.PREUNLOCKING]: 0n,
+      [PositionState.UNLOCKED]: 0n,
+      [PositionState.UNLOCKING]: 0n,
+    },
+  };
+  for (const position of positions.data.positions) {
+    const category = position.targetWithParameters.voting
+      ? "voting"
+      : "integrityPool";
+    const state = getPositionState(position, epoch);
+    summary[category][state] += position.amount;
+  }
+  return summary;
+};

+ 11 - 0
pnpm-lock.yaml

@@ -1193,6 +1193,12 @@ importers:
       '@solana/web3.js':
         specifier: 'catalog:'
         version: 1.98.0(bufferutil@4.0.9)(encoding@0.1.13)(utf-8-validate@5.0.10)
+      '@streamparser/json':
+        specifier: ^0.0.22
+        version: 0.0.22
+      zod:
+        specifier: 'catalog:'
+        version: 3.24.2
     devDependencies:
       '@cprussin/eslint-config':
         specifier: 'catalog:'
@@ -9406,6 +9412,9 @@ packages:
     peerDependencies:
       storybook: ^8.2.0 || ^8.3.0-0 || ^8.4.0-0 || ^8.5.0-0 || ^8.6.0-0
 
+  '@streamparser/json@0.0.22':
+    resolution: {integrity: sha512-b6gTSBjJ8G8SuO3Gbbj+zXbVx8NSs1EbpbMKpzGLWMdkR+98McH9bEjSz3+0mPJf68c5nxa3CrJHp5EQNXM6zQ==}
+
   '@suchipi/femver@1.0.0':
     resolution: {integrity: sha512-bprE8+K5V+DPX7q2e2K57ImqNBdfGHDIWaGI5xHxZoxbKOuQZn4wzPiUxOAHnsUr3w3xHrWXwN7gnG/iIuEMIg==}
 
@@ -33483,6 +33492,8 @@ snapshots:
     dependencies:
       storybook: 8.6.12(bufferutil@4.0.9)(prettier@3.5.3)(utf-8-validate@6.0.3)
 
+  '@streamparser/json@0.0.22': {}
+
   '@suchipi/femver@1.0.0': {}
 
   '@svgr/babel-plugin-add-jsx-attribute@6.5.1(@babel/core@7.26.10)':