Skip to content

Commit 9a6e7d1

Browse files
feat(staking): add stake accounts and supply routes (#1957)
* feat(staking): add stake accounts route * go * go * wip * fix * fix * fix
1 parent 9c73bbb commit 9a6e7d1

File tree

8 files changed

+238
-32
lines changed

8 files changed

+238
-32
lines changed

apps/staking/src/api.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -194,7 +194,7 @@ const loadDataForStakeAccount = async (
194194
client: PythStakingClient,
195195
hermesClient: HermesClient,
196196
stakeAccount: PublicKey,
197-
) => {
197+
): Promise<Data> => {
198198
const [
199199
{ publishers, ...baseInfo },
200200
stakeAccountCustody,
@@ -240,7 +240,7 @@ const loadDataForStakeAccount = async (
240240
cooldown: filterGovernancePositions(PositionState.PREUNLOCKING),
241241
cooldown2: filterGovernancePositions(PositionState.UNLOCKING),
242242
},
243-
unlockSchedule,
243+
unlockSchedule: unlockSchedule.schedule,
244244
integrityStakingPublishers: publishers.map((publisher) => ({
245245
...publisher,
246246
positions: {
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import { PythStakingClient } from "@pythnetwork/staking-sdk";
2+
import { WalletAdapterNetwork } from "@solana/wallet-adapter-base";
3+
import { clusterApiUrl, Connection, PublicKey } from "@solana/web3.js";
4+
import type { NextRequest } from "next/server";
5+
import { z } from "zod";
6+
7+
import { IS_MAINNET, RPC } from "../../../config/server";
8+
9+
const UnlockScheduleSchema = z.object({
10+
date: z.date(),
11+
amount: z.number(),
12+
});
13+
14+
const LockSchema = z.object({
15+
type: z.string(),
16+
schedule: z.array(UnlockScheduleSchema),
17+
});
18+
19+
const ResponseSchema = z.array(
20+
z.object({
21+
custodyAccount: z.string(),
22+
actualAmount: z.number(),
23+
lock: LockSchema,
24+
}),
25+
);
26+
27+
const stakingClient = new PythStakingClient({
28+
connection: new Connection(
29+
RPC ??
30+
clusterApiUrl(
31+
IS_MAINNET ? WalletAdapterNetwork.Mainnet : WalletAdapterNetwork.Devnet,
32+
),
33+
),
34+
});
35+
36+
const isValidPublicKey = (publicKey: string) => {
37+
try {
38+
new PublicKey(publicKey);
39+
return true;
40+
} catch {
41+
return false;
42+
}
43+
};
44+
45+
export async function GET(req: NextRequest) {
46+
const owner = req.nextUrl.searchParams.get("owner");
47+
48+
if (owner === null || !isValidPublicKey(owner)) {
49+
return Response.json(
50+
{
51+
error:
52+
"Must provide the 'owner' query parameters as a valid base58 public key",
53+
},
54+
{
55+
status: 400,
56+
},
57+
);
58+
}
59+
60+
const positions = await stakingClient.getAllStakeAccountPositions(
61+
new PublicKey(owner),
62+
);
63+
64+
const responseRaw = await Promise.all(
65+
positions.map(async (position) => {
66+
const custodyAccount =
67+
await stakingClient.getStakeAccountCustody(position);
68+
const lock = await stakingClient.getUnlockSchedule(position, true);
69+
return {
70+
custodyAccount: custodyAccount.address.toBase58(),
71+
actualAmount: Number(custodyAccount.amount),
72+
lock: {
73+
type: lock.type,
74+
schedule: lock.schedule.map((unlock) => ({
75+
date: unlock.date,
76+
amount: Number(unlock.amount),
77+
})),
78+
},
79+
};
80+
}),
81+
);
82+
83+
const response = ResponseSchema.safeParse(responseRaw);
84+
85+
return response.success
86+
? Response.json(response.data)
87+
: Response.json(
88+
{
89+
error: "Internal server error",
90+
},
91+
{
92+
status: 500,
93+
},
94+
);
95+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { PythStakingClient } from "@pythnetwork/staking-sdk";
2+
import { WalletAdapterNetwork } from "@solana/wallet-adapter-base";
3+
import { clusterApiUrl, Connection } from "@solana/web3.js";
4+
import type { NextRequest } from "next/server";
5+
import { z } from "zod";
6+
7+
import { IS_MAINNET, RPC } from "../../../config/server";
8+
9+
const stakingClient = new PythStakingClient({
10+
connection: new Connection(
11+
RPC ??
12+
clusterApiUrl(
13+
IS_MAINNET ? WalletAdapterNetwork.Mainnet : WalletAdapterNetwork.Devnet,
14+
),
15+
),
16+
});
17+
18+
const querySchema = z.enum(["totalSupply", "circulatingSupply"]);
19+
20+
export async function GET(req: NextRequest) {
21+
const query = querySchema.safeParse(req.nextUrl.searchParams.get("q"));
22+
if (!query.success) {
23+
return Response.json(
24+
{
25+
error:
26+
"The 'q' query parameter must be one of 'totalSupply' or 'circulatingSupply'.",
27+
},
28+
{
29+
status: 400,
30+
},
31+
);
32+
}
33+
const q = query.data;
34+
35+
if (q === "circulatingSupply") {
36+
const circulatingSupply = await stakingClient.getCirculatingSupply();
37+
return Response.json(Number(circulatingSupply));
38+
} else {
39+
const pythMint = await stakingClient.getPythTokenMint();
40+
return Response.json(Number(pythMint.supply));
41+
}
42+
}

governance/pyth_staking_sdk/src/constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ const ONE_MINUTE_IN_SECONDS = 60n;
55
const ONE_HOUR_IN_SECONDS = 60n * ONE_MINUTE_IN_SECONDS;
66
const ONE_DAY_IN_SECONDS = 24n * ONE_HOUR_IN_SECONDS;
77
const ONE_WEEK_IN_SECONDS = 7n * ONE_DAY_IN_SECONDS;
8+
export const ONE_YEAR_IN_SECONDS = 365n * ONE_DAY_IN_SECONDS;
89

910
export const EPOCH_DURATION = ONE_WEEK_IN_SECONDS;
1011

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1+
export * from "./pdas";
12
export * from "./pyth-staking-client";
3+
export * from "./types";
4+
export * from "./utils/apy";
25
export * from "./utils/clock";
3-
export * from "./utils/position";
46
export * from "./utils/pool";
5-
export * from "./utils/apy";
6-
export * from "./types";
7+
export * from "./utils/position";
8+
export * from "./utils/vesting";

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

Lines changed: 54 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import {
1212
createTransferInstruction,
1313
getAccount,
1414
getAssociatedTokenAddress,
15+
getMint,
16+
type Mint,
1517
} from "@solana/spl-token";
1618
import type { AnchorWallet } from "@solana/wallet-adapter-react";
1719
import {
@@ -22,7 +24,12 @@ import {
2224
TransactionInstruction,
2325
} from "@solana/web3.js";
2426

25-
import { GOVERNANCE_ADDRESS, POSITIONS_ACCOUNT_SIZE } from "./constants";
27+
import {
28+
FRACTION_PRECISION_N,
29+
GOVERNANCE_ADDRESS,
30+
ONE_YEAR_IN_SECONDS,
31+
POSITIONS_ACCOUNT_SIZE,
32+
} from "./constants";
2633
import {
2734
getConfigAddress,
2835
getDelegationRecordAddress,
@@ -36,6 +43,7 @@ import {
3643
type PoolConfig,
3744
type PoolDataAccount,
3845
type StakeAccountPositions,
46+
type VestingSchedule,
3947
} from "./types";
4048
import { convertBigIntToBN, convertBNToBigInt } from "./utils/bn";
4149
import { epochToDate, getCurrentEpoch } from "./utils/clock";
@@ -56,7 +64,7 @@ import type { Staking } from "../types/staking";
5664

5765
export type PythStakingClientConfig = {
5866
connection: Connection;
59-
wallet: AnchorWallet | undefined;
67+
wallet?: AnchorWallet;
6068
};
6169

6270
export class PythStakingClient {
@@ -105,7 +113,9 @@ export class PythStakingClient {
105113
}
106114

107115
/** Gets a users stake accounts */
108-
public async getAllStakeAccountPositions(): Promise<PublicKey[]> {
116+
public async getAllStakeAccountPositions(
117+
owner?: PublicKey,
118+
): Promise<PublicKey[]> {
109119
const positionDataMemcmp = this.stakingProgram.coder.accounts.memcmp(
110120
"positionData",
111121
) as {
@@ -124,7 +134,7 @@ export class PythStakingClient {
124134
{
125135
memcmp: {
126136
offset: 8,
127-
bytes: this.wallet.publicKey.toBase58(),
137+
bytes: owner?.toBase58() ?? this.wallet.publicKey.toBase58(),
128138
},
129139
},
130140
],
@@ -529,7 +539,10 @@ export class PythStakingClient {
529539
return sendTransaction([instruction], this.connection, this.wallet);
530540
}
531541

532-
public async getUnlockSchedule(stakeAccountPositions: PublicKey) {
542+
public async getUnlockSchedule(
543+
stakeAccountPositions: PublicKey,
544+
includePastPeriods = false,
545+
) {
533546
const stakeAccountMetadataAddress = getStakeAccountMetadataAddress(
534547
stakeAccountPositions,
535548
);
@@ -548,7 +561,38 @@ export class PythStakingClient {
548561
return getUnlockSchedule({
549562
vestingSchedule,
550563
pythTokenListTime: config.pythTokenListTime,
564+
includePastPeriods,
565+
});
566+
}
567+
568+
public async getCirculatingSupply() {
569+
const vestingSchedule: VestingSchedule = {
570+
periodicVestingAfterListing: {
571+
initialBalance: 8_500_000_000n * FRACTION_PRECISION_N,
572+
numPeriods: 4n,
573+
periodDuration: ONE_YEAR_IN_SECONDS,
574+
},
575+
};
576+
577+
const config = await this.getGlobalConfig();
578+
579+
if (config.pythTokenListTime === null) {
580+
throw new Error("Pyth token list time not set in global config");
581+
}
582+
583+
const unlockSchedule = getUnlockSchedule({
584+
vestingSchedule,
585+
pythTokenListTime: config.pythTokenListTime,
586+
includePastPeriods: false,
551587
});
588+
589+
const totalLocked = unlockSchedule.schedule.reduce(
590+
(total, unlock) => total + unlock.amount,
591+
0n,
592+
);
593+
594+
const mint = await this.getPythTokenMint();
595+
return mint.supply - totalLocked;
552596
}
553597

554598
async getAdvanceDelegationRecordInstructions(
@@ -705,4 +749,9 @@ export class PythStakingClient {
705749
undefined,
706750
);
707751
}
752+
753+
public async getPythTokenMint(): Promise<Mint> {
754+
const globalConfig = await this.getGlobalConfig();
755+
return getMint(this.connection, globalConfig.pythTokenMint);
756+
}
708757
}

governance/pyth_staking_sdk/src/types.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,12 @@ export type VestingScheduleAnchor = IdlTypes<Staking>["vestingSchedule"];
3636
export type VestingSchedule = ConvertBNToBigInt<VestingScheduleAnchor>;
3737

3838
export type UnlockSchedule = {
39-
date: Date;
40-
amount: bigint;
41-
}[];
39+
type: "fullyUnlocked" | "periodicUnlockingAfterListing" | "periodicUnlocking";
40+
schedule: {
41+
date: Date;
42+
amount: bigint;
43+
}[];
44+
};
4245

4346
export type StakeAccountPositions = {
4447
address: PublicKey;

governance/pyth_staking_sdk/src/utils/vesting.ts

Lines changed: 33 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -3,26 +3,38 @@ import type { UnlockSchedule, VestingSchedule } from "../types";
33
export const getUnlockSchedule = (options: {
44
pythTokenListTime: bigint;
55
vestingSchedule: VestingSchedule;
6+
includePastPeriods: boolean;
67
}): UnlockSchedule => {
7-
const { vestingSchedule, pythTokenListTime } = options;
8+
const { vestingSchedule, pythTokenListTime, includePastPeriods } = options;
89

910
if (vestingSchedule.fullyVested) {
10-
return [];
11+
return {
12+
type: "fullyUnlocked",
13+
schedule: [],
14+
};
1115
} else if (vestingSchedule.periodicVestingAfterListing) {
12-
return getPeriodicUnlockSchedule({
13-
balance: vestingSchedule.periodicVestingAfterListing.initialBalance,
14-
numPeriods: vestingSchedule.periodicVestingAfterListing.numPeriods,
15-
periodDuration:
16-
vestingSchedule.periodicVestingAfterListing.periodDuration,
17-
startDate: pythTokenListTime,
18-
});
16+
return {
17+
type: "periodicUnlockingAfterListing",
18+
schedule: getPeriodicUnlockSchedule({
19+
balance: vestingSchedule.periodicVestingAfterListing.initialBalance,
20+
numPeriods: vestingSchedule.periodicVestingAfterListing.numPeriods,
21+
periodDuration:
22+
vestingSchedule.periodicVestingAfterListing.periodDuration,
23+
startDate: pythTokenListTime,
24+
includePastPeriods,
25+
}),
26+
};
1927
} else {
20-
return getPeriodicUnlockSchedule({
21-
balance: vestingSchedule.periodicVesting.initialBalance,
22-
numPeriods: vestingSchedule.periodicVesting.numPeriods,
23-
periodDuration: vestingSchedule.periodicVesting.periodDuration,
24-
startDate: vestingSchedule.periodicVesting.startDate,
25-
});
28+
return {
29+
type: "periodicUnlocking",
30+
schedule: getPeriodicUnlockSchedule({
31+
balance: vestingSchedule.periodicVesting.initialBalance,
32+
numPeriods: vestingSchedule.periodicVesting.numPeriods,
33+
periodDuration: vestingSchedule.periodicVesting.periodDuration,
34+
startDate: vestingSchedule.periodicVesting.startDate,
35+
includePastPeriods,
36+
}),
37+
};
2638
}
2739
};
2840

@@ -31,16 +43,18 @@ export const getPeriodicUnlockSchedule = (options: {
3143
startDate: bigint;
3244
periodDuration: bigint;
3345
numPeriods: bigint;
34-
}): UnlockSchedule => {
35-
const { balance, startDate, periodDuration, numPeriods } = options;
46+
includePastPeriods: boolean;
47+
}): UnlockSchedule["schedule"] => {
48+
const { balance, startDate, periodDuration, numPeriods, includePastPeriods } =
49+
options;
3650

37-
const unlockSchedule: UnlockSchedule = [];
51+
const unlockSchedule: UnlockSchedule["schedule"] = [];
3852
const currentTimeStamp = Date.now() / 1000;
3953

4054
for (let i = 0; i < numPeriods; i++) {
4155
const unlockTimeStamp =
4256
Number(startDate) + Number(periodDuration) * (i + 1);
43-
if (currentTimeStamp < unlockTimeStamp) {
57+
if (currentTimeStamp < unlockTimeStamp || includePastPeriods) {
4458
unlockSchedule.push({
4559
date: new Date(unlockTimeStamp * 1000),
4660
amount: balance / numPeriods,

0 commit comments

Comments
 (0)