Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions apps/staking/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,7 @@ const loadDataForStakeAccount = async (
client: PythStakingClient,
hermesClient: HermesClient,
stakeAccount: PublicKey,
) => {
): Promise<Data> => {
const [
{ publishers, ...baseInfo },
stakeAccountCustody,
Expand Down Expand Up @@ -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: {
Expand Down
61 changes: 61 additions & 0 deletions apps/staking/src/app/api/stake-accounts/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { PythStakingClient } from "@pythnetwork/staking-sdk";
import { Connection, PublicKey } from "@solana/web3.js";
import { type NextRequest } from "next/server";

import { RPC } from "../../../config/server";

type ResponseType = {
custodyAccount: string;
actualAmount: number;
lock: {
type: string;
schedule: {
date: Date;
amount: number;
}[];
};
}[];

const stakingClient = new PythStakingClient({
connection: new Connection(RPC ?? "https://api.devnet.solana.com"),
});

export async function GET(req: NextRequest) {
const owner = req.nextUrl.searchParams.get("owner");

if (owner === null) {
return Response.json(
{
error: "Must provide the 'owner' query parameters",
},
{
status: 400,
},
);
}

const positions = await stakingClient.getAllStakeAccountPositions(
new PublicKey(owner),
);

const response: ResponseType = 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),
})),
},
};
}),
);

return Response.json(response);
}
45 changes: 45 additions & 0 deletions apps/staking/src/app/api/supply/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { PythStakingClient } from "@pythnetwork/staking-sdk";
import { Connection } from "@solana/web3.js";
import { type NextRequest } from "next/server";

import { RPC } from "../../../config/server";

const stakingClient = new PythStakingClient({
connection: new Connection(RPC ?? "https://api.devnet.solana.com"),
});

function validateQ(q: string | null): q is "totalSupply" | "circulatingSupply" {
return q !== null && ["totalSupply", "circulatingSupply"].includes(q);
}

function getResponse(data: unknown) {
return Response.json(data, {
headers: {
"Cache-Control": "max-age=0, s-maxage=3600",
},
});
}

export async function GET(req: NextRequest) {
const q = req.nextUrl.searchParams.get("q");

if (!validateQ(q)) {
return Response.json(
{
error:
"The 'q' query parameter must be one of 'totalSupply' or 'circulatingSupply'.",
},
{
status: 400,
},
);
}

if (q === "circulatingSupply") {
const circulatingSupply = await stakingClient.getCirculatingSupply();
return getResponse(Number(circulatingSupply));
} else {
const pythMint = await stakingClient.getPythTokenMint();
return getResponse(Number(pythMint.supply));
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems a bit odd to me to have a single endpoint that has a query param that enables it to do two totally different and unrelated things. Why not just split these into separate endpoints?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i 100% agree, but let's keep it the same for now since other users are already integrated using this with the old ui.

}
1 change: 1 addition & 0 deletions governance/pyth_staking_sdk/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
8 changes: 5 additions & 3 deletions governance/pyth_staking_sdk/src/index.ts
Original file line number Diff line number Diff line change
@@ -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";
58 changes: 53 additions & 5 deletions governance/pyth_staking_sdk/src/pyth-staking-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import {
createTransferInstruction,
getAccount,
getAssociatedTokenAddress,
getMint,
type Mint,
} from "@solana/spl-token";
import type { AnchorWallet } from "@solana/wallet-adapter-react";
import {
Expand All @@ -21,7 +23,11 @@ import {
TransactionInstruction,
} from "@solana/web3.js";

import { GOVERNANCE_ADDRESS, POSITIONS_ACCOUNT_SIZE } from "./constants";
import {
GOVERNANCE_ADDRESS,
ONE_YEAR_IN_SECONDS,
POSITIONS_ACCOUNT_SIZE,
} from "./constants";
import {
getConfigAddress,
getDelegationRecordAddress,
Expand All @@ -35,6 +41,7 @@ import {
type PoolConfig,
type PoolDataAccount,
type StakeAccountPositions,
type VestingSchedule,
} from "./types";
import { convertBigIntToBN, convertBNToBigInt } from "./utils/bn";
import { epochToDate, getCurrentEpoch } from "./utils/clock";
Expand All @@ -55,7 +62,7 @@ import type { Staking } from "../types/staking";

export type PythStakingClientConfig = {
connection: Connection;
wallet: AnchorWallet | undefined;
wallet?: AnchorWallet;
};

export class PythStakingClient {
Expand Down Expand Up @@ -104,7 +111,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 {
Expand All @@ -123,7 +132,7 @@ export class PythStakingClient {
{
memcmp: {
offset: 8,
bytes: this.wallet.publicKey.toBase58(),
bytes: owner?.toBase58() ?? this.wallet.publicKey.toBase58(),
},
},
],
Expand Down Expand Up @@ -511,7 +520,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,
);
Expand All @@ -530,9 +542,40 @@ export class PythStakingClient {
return getUnlockSchedule({
vestingSchedule,
pythTokenListTime: config.pythTokenListTime,
includePastPeriods,
});
}

public async getCirculatingSupply() {
let circulatingSupply = 8_500_000_000n;

const vestingSchedule: VestingSchedule = {
periodicVestingAfterListing: {
initialBalance: circulatingSupply,
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,
});

for (const unlock of unlockSchedule.schedule) {
circulatingSupply -= unlock.amount;
}

return circulatingSupply;
}

async getAdvanceDelegationRecordInstructions(
stakeAccountPositions: PublicKey,
) {
Expand Down Expand Up @@ -687,4 +730,9 @@ export class PythStakingClient {
undefined,
);
}

public async getPythTokenMint(): Promise<Mint> {
const globalConfig = await this.getGlobalConfig();
return getMint(this.connection, globalConfig.pythTokenMint);
}
}
9 changes: 6 additions & 3 deletions governance/pyth_staking_sdk/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
52 changes: 33 additions & 19 deletions governance/pyth_staking_sdk/src/utils/vesting.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}),
};
}
};

Expand All @@ -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,
Expand Down
Loading