diff --git a/apps/staking/public/publisher-icons/blocksize.png b/apps/staking/public/publisher-icons/blocksize.png new file mode 100644 index 0000000000..f93928d89e Binary files /dev/null and b/apps/staking/public/publisher-icons/blocksize.png differ diff --git a/apps/staking/src/api.ts b/apps/staking/src/api.ts index 26b012a94f..9759488b20 100644 --- a/apps/staking/src/api.ts +++ b/apps/staking/src/api.ts @@ -5,12 +5,15 @@ import { getAmountByTargetAndState, getCurrentEpoch, PositionState, + PythnetClient, PythStakingClient, type StakeAccountPositions, } from "@pythnetwork/staking-sdk"; import { PublicKey } from "@solana/web3.js"; import { z } from "zod"; +import { KNOWN_PUBLISHERS } from "./known-publishers"; + const publishersRankingSchema = z .object({ publisher: z.string(), @@ -43,8 +46,15 @@ type Data = { cooldown2: bigint; }; yieldRate: bigint; + m: bigint; + z: bigint; integrityStakingPublishers: { - name: string | undefined; + identity: + | { + name: string; + icon: string; + } + | undefined; publicKey: PublicKey; stakeAccount: PublicKey | undefined; selfStake: bigint; @@ -54,7 +64,8 @@ type Data = { poolUtilizationDelta: bigint; numFeeds: number; qualityRanking: number; - apyHistory: { date: Date; apy: number }[]; + delegationFee: bigint; + apyHistory: { date: Date; apy: number; selfApy: number }[]; positions?: | { warmup?: bigint | undefined; @@ -95,18 +106,29 @@ export const getStakeAccount = async ( export const loadData = async ( client: PythStakingClient, + pythnetClient: PythnetClient, hermesClient: HermesClient, stakeAccount?: PublicKey | undefined, ): Promise => stakeAccount === undefined - ? loadDataNoStakeAccount(client, hermesClient) - : loadDataForStakeAccount(client, hermesClient, stakeAccount); + ? loadDataNoStakeAccount(client, pythnetClient, hermesClient) + : loadDataForStakeAccount( + client, + pythnetClient, + hermesClient, + stakeAccount, + ); const loadDataNoStakeAccount = async ( client: PythStakingClient, + pythnetClient: PythnetClient, hermesClient: HermesClient, ): Promise => { - const { publishers, ...baseInfo } = await loadBaseInfo(client, hermesClient); + const { publishers, ...baseInfo } = await loadBaseInfo( + client, + pythnetClient, + hermesClient, + ); return { ...baseInfo, @@ -127,6 +149,7 @@ const loadDataNoStakeAccount = async ( const loadDataForStakeAccount = async ( client: PythStakingClient, + pythnetClient: PythnetClient, hermesClient: HermesClient, stakeAccount: PublicKey, ): Promise => { @@ -137,7 +160,7 @@ const loadDataForStakeAccount = async ( claimableRewards, stakeAccountPositions, ] = await Promise.all([ - loadBaseInfo(client, hermesClient), + loadBaseInfo(client, pythnetClient, hermesClient), client.getStakeAccountCustody(stakeAccount), client.getUnlockSchedule(stakeAccount), client.getClaimableRewards(stakeAccount), @@ -196,45 +219,61 @@ const loadDataForStakeAccount = async ( const loadBaseInfo = async ( client: PythStakingClient, + pythnetClient: PythnetClient, hermesClient: HermesClient, ) => { - const [publishers, walletAmount, poolConfig, currentEpoch] = + const [publishers, walletAmount, poolConfig, currentEpoch, parameters] = await Promise.all([ - loadPublisherData(client, hermesClient), + loadPublisherData(client, pythnetClient, hermesClient), client.getOwnerPythBalance(), client.getPoolConfigAccount(), getCurrentEpoch(client.connection), + pythnetClient.getStakeCapParameters(), ]); - return { yieldRate: poolConfig.y, walletAmount, publishers, currentEpoch }; + return { + yieldRate: poolConfig.y, + walletAmount, + publishers, + currentEpoch, + m: parameters.m, + z: parameters.z, + }; }; const loadPublisherData = async ( client: PythStakingClient, + pythnetClient: PythnetClient, hermesClient: HermesClient, ) => { - const [poolData, publisherRankings, publisherCaps] = await Promise.all([ - client.getPoolDataAccount(), - getPublisherRankings(), - hermesClient.getLatestPublisherCaps({ - parsed: true, - }), - ]); + const [poolData, publisherRankings, publisherCaps, publisherNumberOfSymbols] = + await Promise.all([ + client.getPoolDataAccount(), + getPublisherRankings(), + hermesClient.getLatestPublisherCaps({ + parsed: true, + }), + pythnetClient.getPublisherNumberOfSymbols(), + ]); return extractPublisherData(poolData).map((publisher) => { const publisherPubkeyString = publisher.pubkey.toBase58(); const publisherRanking = publisherRankings.find( (ranking) => ranking.publisher === publisherPubkeyString, ); - const apyHistory = publisher.apyHistory.map(({ epoch, apy }) => ({ + const numberOfSymbols = publisherNumberOfSymbols[publisherPubkeyString]; + const apyHistory = publisher.apyHistory.map(({ epoch, apy, selfApy }) => ({ date: epochToDate(epoch + 1n), apy, + selfApy, })); return { apyHistory, - name: undefined, // TODO - numFeeds: publisherRanking?.numSymbols ?? 0, + identity: ( + KNOWN_PUBLISHERS as Record + )[publisher.pubkey.toBase58()], + numFeeds: numberOfSymbols ?? 0, poolCapacity: getPublisherCap(publisherCaps, publisher.pubkey), poolUtilization: publisher.totalDelegation, poolUtilizationDelta: publisher.totalDelegationDelta, @@ -243,6 +282,7 @@ const loadPublisherData = async ( selfStake: publisher.selfDelegation, selfStakeDelta: publisher.selfDelegationDelta, stakeAccount: publisher.stakeAccount ?? undefined, + delegationFee: publisher.delegationFee, }; }); }; diff --git a/apps/staking/src/components/Changelog/index.tsx b/apps/staking/src/components/Changelog/index.tsx new file mode 100644 index 0000000000..3d6b26351d --- /dev/null +++ b/apps/staking/src/components/Changelog/index.tsx @@ -0,0 +1,198 @@ +"use client"; + +import type { ReactNode } from "react"; +import { useDateFormatter } from "react-aria"; + +import { useChangelog } from "../../hooks/use-changelog"; +import { Link } from "../Link"; +import { ModalDialog } from "../ModalDialog"; + +export const Changelog = () => { + const { isOpen, toggleOpen } = useChangelog(); + + return ( + +
    + {messages.map(({ id, message }) => ( +
  • {message}
  • + ))} +
+
+ ); +}; + +type ChangelogMessageProps = { + date: Date; + children: ReactNode | ReactNode[]; +}; + +export const ChangelogMessage = ({ date, children }: ChangelogMessageProps) => { + const dateFormatter = useDateFormatter({ + year: "numeric", + month: "short", + day: "numeric", + }); + + return ( +
+

+ {dateFormatter.format(date)} +

+ {children} +
+ ); +}; + +type ChangelogSectionProps = { + title: ReactNode; + children: ReactNode | ReactNode[]; +}; + +export const ChangelogSection = ({ + title, + children, +}: ChangelogSectionProps) => ( +
+

{title}

+
+ {children} +
+
+); + +export const messages = [ + { + id: 1, + message: ( + + +
+

+ We are pleased to announce the following Oracle Integrity Staking + milestones: +

+
    +
  • 145M PYTH staked and securing DeFi.
  • +
  • 10K unique stakers participating.
  • +
  • 492K in PYTH programmatically distributed.
  • +
+
+

We’re thrilled to see so many community participants.

+
+ +
    +
  • + New sort filter for publishers list. Publishers with self-stake + are displayed first by default. You can sort by publisher details, + pool composition, and more. +
  • +
  • + Publishers interested in de-anonymizing themselves can have their + names displayed in the publisher list. +
  • +
  • New OIS live stats added to navigation bar.
  • +
  • + New dialogue added under “Help” where you can view current program + parameters. +
  • +
  • + Option to remove PYTH from the smart contract program for parties + with restricted access to the staking frontend. +
  • +
  • + Full access to Pyth Governance for certain restricted + jurisdictions. +
  • +
  • APYs are now shown as net of delegation fees.
  • +
  • + Updates to educational materials (all Guides and FAQs) for clarity + and readability. +
  • +
  • + New Oracle Integrity Staking{" "} + + discussion catalogue + {" "} + opened in Pyth DAO forum. Let the community know your thoughts and + feedback! +
  • +
+
+ +

+ The Pyth contributors take security extremely seriously. The + contract code is{" "} + + open source + {" "} + and the upgrade authority is governed by the Pyth DAO. The official{" "} + + audit report + {" "} + is publicly accessible. All on-chain contract codes are verified + using{" "} + + Solana verifiable build + {" "} + and the Pyth DAO governs the upgrade authority. +

+
+ +

+ Please remember that publishers have priority for programmatic + rewards distributions. By protocol design, if a pool’s stake cap is + exceeded, the programmatic reward rate for other stakers + participating in that pool will be lower than the Pyth DAO-set + maximum reward rate. +

+
+ +

+ The Pyth contributors are glad to see so many network participants + getting involved with Oracle Integrity Staking to help secure the + oracle and protect the wider DeFi industry. OIS wouldn’t be possible + without you! +

+
+ +

+ Please reach out in the official{" "} + + Pyth Discord + {" "} + or the{" "} + + Pyth DAO Forum + {" "} + to share your questions, ideas, or feedback. We want to hear what + you think. +

+
+
+ ), + }, +]; diff --git a/apps/staking/src/components/CopyButton/index.tsx b/apps/staking/src/components/CopyButton/index.tsx index 7c27e02e70..5a7e5c4531 100644 --- a/apps/staking/src/components/CopyButton/index.tsx +++ b/apps/staking/src/components/CopyButton/index.tsx @@ -52,7 +52,7 @@ export const CopyButton = ({ onPress={copy} isDisabled={isCopied} className={clsx( - "group -mx-2 -mt-0.5 rounded-md px-2 py-0.5 align-middle transition hover:bg-white/10 focus:outline-none focus-visible:ring-1 focus-visible:ring-pythpurple-400", + "group mx-[-0.25em] -mt-0.5 inline-block rounded-md px-[0.25em] py-0.5 transition hover:bg-white/10 focus:outline-none focus-visible:ring-1 focus-visible:ring-pythpurple-400", className, )} {...(isCopied && { "data-is-copied": true })} @@ -60,13 +60,13 @@ export const CopyButton = ({ > {(...args) => ( <> - + {typeof children === "function" ? children(...args) : children} - + -
Copy code to clipboaord
+
Copy to clipboard
diff --git a/apps/staking/src/components/Dashboard/index.tsx b/apps/staking/src/components/Dashboard/index.tsx index 75fc14de20..9eea46b957 100644 --- a/apps/staking/src/components/Dashboard/index.tsx +++ b/apps/staking/src/components/Dashboard/index.tsx @@ -249,8 +249,7 @@ const useIntegrityStakingSum = ( publishers .map((publisher) => publisher.positions?.[field] ?? 0n) .reduce((acc, cur) => acc + cur, 0n), - // eslint-disable-next-line react-hooks/exhaustive-deps - publishers.map((publisher) => publisher.positions?.[field]), + [publishers, field], ); // eslint-disable-next-line unicorn/no-array-reduce diff --git a/apps/staking/src/components/Header/current-stake-account.tsx b/apps/staking/src/components/Header/current-stake-account.tsx index 553be7578a..1356124318 100644 --- a/apps/staking/src/components/Header/current-stake-account.tsx +++ b/apps/staking/src/components/Header/current-stake-account.tsx @@ -20,7 +20,7 @@ export const CurrentStakeAccount = ({ return api.type === ApiStateType.Loaded && !isBlocked ? (
Stake account:
{api.account} diff --git a/apps/staking/src/components/Header/help-menu.tsx b/apps/staking/src/components/Header/help-menu.tsx index a617f05ab4..fece5a6e35 100644 --- a/apps/staking/src/components/Header/help-menu.tsx +++ b/apps/staking/src/components/Header/help-menu.tsx @@ -7,6 +7,9 @@ import { import { useState, useCallback } from "react"; import { MenuTrigger, Button } from "react-aria-components"; +import { ProgramParameters } from "./program-parameters"; +import { StateType, useApi } from "../../hooks/use-api"; +import { useChangelog } from "../../hooks/use-changelog"; import { GeneralFaq } from "../GeneralFaq"; import { GovernanceGuide } from "../GovernanceGuide"; import { Menu, MenuItem, Section, Separator } from "../Menu"; @@ -14,6 +17,7 @@ import { OracleIntegrityStakingGuide } from "../OracleIntegrityStakingGuide"; import { PublisherFaq } from "../PublisherFaq"; export const HelpMenu = () => { + const api = useApi(); const [faqOpen, setFaqOpen] = useState(false); const openFaq = useCallback(() => { setFaqOpen(true); @@ -34,12 +38,18 @@ export const HelpMenu = () => { setPublisherFaqOpen(true); }, [setPublisherFaqOpen]); + const [parametersOpen, setParametersOpen] = useState(false); + const openParameters = useCallback(() => { + setParametersOpen(true); + }, [setParametersOpen]); + const { open: openChangelog } = useChangelog(); + return ( <> - @@ -65,6 +75,16 @@ export const HelpMenu = () => { Data Publisher Guide + +
+ {(api.type === StateType.Loaded || + api.type === StateType.LoadedNoStakeAccount) && ( + + Current Program Parameters + + )} + Changelog +
@@ -80,6 +100,14 @@ export const HelpMenu = () => { isOpen={publisherFaqOpen} onOpenChange={setPublisherFaqOpen} /> + {(api.type === StateType.Loaded || + api.type === StateType.LoadedNoStakeAccount) && ( + + )} ); }; diff --git a/apps/staking/src/components/Header/index.tsx b/apps/staking/src/components/Header/index.tsx index 7772af7273..f04896dc40 100644 --- a/apps/staking/src/components/Header/index.tsx +++ b/apps/staking/src/components/Header/index.tsx @@ -5,6 +5,7 @@ import { CurrentStakeAccount } from "./current-stake-account"; import { HelpMenu } from "./help-menu"; import Logo from "./logo.svg"; import Logomark from "./logomark.svg"; +import { Stats } from "./stats"; import { Link } from "../Link"; import { MaxWidth } from "../MaxWidth"; import { WalletButton } from "../WalletButton"; @@ -13,22 +14,31 @@ export const Header = ({ className, ...props }: Omit, "children">) => ( -
-
- - - - - -
- - - -
-
-
-
+ <> +
+
+ +
+ + + + + +
+
+ + + +
+
+
+
+ + ); diff --git a/apps/staking/src/components/Header/program-parameters.tsx b/apps/staking/src/components/Header/program-parameters.tsx new file mode 100644 index 0000000000..16fdbe652c --- /dev/null +++ b/apps/staking/src/components/Header/program-parameters.tsx @@ -0,0 +1,101 @@ +import type { ComponentProps, ReactNode } from "react"; + +import type { StateType, States } from "../../hooks/use-api"; +import { StateType as DataStateType, useData } from "../../hooks/use-data"; +import { tokensToString } from "../../tokens"; +import { Link } from "../Link"; +import { ModalDialog } from "../ModalDialog"; +import { Tokens } from "../Tokens"; + +const ONE_SECOND_IN_MS = 1000; +const ONE_MINUTE_IN_MS = 60 * ONE_SECOND_IN_MS; +const REFRESH_INTERVAL = 1 * ONE_MINUTE_IN_MS; + +type Props = Omit, "title" | "children"> & { + api: States[StateType.Loaded] | States[StateType.LoadedNoStakeAccount]; +}; + +export const ProgramParameters = ({ api, ...props }: Props) => { + const data = useData(api.dashboardDataCacheKey, api.loadData, { + refreshInterval: REFRESH_INTERVAL, + }); + + return ( + + See the current program parameters. For more details, see{" "} + + the docs + + + } + {...props} + > +
    + {data.data.m} + ) : ( + + ) + } + variable="M" + > + A constant parameter representing the target stake per symbol + + + ) + } + variable="Z" + > + A constant parameter to control cap contribution from symbols with a + low number of publishers + + + ) + } + variable="y" + > + The cap to the rate of rewards for any pool + +
+
+ ); +}; + +type ParameterProps = { + value: ReactNode; + variable: ReactNode; + children: ReactNode; +}; + +const Parameter = ({ variable, value, children }: ParameterProps) => ( +
  • +
    + {variable} +
    +
    {value}
    +

    {children}

    +
  • +); + +const Loading = () => ( +
    +); diff --git a/apps/staking/src/components/Header/stats.tsx b/apps/staking/src/components/Header/stats.tsx new file mode 100644 index 0000000000..65a6d33b61 --- /dev/null +++ b/apps/staking/src/components/Header/stats.tsx @@ -0,0 +1,81 @@ +"use client"; + +import { PythStakingClient } from "@pythnetwork/staking-sdk"; +import { useConnection } from "@solana/wallet-adapter-react"; +import { Connection } from "@solana/web3.js"; +import clsx from "clsx"; +import type { HTMLProps } from "react"; + +import { StateType, useData } from "../../hooks/use-data"; +import { Tokens } from "../Tokens"; + +const ONE_SECOND_IN_MS = 1000; +const ONE_MINUTE_IN_MS = 60 * ONE_SECOND_IN_MS; +const REFRESH_INTERVAL = 1 * ONE_MINUTE_IN_MS; +const INITIAL_REWARD_POOL_SIZE = 60_000_000_000_000n; + +export const Stats = ({ className, ...props }: HTMLProps) => { + const { connection } = useConnection(); + const state = useData("poolStats", () => fetchStats(connection), { + refreshInterval: REFRESH_INTERVAL, + }); + + return ( +
    +
    + {state.type === StateType.Loaded ? ( + + {state.data.totalStaked} + + ) : ( + + )} +
    + OIS Total Staked +
    +
    +
    +
    + {state.type === StateType.Loaded ? ( + + {state.data.rewardsDistributed} + + ) : ( + + )} +
    + OIS Rewards Distributed +
    +
    +
    + ); +}; + +const Loading = () => ( +
    +); + +const fetchStats = async (connection: Connection) => { + const client = new PythStakingClient({ connection }); + const [poolData, rewardCustodyAccount] = await Promise.all([ + client.getPoolDataAccount(), + client.getRewardCustodyAccount(), + ]); + + return { + totalStaked: + sumDelegations(poolData.delState) + sumDelegations(poolData.selfDelState), + rewardsDistributed: + poolData.claimableRewards + + INITIAL_REWARD_POOL_SIZE - + rewardCustodyAccount.amount, + }; +}; + +const sumDelegations = ( + values: { totalDelegation: bigint; deltaDelegation: bigint }[], +) => + values.reduce( + (acc, value) => acc + value.totalDelegation + value.deltaDelegation, + 0n, + ); diff --git a/apps/staking/src/components/ModalDialog/index.tsx b/apps/staking/src/components/ModalDialog/index.tsx index 7997cfbc0a..3886135f88 100644 --- a/apps/staking/src/components/ModalDialog/index.tsx +++ b/apps/staking/src/components/ModalDialog/index.tsx @@ -1,3 +1,5 @@ +"use client"; + import { XMarkIcon } from "@heroicons/react/24/outline"; import clsx from "clsx"; import type { ComponentProps, ReactNode } from "react"; diff --git a/apps/staking/src/components/OracleIntegrityStaking/index.tsx b/apps/staking/src/components/OracleIntegrityStaking/index.tsx index 4a88c48cd5..a4b8e68dbf 100644 --- a/apps/staking/src/components/OracleIntegrityStaking/index.tsx +++ b/apps/staking/src/components/OracleIntegrityStaking/index.tsx @@ -10,6 +10,7 @@ import { import { calculateApy } from "@pythnetwork/staking-sdk"; import { PublicKey } from "@solana/web3.js"; import clsx from "clsx"; +import Image from "next/image"; import { useMemo, useCallback, @@ -190,13 +191,13 @@ const SelfStaking = ({

    Self Staking

    - {self} - +
    @@ -353,10 +354,12 @@ const ReassignStakeAccount = ({ closeDisabled={closeDisabled} description={ <> - + Designate a different stake account as the self-staking account for - {self} + + {self} + } {...props} @@ -603,7 +606,7 @@ const PublisherList = ({ const scrollTarget = useRef(null); const [search, setSearch] = useState(""); const [yoursFirst, setYoursFirst] = useState(true); - const [sort, setSort] = useState(SortOption.RemainingPoolDescending); + const [sort, setSort] = useState(SortOption.SelfStakeDescending); const filter = useFilter({ sensitivity: "base", usage: "search" }); const [currentPage, setPage] = useState(1); const collator = useCollator(); @@ -613,8 +616,8 @@ const PublisherList = ({ .filter( (publisher) => filter.contains(publisher.publicKey.toBase58(), search) || - (publisher.name !== undefined && - filter.contains(publisher.name, search)), + (publisher.identity !== undefined && + filter.contains(publisher.identity.name, search)), ) .sort((a, b) => { if (yoursFirst) { @@ -626,7 +629,7 @@ const PublisherList = ({ return 1; } } - return doSort(collator, a, b, yieldRate, sort); + return compare(collator, a, b, yieldRate, sort); }), [publishers, search, sort, filter, yieldRate, yoursFirst, collator], ); @@ -938,7 +941,7 @@ const getPageRange = ( return { first, count: Math.min(numPages - first + 1, 5) }; }; -const doSort = ( +const compare = ( collator: Intl.Collator, a: PublisherProps["publisher"], b: PublisherProps["publisher"], @@ -948,80 +951,165 @@ const doSort = ( switch (sort) { case SortOption.PublisherNameAscending: case SortOption.PublisherNameDescending: { - const value = collator.compare( - a.name ?? a.publicKey.toBase58(), - b.name ?? b.publicKey.toBase58(), + // No need for a fallback sort since each publisher has a unique value. + return compareName( + collator, + a, + b, + sort === SortOption.PublisherNameAscending, ); - return sort === SortOption.PublisherNameAscending ? -1 * value : value; } case SortOption.ApyAscending: case SortOption.ApyDescending: { - const value = - calculateApy({ - isSelf: false, - selfStake: a.selfStake + a.selfStakeDelta, - poolCapacity: a.poolCapacity, - poolUtilization: a.poolUtilization + a.poolUtilizationDelta, - yieldRate, - }) - - calculateApy({ - isSelf: false, - selfStake: b.selfStake + b.selfStakeDelta, - poolCapacity: b.poolCapacity, - poolUtilization: b.poolUtilization + b.poolUtilizationDelta, - yieldRate, - }); - return sort === SortOption.ApyDescending ? -1 * value : value; - } - case SortOption.NumberOfFeedsAscending: { - return Number(a.numFeeds - b.numFeeds); + const ascending = sort === SortOption.ApyAscending; + return compareInOrder([ + () => compareApy(a, b, yieldRate, ascending), + () => compareSelfStake(a, b, ascending), + () => comparePoolCapacity(a, b, ascending), + () => compareName(collator, a, b, ascending), + ]); } + case SortOption.NumberOfFeedsAscending: case SortOption.NumberOfFeedsDescending: { - return Number(b.numFeeds - a.numFeeds); + const ascending = sort === SortOption.NumberOfFeedsAscending; + return compareInOrder([ + () => (ascending ? -1 : 1) * Number(b.numFeeds - a.numFeeds), + () => compareSelfStake(a, b, ascending), + () => comparePoolCapacity(a, b, ascending), + () => compareApy(a, b, yieldRate, ascending), + () => compareName(collator, a, b, ascending), + ]); } case SortOption.RemainingPoolAscending: case SortOption.RemainingPoolDescending: { - if (a.poolCapacity === 0n && b.poolCapacity === 0n) { - return 0; - } else if (a.poolCapacity === 0n) { - return 1; - } else if (b.poolCapacity === 0n) { - return -1; - } else { - const remainingPoolA = - a.poolCapacity - a.poolUtilization - a.poolUtilizationDelta; - const remainingPoolB = - b.poolCapacity - b.poolUtilization - b.poolUtilizationDelta; - const value = Number(remainingPoolA - remainingPoolB); - return sort === SortOption.RemainingPoolDescending ? -1 * value : value; - } + const ascending = sort === SortOption.RemainingPoolAscending; + return compareInOrder([ + () => comparePoolCapacity(a, b, ascending), + () => compareSelfStake(a, b, ascending), + () => compareApy(a, b, yieldRate, ascending), + () => compareName(collator, a, b, ascending), + ]); } case SortOption.QualityRankingDescending: case SortOption.QualityRankingAscending: { - if (a.qualityRanking === 0 && b.qualityRanking === 0) { - return 0; - } else if (a.qualityRanking === 0) { - return 1; - } else if (b.qualityRanking === 0) { - return -1; - } else { - const value = Number(a.qualityRanking - b.qualityRanking); - return sort === SortOption.QualityRankingAscending ? -1 * value : value; - } - } - case SortOption.SelfStakeAscending: { - return Number( - a.selfStake + a.selfStakeDelta - b.selfStake - b.selfStakeDelta, + // No need for a fallback sort since each publisher has a unique value. + return compareQualityRanking( + a, + b, + sort === SortOption.QualityRankingAscending, ); } + case SortOption.SelfStakeAscending: case SortOption.SelfStakeDescending: { - return Number( - b.selfStake + b.selfStakeDelta - a.selfStake - a.selfStakeDelta, - ); + const ascending = sort === SortOption.SelfStakeAscending; + return compareInOrder([ + () => compareSelfStake(a, b, ascending), + () => comparePoolCapacity(a, b, ascending), + () => compareApy(a, b, yieldRate, ascending), + () => compareName(collator, a, b, ascending), + ]); } } }; +const compareInOrder = (comparisons: (() => number)[]): number => { + for (const compare of comparisons) { + const value = compare(); + if (value !== 0) { + return value; + } + } + return 0; +}; + +const compareName = ( + collator: Intl.Collator, + a: PublisherProps["publisher"], + b: PublisherProps["publisher"], + reverse?: boolean, +) => + (reverse ? -1 : 1) * + collator.compare( + a.identity?.name ?? a.publicKey.toBase58(), + b.identity?.name ?? b.publicKey.toBase58(), + ); + +const compareApy = ( + a: PublisherProps["publisher"], + b: PublisherProps["publisher"], + yieldRate: bigint, + reverse?: boolean, +) => + (reverse ? -1 : 1) * + (calculateApy({ + isSelf: false, + selfStake: b.selfStake + b.selfStakeDelta, + poolCapacity: b.poolCapacity, + poolUtilization: b.poolUtilization + b.poolUtilizationDelta, + yieldRate, + delegationFee: b.delegationFee, + }) - + calculateApy({ + isSelf: false, + selfStake: a.selfStake + a.selfStakeDelta, + poolCapacity: a.poolCapacity, + poolUtilization: a.poolUtilization + a.poolUtilizationDelta, + yieldRate, + delegationFee: a.delegationFee, + })); + +const comparePoolCapacity = ( + a: PublisherProps["publisher"], + b: PublisherProps["publisher"], + reverse?: boolean, +) => { + if (a.poolCapacity === 0n && b.poolCapacity === 0n) { + return 0; + } else if (a.poolCapacity === 0n) { + return 1; + } else if (b.poolCapacity === 0n) { + return -1; + } else { + const remainingPoolA = + a.poolCapacity - a.poolUtilization - a.poolUtilizationDelta; + const remainingPoolB = + b.poolCapacity - b.poolUtilization - b.poolUtilizationDelta; + if (remainingPoolA <= 0n && remainingPoolB <= 0n) { + return 0; + } else if (remainingPoolA <= 0n && remainingPoolB > 0n) { + return 1; + } else if (remainingPoolB <= 0n && remainingPoolA > 0n) { + return -1; + } else { + return (reverse ? -1 : 1) * Number(remainingPoolB - remainingPoolA); + } + } +}; + +const compareQualityRanking = ( + a: PublisherProps["publisher"], + b: PublisherProps["publisher"], + reverse?: boolean, +) => { + if (a.qualityRanking === 0 && b.qualityRanking === 0) { + return 0; + } else if (a.qualityRanking === 0) { + return 1; + } else if (b.qualityRanking === 0) { + return -1; + } else { + return (reverse ? -1 : 1) * Number(a.qualityRanking - b.qualityRanking); + } +}; + +const compareSelfStake = ( + a: PublisherProps["publisher"], + b: PublisherProps["publisher"], + reverse?: boolean, +) => + (reverse ? -1 : 1) * + Number(b.selfStake + b.selfStakeDelta - (a.selfStake + a.selfStakeDelta)); + type SortablePublisherTableHeaderProps = Omit< ComponentProps, "children" @@ -1081,7 +1169,12 @@ type PublisherProps = { totalStaked: bigint; isSelf?: boolean | undefined; publisher: { - name: string | undefined; + identity: + | { + name: string; + icon: string; + } + | undefined; publicKey: PublicKey; stakeAccount: PublicKey | undefined; selfStake: bigint; @@ -1091,7 +1184,8 @@ type PublisherProps = { poolUtilizationDelta: bigint; numFeeds: number; qualityRanking: number; - apyHistory: { date: Date; apy: number }[]; + delegationFee: bigint; + apyHistory: { date: Date; apy: number; selfApy: number }[]; positions?: | { warmup?: bigint | undefined; @@ -1152,6 +1246,7 @@ const Publisher = ({ poolUtilization: publisher.poolUtilization + publisher.poolUtilizationDelta, yieldRate, + delegationFee: publisher.delegationFee, }).toFixed(2), [ isSelf, @@ -1160,6 +1255,7 @@ const Publisher = ({ publisher.poolCapacity, publisher.poolUtilization, publisher.poolUtilizationDelta, + publisher.delegationFee, yieldRate, ], ); @@ -1168,13 +1264,14 @@ const Publisher = ({
    {!isSelf && (
    - {publisher} - + - {publisher} - + {publisher.selfStake + publisher.selfStakeDelta} @@ -1290,9 +1388,9 @@ const Publisher = ({
    ({ + data={publisher.apyHistory.map(({ date, apy, selfApy }) => ({ date, - value: apy, + value: isSelf ? selfApy : apy, }))} />
    @@ -1439,12 +1537,12 @@ const YourPositionsTable = ({ className="w-28" actionDescription={ <> - + Cancel tokens that are in warmup for staking to - + {publisher} - + } actionName="Cancel" @@ -1476,12 +1574,10 @@ const YourPositionsTable = ({ className="md:w-28" actionDescription={ <> - - Unstake tokens from - - + Unstake tokens from + {publisher} - + } actionName="Unstake" @@ -1528,8 +1624,10 @@ const StakeToPublisherButton = ({ size="small" actionDescription={ <> - Stake to - {publisher} + Stake to + + {publisher} + } actionName="Stake" @@ -1579,6 +1677,7 @@ const NewApy = ({ calculateApy({ poolCapacity: publisher.poolCapacity, yieldRate, + delegationFee: publisher.delegationFee, ...(isSelf ? { isSelf: true, @@ -1602,6 +1701,7 @@ const NewApy = ({ publisher.selfStakeDelta, publisher.poolUtilization, publisher.poolUtilizationDelta, + publisher.delegationFee, children, ], ); @@ -1609,34 +1709,58 @@ const NewApy = ({ return
    {apy}%
    ; }; -type PublisherNameProps = { +type PublisherIdentityProps = PublisherKeyProps & { + withNameClassName?: string | undefined; +}; + +const PublisherIdentity = ({ + className, + withNameClassName, + ...props +}: PublisherIdentityProps) => + props.children.identity ? ( + + + {`${props.children.identity.name} + {props.children.identity.name} + + + + ) : ( + + ); + +type PublisherKeyProps = { className?: string | undefined; children: PublisherProps["publisher"]; fullClassName?: string; truncatedClassName?: string; }; -const PublisherName = ({ +const PublisherKey = ({ children, fullClassName, truncatedClassName, className, -}: PublisherNameProps) => - children.name ? ( - {children.name} - ) : ( - - {fullClassName && ( - {children.publicKey.toBase58()} - )} - - {children.publicKey} - - - ); +}: PublisherKeyProps) => ( + + {fullClassName && ( + {children.publicKey.toBase58()} + )} + + {children.publicKey} + + +); const useTransferActionForPublisher = ( action: ((publisher: PublicKey, amount: bigint) => Promise) | undefined, diff --git a/apps/staking/src/components/Root/index.tsx b/apps/staking/src/components/Root/index.tsx index 0216f9f311..2b81ebd463 100644 --- a/apps/staking/src/components/Root/index.tsx +++ b/apps/staking/src/components/Root/index.tsx @@ -13,12 +13,14 @@ import { WALLETCONNECT_PROJECT_ID, MAINNET_RPC, HERMES_URL, + PYTHNET_RPC, } from "../../config/server"; import { ApiProvider } from "../../hooks/use-api"; import { LoggerProvider } from "../../hooks/use-logger"; import { NetworkProvider } from "../../hooks/use-network"; import { ToastProvider } from "../../hooks/use-toast"; import { Amplitude } from "../Amplitude"; +import { Changelog } from "../Changelog"; import { Footer } from "../Footer"; import { Header } from "../Header"; import { MaxWidth } from "../MaxWidth"; @@ -63,6 +65,7 @@ export const Root = ({ children }: Props) => (