diff --git a/apps/staking/src/api.ts b/apps/staking/src/api.ts index 184ea53466..ee5e717c80 100644 --- a/apps/staking/src/api.ts +++ b/apps/staking/src/api.ts @@ -437,6 +437,23 @@ export const unstakeIntegrityStaking = async ( ); }; +export const reassignPublisherAccount = async ( + _client: PythStakingClient, + _stakeAccount: PublicKey, + _targetAccount: PublicKey, +): Promise => { + await new Promise((resolve) => setTimeout(resolve, MOCK_DELAY)); + throw new NotImplementedError(); +}; + +export const optPublisherOut = async ( + _client: PythStakingClient, + _stakeAccount: PublicKey, +): Promise => { + await new Promise((resolve) => setTimeout(resolve, MOCK_DELAY)); + throw new NotImplementedError(); +}; + export const getUpcomingEpoch = (): Date => { const d = new Date(); d.setUTCDate(d.getUTCDate() + ((5 + 7 - d.getUTCDay()) % 7 || 7)); diff --git a/apps/staking/src/components/Button/index.tsx b/apps/staking/src/components/Button/index.tsx index bfe29c813e..a687b03ed2 100644 --- a/apps/staking/src/components/Button/index.tsx +++ b/apps/staking/src/components/Button/index.tsx @@ -8,7 +8,7 @@ import { Link } from "../Link"; type VariantProps = { variant?: "secondary" | undefined; - size?: "small" | "nopad" | undefined; + size?: "small" | "nopad" | "noshrink" | undefined; }; type ButtonProps = Omit, "isDisabled"> & @@ -77,6 +77,9 @@ const sizeClassName = (size: VariantProps["size"]) => { case "nopad": { return "px-0 py-0"; } + case "noshrink": { + return "px-8 py-2"; + } case undefined: { return "px-2 sm:px-4 md:px-8 py-2"; } diff --git a/apps/staking/src/components/ModalDialog/index.tsx b/apps/staking/src/components/ModalDialog/index.tsx index bfa6250337..80a4a718db 100644 --- a/apps/staking/src/components/ModalDialog/index.tsx +++ b/apps/staking/src/components/ModalDialog/index.tsx @@ -1,4 +1,5 @@ import { XMarkIcon } from "@heroicons/react/24/outline"; +import clsx from "clsx"; import type { ComponentProps, ReactNode } from "react"; import { Dialog, Heading, Modal, ModalOverlay } from "react-aria-components"; @@ -14,8 +15,9 @@ type ModalDialogProps = Omit< "children" > & { closeDisabled?: boolean | undefined; + closeButtonText?: string; title: ReactNode | ReactNode[]; - description?: string; + description?: ReactNode; children?: | ((options: DialogRenderProps) => ReactNode | ReactNode[]) | ReactNode @@ -25,6 +27,7 @@ type ModalDialogProps = Omit< export const ModalDialog = ({ closeDisabled, + closeButtonText, children, title, description, @@ -37,7 +40,7 @@ export const ModalDialog = ({ {...props} > - + {(options) => ( <> - + {title} {description && (

{description}

)} {typeof children === "function" ? children(options) : children} + {closeButtonText !== undefined && ( +
+ +
+ )} )}
diff --git a/apps/staking/src/components/OracleIntegrityStaking/index.tsx b/apps/staking/src/components/OracleIntegrityStaking/index.tsx index 907f8bd03e..8a8ce3abb9 100644 --- a/apps/staking/src/components/OracleIntegrityStaking/index.tsx +++ b/apps/staking/src/components/OracleIntegrityStaking/index.tsx @@ -13,6 +13,8 @@ import { type ComponentProps, type Dispatch, type SetStateAction, + type HTMLAttributes, + type FormEvent, } from "react"; import { useFilter } from "react-aria"; import { @@ -21,16 +23,25 @@ import { Button as BaseButton, Meter, Label, + DialogTrigger, + TextField, + Form, } from "react-aria-components"; import { type States, StateType as ApiStateType } from "../../hooks/use-api"; +import { + StateType as UseAsyncStateType, + useAsync, +} from "../../hooks/use-async"; import { Button } from "../Button"; +import { ModalDialog } from "../ModalDialog"; import { ProgramSection } from "../ProgramSection"; import { SparkChart } from "../SparkChart"; import { StakingTimeline } from "../StakingTimeline"; import { Styled } from "../Styled"; import { Tokens } from "../Tokens"; import { AmountType, TransferButton } from "../TransferButton"; +import { TruncatedKey } from "../TruncatedKey"; const PAGE_SIZE = 10; @@ -90,12 +101,18 @@ export const OracleIntegrityStaking = ({ ), })} > - {self && ( + {self && api.type == ApiStateType.Loaded && (
-

- You ({self.name ?? self.publicKey.toBase58()}) -

+
+

+ You ({self}) +

+
+ + +
+
@@ -141,6 +158,247 @@ export const OracleIntegrityStaking = ({ ); }; +type ReassignStakeAccountButtonProps = { + api: States[ApiStateType.Loaded]; + self: PublisherProps["publisher"]; +}; + +const ReassignStakeAccountButton = ({ + api, + self, +}: ReassignStakeAccountButtonProps) => { + const [closeDisabled, setCloseDisabled] = useState(false); + + return ( + + + {hasAnyPositions(self) ? ( + +
+

+ You cannot designate another account while self-staked. +

+

+ Please close all self-staking positions, wait the cooldown period + (if applicable), and try again once your self-stake is fully + closed. +

+
+
+ ) : ( + + Designate a different stake account as the self-staking account + for{" "} + {self} + + } + > + {({ close }) => ( + + )} + + )} +
+ ); +}; + +type ReassignStakeAccountFormProps = { + api: States[ApiStateType.Loaded]; + close: () => void; + setCloseDisabled: (value: boolean) => void; +}; + +const ReassignStakeAccountForm = ({ + api, + close, + setCloseDisabled, +}: ReassignStakeAccountFormProps) => { + const [value, setValue] = useState(""); + + const key = useMemo(() => { + try { + return new PublicKey(value); + } catch { + return; + } + }, [value]); + + const doReassign = useCallback( + () => + key === undefined + ? Promise.reject(new InvalidKeyError()) + : api.reassignPublisherAccount(key), + [api, key], + ); + + const { state, execute } = useAsync(doReassign); + + const handleSubmit = useCallback( + (e: FormEvent) => { + e.preventDefault(); + setCloseDisabled(true); + execute() + .then(() => { + close(); + }) + .catch(() => { + /* no-op since this is already handled in the UI using `state` and is logged in useTransfer */ + }) + .finally(() => { + setCloseDisabled(false); + }); + }, + [execute, close, setCloseDisabled], + ); + + return ( +
+ +
+ +
+ + {state.type === UseAsyncStateType.Error && ( +

+ Uh oh, an error occurred! Please try again +

+ )} +
+ + + ); +}; + +type ReassignStakeAccountButtonContentsProps = { + value: string; + publicKey: PublicKey | undefined; +}; + +const ReassignStakeAccountButtonContents = ({ + value, + publicKey, +}: ReassignStakeAccountButtonContentsProps) => { + if (value === "") { + return "Enter the new stake account key"; + } else if (publicKey === undefined) { + return "Please enter a valid public key"; + } else { + return "Submit"; + } +}; + +type OptOutButtonProps = { + api: States[ApiStateType.Loaded]; + self: PublisherProps["publisher"]; +}; + +const OptOutButton = ({ api, self }: OptOutButtonProps) => { + const { state, execute } = useAsync(api.optPublisherOut); + + const doOptOut = useCallback(() => { + execute().catch(() => { + /* TODO figure out a better UI treatment for when claim fails */ + }); + }, [execute]); + + return ( + + + {hasAnyPositions(self) ? ( + +
+

+ You cannot opt out of rewards while self-staked. +

+

+ Please close all self-staking positions, wait the cooldown period + (if applicable), and try again once your self-stake is fully + closed. +

+
+
+ ) : ( + + {({ close }) => ( + <> +
+

+ Are you sure you want to opt out of rewards? +

+

+ Opting out of rewards will prevent you from earning the + publisher yield rate. You will still be able to participate in + OIS after opting out of rewards, but{" "} + + {self} + {" "} + will no longer be able to receive delegated stake, and you + will no longer receive the self-staking yield. +

+
+ {state.type === UseAsyncStateType.Error && ( +

+ Uh oh, an error occurred! Please try again +

+ )} +
+ + +
+ + )} +
+ )} +
+ ); +}; + type PublisherListProps = { api: States[ApiStateType.Loaded] | States[ApiStateType.LoadedNoStakeAccount]; title: string; @@ -269,70 +527,76 @@ const PublisherList = ({ -
- - - - Publisher - - - Self stake - - - Pool - - - APY - - Historical APY - - Number of feeds - - - Quality ranking - - - - - - - {paginatedPublishers.map((publisher) => ( - - ))} - -
+ {filteredSortedPublishers.length > 0 ? ( + + + + + Publisher + + + Self stake + + + Pool + + + APY + + Historical APY + + Number of feeds + + + Quality ranking + + + + + + + {paginatedPublishers.map((publisher) => ( + + ))} + +
+ ) : ( +

+ No results match your query +

+ )} {filteredSortedPublishers.length > PAGE_SIZE && (
@@ -496,7 +760,7 @@ const Publisher = ({ {!isSelf && ( <> - {publisher.name ?? publisher.publicKey.toBase58()} + {publisher} {publisher.selfStake} @@ -574,12 +838,7 @@ const Publisher = ({ @@ -608,7 +867,14 @@ const Publisher = ({ size="small" variant="secondary" className="w-28" - actionDescription={`Cancel tokens that are in warmup for staking to ${publisher.name ?? publisher.publicKey.toBase58()}`} + actionDescription={ + <> + Cancel tokens that are in warmup for staking to{" "} + + {publisher} + + + } actionName="Cancel" submitButtonText="Cancel Warmup" title="Cancel Warmup" @@ -635,7 +901,14 @@ const Publisher = ({ size="small" variant="secondary" className="w-28" - actionDescription={`Unstake tokens from ${publisher.name ?? publisher.publicKey.toBase58()}`} + actionDescription={ + <> + Unstake tokens from{" "} + + {publisher} + + + } actionName="Unstake" max={staked} transfer={unstake} @@ -659,36 +932,31 @@ const PublisherTableCell = Styled("td", "py-4 px-5 whitespace-nowrap"); type StakeToPublisherButtonProps = { api: States[ApiStateType.Loaded] | States[ApiStateType.LoadedNoStakeAccount]; - publisherName: string | undefined; - publisherKey: PublicKey; + publisher: PublisherProps["publisher"]; availableToStake: bigint; - poolCapacity: bigint; - poolUtilization: bigint; - isSelf: boolean; - selfStake: bigint; yieldRate: bigint; }; const StakeToPublisherButton = ({ api, - publisherName, - publisherKey, - poolCapacity, - poolUtilization, availableToStake, - isSelf, - selfStake, + publisher, yieldRate, }: StakeToPublisherButtonProps) => { const delegate = useTransferActionForPublisher( api.type === ApiStateType.Loaded ? api.delegateIntegrityStaking : undefined, - publisherKey, + publisher.publicKey, ); return ( + Stake to{" "} + {publisher} + + } actionName="Stake" max={availableToStake} transfer={delegate} @@ -698,21 +966,21 @@ const StakeToPublisherButton = ({
APY after staking
- {isSelf + {publisher.isSelf ? calculateApy({ - isSelf, + isSelf: publisher.isSelf, selfStake: - selfStake + + publisher.selfStake + (amount.type === AmountType.Valid ? amount.amount : 0n), - poolCapacity, + poolCapacity: publisher.poolCapacity, yieldRate, }) : calculateApy({ - isSelf, - selfStake, - poolCapacity, + isSelf: publisher.isSelf, + selfStake: publisher.selfStake, + poolCapacity: publisher.poolCapacity, poolUtilization: - poolUtilization + + publisher.poolUtilization + (amount.type === AmountType.Valid ? amount.amount : 0n), yieldRate, })} @@ -726,6 +994,26 @@ const StakeToPublisherButton = ({ ); }; +type PublisherNameProps = Omit, "children"> & { + children: PublisherProps["publisher"]; + fullKey?: boolean | undefined; +}; + +const PublisherName = ({ children, fullKey, ...props }: PublisherNameProps) => ( + + {children.name ?? ( + <> + {fullKey === true && ( + + {children.publicKey.toBase58()} + + )} + {children.publicKey} + + )} + +); + const useTransferActionForPublisher = ( action: ((publisher: PublicKey, amount: bigint) => Promise) | undefined, publisher: PublicKey, @@ -755,3 +1043,9 @@ enum SortField { NumberOfFeeds, QualityRanking, } + +class InvalidKeyError extends Error { + constructor() { + super("Invalid public key"); + } +} diff --git a/apps/staking/src/components/TransferButton/index.tsx b/apps/staking/src/components/TransferButton/index.tsx index 0f2ca15e3f..bd00a03ba0 100644 --- a/apps/staking/src/components/TransferButton/index.tsx +++ b/apps/staking/src/components/TransferButton/index.tsx @@ -23,10 +23,10 @@ import { Tokens } from "../Tokens"; import PythTokensIcon from "../Tokens/pyth.svg"; type Props = Omit, "children"> & { - actionName: string; - actionDescription: string; - title?: string | undefined; - submitButtonText?: string | undefined; + actionName: ReactNode; + actionDescription: ReactNode; + title?: ReactNode | undefined; + submitButtonText?: ReactNode | undefined; max: bigint; children?: | ((amount: Amount) => ReactNode | ReactNode[]) @@ -82,7 +82,7 @@ type DialogContentsProps = { children: Props["children"]; transfer: (amount: bigint) => Promise; setCloseDisabled: (value: boolean) => void; - submitButtonText: string; + submitButtonText: ReactNode; close: () => void; }; @@ -165,6 +165,7 @@ const DialogContents = ({
diff --git a/apps/staking/src/components/TruncatedKey/index.tsx b/apps/staking/src/components/TruncatedKey/index.tsx new file mode 100644 index 0000000000..9373194b2e --- /dev/null +++ b/apps/staking/src/components/TruncatedKey/index.tsx @@ -0,0 +1,16 @@ +import type { PublicKey } from "@solana/web3.js"; +import { useMemo, type HTMLAttributes } from "react"; + +type Props = Omit, "children"> & { + children: PublicKey | `0x${string}`; +}; + +export const TruncatedKey = ({ children, ...props }: Props) => { + const key = useMemo(() => { + const isHex = typeof children === "string"; + const asString = isHex ? children : children.toBase58(); + return asString.slice(0, isHex ? 6 : 4) + ".." + asString.slice(-4); + }, [children]); + + return {key}; +}; diff --git a/apps/staking/src/components/WalletButton/index.tsx b/apps/staking/src/components/WalletButton/index.tsx index 3eb729c332..caf1e53279 100644 --- a/apps/staking/src/components/WalletButton/index.tsx +++ b/apps/staking/src/components/WalletButton/index.tsx @@ -12,7 +12,6 @@ import { } from "@heroicons/react/24/outline"; import { useWallet } from "@solana/wallet-adapter-react"; import { useWalletModal } from "@solana/wallet-adapter-react-ui"; -import type { PublicKey } from "@solana/web3.js"; import clsx from "clsx"; import { type ComponentProps, @@ -20,7 +19,6 @@ import { type SVGAttributes, type ReactNode, useCallback, - useMemo, useState, } from "react"; import { @@ -43,6 +41,7 @@ import { usePrimaryDomain } from "../../hooks/use-primary-domain"; import { AccountHistory } from "../AccountHistory"; import { Button } from "../Button"; import { ModalDialog } from "../ModalDialog"; +import { TruncatedKey } from "../TruncatedKey"; type Props = Omit, "onClick" | "children">; @@ -141,9 +140,7 @@ const ConnectedButton = ({ invisible: item.account !== api.account, })} /> -
-                          {item.account.address}
-                        
+ {item.account.address} )} @@ -204,7 +201,7 @@ const ButtonContent = () => { if (primaryDomain) { return primaryDomain; } else if (wallet.publicKey) { - return {wallet.publicKey}; + return {wallet.publicKey}; } else if (wallet.connecting) { return "Connecting..."; } else { @@ -212,13 +209,6 @@ const ButtonContent = () => { } }; -const TruncatedKey = ({ children }: { children: PublicKey | `0x${string}` }) => - useMemo(() => { - const isHex = typeof children === "string"; - const asString = isHex ? children : children.toBase58(); - return asString.slice(0, isHex ? 6 : 4) + ".." + asString.slice(-4); - }, [children]); - type WalletMenuItemProps = Omit, "children"> & { icon?: ComponentType>; children: ReactNode; diff --git a/apps/staking/src/hooks/use-api.tsx b/apps/staking/src/hooks/use-api.tsx index 658688c211..28219808f5 100644 --- a/apps/staking/src/hooks/use-api.tsx +++ b/apps/staking/src/hooks/use-api.tsx @@ -115,6 +115,8 @@ const State = { delegateIntegrityStaking: bindApi(api.delegateIntegrityStaking), unstakeIntegrityStaking: bindApi(api.unstakeIntegrityStaking), cancelWarmupIntegrityStaking: bindApi(api.cancelWarmupIntegrityStaking), + reassignPublisherAccount: bindApi(api.reassignPublisherAccount), + optPublisherOut: bindApi(api.optPublisherOut), }; },