diff --git a/frontend/pages/api/v1/all_locked_accounts.ts b/frontend/pages/api/v1/all_locked_accounts.ts index ceacb2d2..af5e7aad 100644 --- a/frontend/pages/api/v1/all_locked_accounts.ts +++ b/frontend/pages/api/v1/all_locked_accounts.ts @@ -103,7 +103,7 @@ function hasStandardLockup( ) ) } -async function getAllStakeAccounts(connection: Connection) { +export async function getAllStakeAccounts(connection: Connection) { const response = await connection.getProgramAccounts(STAKING_ADDRESS, { encoding: 'base64', filters: [ diff --git a/frontend/pages/api/v1/cmc/supply.ts b/frontend/pages/api/v1/cmc/supply.ts new file mode 100644 index 00000000..71cc1b22 --- /dev/null +++ b/frontend/pages/api/v1/cmc/supply.ts @@ -0,0 +1,150 @@ +import { NextApiRequest, NextApiResponse } from 'next' +import { PythBalance } from '@pythnetwork/staking/app/pythBalance' +import BN from 'bn.js' +import { STAKING_ADDRESS } from '@pythnetwork/staking/app/constants' +import { PYTH_TOKEN } from '@pythnetwork/staking/app/deploy/mainnet_beta' +import { Connection, Keypair, PublicKey } from '@solana/web3.js' +import { Program, AnchorProvider, IdlAccounts } from '@coral-xyz/anchor' +import NodeWallet from '@coral-xyz/anchor/dist/cjs/nodewallet' +import { Staking } from '@pythnetwork/staking/lib/target/types/staking' +import idl from '@pythnetwork/staking/target/idl/staking.json' +import { splTokenProgram } from '@coral-xyz/spl-token' +import { TOKEN_PROGRAM_ID } from '@solana/spl-token' +import { + getConfig, + getCustodyAccountAddress, + getMetadataAccountAddress, +} from './../locked_accounts' +import { getAllStakeAccounts } from '../all_locked_accounts' + +const connection = new Connection(process.env.BACKEND_ENDPOINT!) +const provider = new AnchorProvider( + connection, + new NodeWallet(new Keypair()), + {} +) +const stakingProgram = new Program( + idl as Staking, + STAKING_ADDRESS, + provider +) +const tokenProgram = splTokenProgram({ + programId: TOKEN_PROGRAM_ID, + provider: provider as any, +}) + +export default async function handlerSupply( + req: NextApiRequest, + res: NextApiResponse +) { + const { q } = req.query + + if (q === 'totalSupply') { + res.setHeader('Cache-Control', 'max-age=0, s-maxage=3600') + res.status(200).send((await getTotalSupply(tokenProgram)).toString(false)) + } else if (q === 'circulatingSupply') { + const configAccountData = await getConfig(stakingProgram) + const allStakeAccounts = await getAllStakeAccounts(connection) + + const allMetadataAccountAddresses = allStakeAccounts.map((account) => + getMetadataAccountAddress(account) + ) + const allCustodyAccountAddresses = allStakeAccounts.map((account) => + getCustodyAccountAddress(account) + ) + + const allMetadataAccounts = + await stakingProgram.account.stakeAccountMetadataV2.fetchMultiple( + allMetadataAccountAddresses + ) + const allCustodyAccounts = await tokenProgram.account.account.fetchMultiple( + allCustodyAccountAddresses + ) + + const lockedCustodyAccounts = allCustodyAccounts.map((data, index) => { + return { lock: allMetadataAccounts[index], amount: data?.amount } + }) + + const totalLockedAmount = lockedCustodyAccounts.reduce((total, account) => { + return total.add( + account.amount && account.lock + ? new PythBalance(account.amount).min( + getCurrentlyLockedAmount(account.lock, configAccountData) + ) + : PythBalance.zero() + ) + }, PythBalance.zero()) + + res.setHeader('Cache-Control', 'max-age=0, s-maxage=3600') + res + .status(200) + .send( + (await getTotalSupply(tokenProgram)) + .sub(totalLockedAmount) + .toString(false) + ) + } else { + res.status(400).send({ + error: + "The 'q' query parameter must be one of 'totalSupply' or 'circulatingSupply'.", + }) + } +} + +function getCurrentlyLockedAmount( + metadataAccountData: IdlAccounts['stakeAccountMetadataV2'], + configAccountData: IdlAccounts['globalConfig'] +): PythBalance { + const lock = metadataAccountData.lock + const listTime = configAccountData.pythTokenListTime + if (lock.fullyVested) { + return PythBalance.zero() + } else if (lock.periodicVestingAfterListing) { + if (!listTime) { + return new PythBalance(lock.periodicVestingAfterListing.initialBalance) + } else { + return getCurrentlyLockedAmountPeriodic( + listTime, + lock.periodicVestingAfterListing.periodDuration, + lock.periodicVestingAfterListing.numPeriods, + lock.periodicVestingAfterListing.initialBalance + ) + } + } else if (lock.periodicVesting) { + return getCurrentlyLockedAmountPeriodic( + lock.periodicVesting.startDate, + lock.periodicVesting.periodDuration, + lock.periodicVesting.numPeriods, + lock.periodicVesting.initialBalance + ) + } else { + throw new Error('Should be unreachable') + } +} + +function getCurrentlyLockedAmountPeriodic( + startDate: BN, + periodDuration: BN, + numPeriods: BN, + initialBalance: BN +): PythBalance { + const currentTimestamp = new BN(Math.floor(Date.now() / 1000)) + if (currentTimestamp.lte(startDate)) { + return new PythBalance(initialBalance) + } else { + const periodsElapsed = startDate.sub(currentTimestamp).div(periodDuration) + if (periodsElapsed.gte(numPeriods)) { + return PythBalance.zero() + } else { + const remainingPeriods = numPeriods.sub(periodsElapsed) + return new PythBalance( + remainingPeriods.mul(initialBalance).div(numPeriods) + ) + } + } +} + +async function getTotalSupply(tokenProgram: any): Promise { + const pythTokenMintData = await tokenProgram.account.mint.fetch(PYTH_TOKEN) + return new PythBalance(pythTokenMintData.supply) +} diff --git a/frontend/pages/api/v1/locked_accounts.ts b/frontend/pages/api/v1/locked_accounts.ts index c9dd56e4..1e908136 100644 --- a/frontend/pages/api/v1/locked_accounts.ts +++ b/frontend/pages/api/v1/locked_accounts.ts @@ -4,7 +4,7 @@ import BN from 'bn.js' import { STAKING_ADDRESS } from '@pythnetwork/staking/app/constants' import { Connection, Keypair, PublicKey } from '@solana/web3.js' import { bs58 } from '@coral-xyz/anchor/dist/cjs/utils/bytes' -import { Program, AnchorProvider } from '@coral-xyz/anchor' +import { Program, AnchorProvider, IdlAccounts } from '@coral-xyz/anchor' import NodeWallet from '@coral-xyz/anchor/dist/cjs/nodewallet' import { Staking } from '@pythnetwork/staking/lib/target/types/staking' import idl from '@pythnetwork/staking/target/idl/staking.json' @@ -51,14 +51,18 @@ export default async function handlerLockedAccounts( } } -async function getStakeAccountDetails(positionAccountAddress: PublicKey) { +export async function getConfig( + stakingProgram: Program +): Promise['globalConfig']> { const configAccountAddress = PublicKey.findProgramAddressSync( [Buffer.from('config')], STAKING_ADDRESS )[0] - const configAccountData = await stakingProgram.account.globalConfig.fetch( - configAccountAddress - ) + return await stakingProgram.account.globalConfig.fetch(configAccountAddress) +} + +async function getStakeAccountDetails(positionAccountAddress: PublicKey) { + const configAccountData = await getConfig(stakingProgram) const metadataAccountAddress = getMetadataAccountAddress( positionAccountAddress diff --git a/staking/app/pythBalance.ts b/staking/app/pythBalance.ts index 6d61b184..a767c026 100644 --- a/staking/app/pythBalance.ts +++ b/staking/app/pythBalance.ts @@ -49,18 +49,19 @@ export class PythBalance { } } - toString(): string { + toString(commas = true): string { const padded = this.toBN() .toString() .padStart(PYTH_DECIMALS + 1, "0"); - return ( - addCommas(padded.slice(0, padded.length - PYTH_DECIMALS)) + - ("." + padded.slice(padded.length - PYTH_DECIMALS)).replace( - TRAILING_ZEROS, - "" - ) - ); + const integerPart = padded.slice(0, padded.length - PYTH_DECIMALS); + return commas + ? addCommas(integerPart) + : integerPart + + ("." + padded.slice(padded.length - PYTH_DECIMALS)).replace( + TRAILING_ZEROS, + "" + ); } toBN() { @@ -91,9 +92,17 @@ export class PythBalance { return new PythBalance(other.toBN().add(this.toBN())); } + sub(other: PythBalance): PythBalance { + return new PythBalance(this.toBN().sub(other.toBN())); + } + isZero(): boolean { return this.eq(PythBalance.zero()); } + + min(other: PythBalance): PythBalance { + return this.lt(other) ? this : other; + } } const addCommas = (x: string) => { diff --git a/vercel.json b/vercel.json index ed7450be..bc978dc8 100644 --- a/vercel.json +++ b/vercel.json @@ -2,6 +2,9 @@ "functions": { "pages/api/v1/all_locked_accounts.ts": { "maxDuration": 30 + }, + "pages/api/v1/cmc/supply.ts": { + "maxDuration": 30 } } }