Skip to content

Commit 3d461e5

Browse files
committed
feat: add a staking endpoint to return a stake summary per account
1 parent 65379c8 commit 3d461e5

File tree

7 files changed

+222
-2
lines changed

7 files changed

+222
-2
lines changed
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import type { PositionState } from "@pythnetwork/staking-sdk";
2+
import {
3+
PythStakingClient,
4+
summarizeAccountPositions,
5+
getCurrentEpoch,
6+
} from "@pythnetwork/staking-sdk";
7+
import { WalletAdapterNetwork } from "@solana/wallet-adapter-base";
8+
import { clusterApiUrl, Connection } from "@solana/web3.js";
9+
10+
import {
11+
AMOUNT_STAKED_PER_ACCOUNT_SECRET,
12+
MAINNET_API_RPC,
13+
} from "../../../../config/server";
14+
15+
export const maxDuration = 800;
16+
17+
export const GET = async (req: Request) => {
18+
if (
19+
AMOUNT_STAKED_PER_ACCOUNT_SECRET === undefined ||
20+
req.headers.get("authorization") ===
21+
`Bearer ${AMOUNT_STAKED_PER_ACCOUNT_SECRET}`
22+
) {
23+
const [accounts, epoch] = await Promise.all([
24+
client.getAllStakeAccountPositionsAllOwners(),
25+
getCurrentEpoch(client.connection),
26+
]);
27+
return Response.json(
28+
accounts.map((account) => {
29+
const summary = summarizeAccountPositions(account, epoch);
30+
return [
31+
account.data.owner,
32+
{
33+
voting: stringifySummaryValues(summary.voting),
34+
integrityPool: stringifySummaryValues(summary.integrityPool),
35+
},
36+
];
37+
}),
38+
);
39+
} else {
40+
return new Response("Unauthorized", { status: 400 });
41+
}
42+
};
43+
44+
const stringifySummaryValues = (values: Record<PositionState, bigint>) =>
45+
Object.fromEntries(
46+
Object.entries(values).map(([state, value]) => [state, value.toString()]),
47+
);
48+
49+
const client = new PythStakingClient({
50+
connection: new Connection(
51+
MAINNET_API_RPC ?? clusterApiUrl(WalletAdapterNetwork.Mainnet),
52+
),
53+
});

apps/staking/src/config/server.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,10 @@ export const SIMULATION_PAYER_ADDRESS = getOr(
8080
"SIMULATION_PAYER_ADDRESS",
8181
"E5KR7yfb9UyVB6ZhmhQki1rM1eBcxHvyGKFZakAC5uc",
8282
);
83+
export const AMOUNT_STAKED_PER_ACCOUNT_SECRET = demandInProduction(
84+
"AMOUNT_STAKED_PER_ACCOUNT_SECRET",
85+
);
86+
8387
class MissingEnvironmentError extends Error {
8488
constructor(name: string) {
8589
super(`Missing environment variable: ${name}!`);

apps/staking/turbo.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@
1212
"MAINNET_API_RPC",
1313
"BLOCKED_REGIONS",
1414
"AMPLITUDE_API_KEY",
15-
"GOOGLE_ANALYTICS_ID"
15+
"GOOGLE_ANALYTICS_ID",
16+
"AMOUNT_STAKED_PER_ACCOUNT_SECRET"
1617
]
1718
},
1819
"start:dev": {

governance/pyth_staking_sdk/package.json

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@
88
"files": [
99
"dist/**/*"
1010
],
11+
"engines": {
12+
"node": "22"
13+
},
1114
"publishConfig": {
1215
"access": "public"
1316
},
@@ -39,6 +42,8 @@
3942
"@pythnetwork/solana-utils": "workspace:*",
4043
"@solana/spl-governance": "^0.3.28",
4144
"@solana/spl-token": "^0.3.7",
42-
"@solana/web3.js": "catalog:"
45+
"@solana/web3.js": "catalog:",
46+
"@streamparser/json": "^0.0.22",
47+
"zod": "catalog:"
4348
}
4449
}

governance/pyth_staking_sdk/src/pyth-staking-client.ts

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ import {
2121
Transaction,
2222
TransactionInstruction,
2323
} from "@solana/web3.js";
24+
import { JSONParser } from "@streamparser/json";
25+
import { z } from "zod";
2426

2527
import {
2628
GOVERNANCE_ADDRESS,
@@ -1031,4 +1033,118 @@ export class PythStakingClient {
10311033

10321034
return getAccount(this.connection, rewardCustodyAccountAddress);
10331035
}
1036+
1037+
/**
1038+
* Return all stake account positions for all owners. Note that this method
1039+
* is unique in a few ways:
1040+
*
1041+
* 1. It's very, very expensive. Don't call it if you don't _really_ need it,
1042+
* and expect it to take a few minutes to respond.
1043+
* 2. Because the full positionData is so large, json parsing it with a
1044+
* typical json parser would involve buffering to a string that's too large
1045+
* for node. So instead we use `stream-json` to parse it as a stream.
1046+
*/
1047+
public async getAllStakeAccountPositionsAllOwners(): Promise<
1048+
StakeAccountPositions[]
1049+
> {
1050+
const res = await fetch(this.connection.rpcEndpoint, {
1051+
method: "POST",
1052+
headers: {
1053+
"Content-Type": "application/json",
1054+
},
1055+
body: JSON.stringify({
1056+
jsonrpc: "2.0",
1057+
id: 1,
1058+
method: "getProgramAccounts",
1059+
params: [
1060+
this.stakingProgram.programId.toBase58(),
1061+
{
1062+
encoding: "base64",
1063+
filters: [
1064+
{
1065+
memcmp: this.stakingProgram.coder.accounts.memcmp(
1066+
"positionData",
1067+
) as {
1068+
offset: number;
1069+
bytes: string;
1070+
},
1071+
},
1072+
],
1073+
},
1074+
],
1075+
}),
1076+
});
1077+
1078+
if (res.ok) {
1079+
const { body } = res;
1080+
if (body) {
1081+
const accounts = await new Promise<unknown>((resolve, reject) => {
1082+
const jsonparser = new JSONParser({ paths: ["$.result"] });
1083+
jsonparser.onValue = ({ value }) => {
1084+
resolve(value);
1085+
};
1086+
const parse = async () => {
1087+
const reader = body.getReader();
1088+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
1089+
while (true) {
1090+
const res = await reader.read();
1091+
if (res.done) break;
1092+
if (typeof res.value === "string") {
1093+
jsonparser.write(res.value);
1094+
}
1095+
}
1096+
};
1097+
1098+
parse().catch((error: unknown) => {
1099+
reject(error instanceof Error ? error : new Error("Unknown Error"));
1100+
});
1101+
});
1102+
1103+
return accountSchema
1104+
.parse(accounts)
1105+
.map(({ pubkey, account }) =>
1106+
deserializeStakeAccountPositions(
1107+
pubkey,
1108+
account.data,
1109+
this.stakingProgram.idl,
1110+
),
1111+
);
1112+
} else {
1113+
throw new NoBodyError();
1114+
}
1115+
} else {
1116+
throw new NotOKError(res);
1117+
}
1118+
}
1119+
}
1120+
1121+
const accountSchema = z.array(
1122+
z.object({
1123+
account: z.object({
1124+
data: z
1125+
.array(z.string())
1126+
.min(1)
1127+
.transform((data) =>
1128+
// Safe because `min(1)` guarantees that `data` is nonempty
1129+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
1130+
Buffer.from(data[0]!, "base64"),
1131+
),
1132+
}),
1133+
pubkey: z.string().transform((value) => new PublicKey(value)),
1134+
}),
1135+
);
1136+
1137+
class NotOKError extends Error {
1138+
constructor(result: Response) {
1139+
super(`Received a ${result.status.toString()} response for ${result.url}`);
1140+
this.cause = result;
1141+
this.name = "NotOKError";
1142+
}
1143+
}
1144+
1145+
class NoBodyError extends Error {
1146+
constructor() {
1147+
super("Response did not contain a body!");
1148+
this.name = "NoBodyError";
1149+
}
10341150
}

governance/pyth_staking_sdk/src/utils/position.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,3 +111,33 @@ export const getVotingTokenAmount = (
111111
);
112112
return totalVotingTokenAmount;
113113
};
114+
115+
export const summarizeAccountPositions = (
116+
positions: StakeAccountPositions,
117+
epoch: bigint,
118+
) => {
119+
const summary = {
120+
voting: {
121+
[PositionState.LOCKED]: 0n,
122+
[PositionState.LOCKING]: 0n,
123+
[PositionState.PREUNLOCKING]: 0n,
124+
[PositionState.UNLOCKED]: 0n,
125+
[PositionState.UNLOCKING]: 0n,
126+
},
127+
integrityPool: {
128+
[PositionState.LOCKED]: 0n,
129+
[PositionState.LOCKING]: 0n,
130+
[PositionState.PREUNLOCKING]: 0n,
131+
[PositionState.UNLOCKED]: 0n,
132+
[PositionState.UNLOCKING]: 0n,
133+
},
134+
};
135+
for (const position of positions.data.positions) {
136+
const category = position.targetWithParameters.voting
137+
? "voting"
138+
: "integrityPool";
139+
const state = getPositionState(position, epoch);
140+
summary[category][state] += position.amount;
141+
}
142+
return summary;
143+
};

pnpm-lock.yaml

Lines changed: 11 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)