diff --git a/apps/staking/src/app/api/v1/amount_staked_per_account/route.ts b/apps/staking/src/app/api/v1/amount_staked_per_account/route.ts new file mode 100644 index 0000000000..359b02a8b6 --- /dev/null +++ b/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) => + Object.fromEntries( + Object.entries(values).map(([state, value]) => [state, value.toString()]), + ); + +const client = new PythStakingClient({ + connection: new Connection( + MAINNET_API_RPC ?? clusterApiUrl(WalletAdapterNetwork.Mainnet), + ), +}); diff --git a/apps/staking/src/config/server.ts b/apps/staking/src/config/server.ts index 25846b4016..3c18e6e7fb 100644 --- a/apps/staking/src/config/server.ts +++ b/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}!`); diff --git a/apps/staking/turbo.json b/apps/staking/turbo.json index a01af66192..efbc34fc13 100644 --- a/apps/staking/turbo.json +++ b/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": { diff --git a/governance/pyth_staking_sdk/package.json b/governance/pyth_staking_sdk/package.json index 65f8a32a63..5b1c1c23de 100644 --- a/governance/pyth_staking_sdk/package.json +++ b/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:" } } diff --git a/governance/pyth_staking_sdk/src/pyth-staking-client.ts b/governance/pyth_staking_sdk/src/pyth-staking-client.ts index 22a99fabd4..bdf576889f 100644 --- a/governance/pyth_staking_sdk/src/pyth-staking-client.ts +++ b/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((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"; + } } diff --git a/governance/pyth_staking_sdk/src/utils/position.ts b/governance/pyth_staking_sdk/src/utils/position.ts index 65a4f11b5b..95dc882598 100644 --- a/governance/pyth_staking_sdk/src/utils/position.ts +++ b/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; +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9321483a36..e2e53d262e 100644 --- a/pnpm-lock.yaml +++ b/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)':