From 2ccb3627ca2f78a88681f529c801a0b6e121e5e8 Mon Sep 17 00:00:00 2001 From: Connor Prussin Date: Tue, 10 Sep 2024 16:37:45 -0700 Subject: [PATCH] feat(staking): create a stake account on initial deposit --- apps/staking/src/api.ts | 180 +++++++----- .../src/components/AccountHistory/index.tsx | 9 +- .../src/components/AccountSummary/index.tsx | 41 ++- apps/staking/src/components/Button/index.tsx | 3 +- .../src/components/Dashboard/index.tsx | 6 + .../src/components/Governance/index.tsx | 18 +- apps/staking/src/components/Home/index.tsx | 54 ++-- .../OracleIntegrityStaking/index.tsx | 79 +++--- .../src/components/ProgramSection/index.tsx | 112 ++++---- apps/staking/src/components/Root/index.tsx | 6 +- .../src/components/TransferButton/index.tsx | 29 +- .../src/components/WalletButton/index.tsx | 122 ++++---- apps/staking/src/hooks/use-account-history.ts | 55 ---- apps/staking/src/hooks/use-api.tsx | 264 ++++++++++++++++++ apps/staking/src/hooks/use-async.ts | 52 ++++ .../{use-dashboard-data.ts => use-data.ts} | 24 +- apps/staking/src/hooks/use-stake-account.tsx | 195 ------------- apps/staking/src/hooks/use-transfer.ts | 71 ----- 18 files changed, 688 insertions(+), 632 deletions(-) delete mode 100644 apps/staking/src/hooks/use-account-history.ts create mode 100644 apps/staking/src/hooks/use-api.tsx create mode 100644 apps/staking/src/hooks/use-async.ts rename apps/staking/src/hooks/{use-dashboard-data.ts => use-data.ts} (65%) delete mode 100644 apps/staking/src/hooks/use-stake-account.tsx delete mode 100644 apps/staking/src/hooks/use-transfer.ts diff --git a/apps/staking/src/api.ts b/apps/staking/src/api.ts index 05c381942..7cf2bc258 100644 --- a/apps/staking/src/api.ts +++ b/apps/staking/src/api.ts @@ -127,7 +127,7 @@ export type AccountHistoryAction = ReturnType< (typeof AccountHistoryAction)[keyof typeof AccountHistoryAction] >; -type AccountHistory = { +export type AccountHistory = { timestamp: Date; action: AccountHistoryAction; amount: bigint; @@ -144,73 +144,114 @@ export const getStakeAccounts = async ( export const loadData = async ( client: PythStakingClient, - stakeAccount: StakeAccountPositions, + stakeAccount?: StakeAccountPositions | undefined, ): Promise => { - const [ - stakeAccountCustody, - publishers, - ownerAtaAccount, - currentEpoch, - unlockSchedule, - ] = await Promise.all([ - client.getStakeAccountCustody(stakeAccount.address), - client.getPublishers(), - client.getOwnerPythAtaAccount(), - getCurrentEpoch(client.connection), - client.getUnlockSchedule(stakeAccount.address), - ]); - - const filterGovernancePositions = (positionState: PositionState) => - getAmountByTargetAndState({ - stakeAccountPositions: stakeAccount, - targetWithParameters: { voting: {} }, - positionState, - epoch: currentEpoch, - }); - - const filterOISPositions = ( - publisher: PublicKey, - positionState: PositionState, - ) => - getAmountByTargetAndState({ - stakeAccountPositions: stakeAccount, - targetWithParameters: { integrityPool: { publisher } }, - positionState, - epoch: currentEpoch, - }); - - return { - lastSlash: undefined, // TODO - availableRewards: 0n, // TODO - expiringRewards: undefined, // TODO - total: stakeAccountCustody.amount, - governance: { - warmup: filterGovernancePositions(PositionState.LOCKING), - staked: filterGovernancePositions(PositionState.LOCKED), - cooldown: filterGovernancePositions(PositionState.PREUNLOCKING), - cooldown2: filterGovernancePositions(PositionState.UNLOCKED), - }, - unlockSchedule, - locked: unlockSchedule.reduce((sum, { amount }) => sum + amount, 0n), - walletAmount: ownerAtaAccount.amount, - integrityStakingPublishers: publishers.map(({ pubkey: publisher }) => ({ - apyHistory: [], // TODO - isSelf: false, // TODO - name: undefined, // TODO - numFeeds: 0, // TODO - poolCapacity: 100n, // TODO - poolUtilization: 0n, // TODO - publicKey: publisher, - qualityRanking: 0, // TODO - selfStake: 0n, // TODO - positions: { - warmup: filterOISPositions(publisher, PositionState.LOCKING), - staked: filterOISPositions(publisher, PositionState.LOCKED), - cooldown: filterOISPositions(publisher, PositionState.PREUNLOCKING), - cooldown2: filterOISPositions(publisher, PositionState.UNLOCKED), + if (stakeAccount === undefined) { + const [integrityStakingPublishers, ownerAtaAccount] = await Promise.all([ + loadPublisherData(client), + client.getOwnerPythAtaAccount(), + ]); + + return { + lastSlash: undefined, + availableRewards: 0n, + expiringRewards: undefined, + total: 0n, + governance: { + warmup: 0n, + staked: 0n, + cooldown: 0n, + cooldown2: 0n, }, - })), - }; + unlockSchedule: [], + locked: 0n, + walletAmount: ownerAtaAccount.amount, + integrityStakingPublishers, + }; + } else { + const [ + stakeAccountCustody, + publishers, + ownerAtaAccount, + currentEpoch, + unlockSchedule, + ] = await Promise.all([ + client.getStakeAccountCustody(stakeAccount.address), + loadPublisherData(client), + client.getOwnerPythAtaAccount(), + getCurrentEpoch(client.connection), + client.getUnlockSchedule(stakeAccount.address), + ]); + + const filterGovernancePositions = (positionState: PositionState) => + getAmountByTargetAndState({ + stakeAccountPositions: stakeAccount, + targetWithParameters: { voting: {} }, + positionState, + epoch: currentEpoch, + }); + + const filterOISPositions = ( + publisher: PublicKey, + positionState: PositionState, + ) => + getAmountByTargetAndState({ + stakeAccountPositions: stakeAccount, + targetWithParameters: { integrityPool: { publisher } }, + positionState, + epoch: currentEpoch, + }); + + return { + lastSlash: undefined, // TODO + availableRewards: 0n, // TODO + expiringRewards: undefined, // TODO + total: stakeAccountCustody.amount, + governance: { + warmup: filterGovernancePositions(PositionState.LOCKING), + staked: filterGovernancePositions(PositionState.LOCKED), + cooldown: filterGovernancePositions(PositionState.PREUNLOCKING), + cooldown2: filterGovernancePositions(PositionState.UNLOCKED), + }, + unlockSchedule, + locked: unlockSchedule.reduce((sum, { amount }) => sum + amount, 0n), + walletAmount: ownerAtaAccount.amount, + integrityStakingPublishers: publishers.map((publisher) => ({ + ...publisher, + positions: { + warmup: filterOISPositions( + publisher.publicKey, + PositionState.LOCKING, + ), + staked: filterOISPositions(publisher.publicKey, PositionState.LOCKED), + cooldown: filterOISPositions( + publisher.publicKey, + PositionState.PREUNLOCKING, + ), + cooldown2: filterOISPositions( + publisher.publicKey, + PositionState.UNLOCKED, + ), + }, + })), + }; + } +}; + +const loadPublisherData = async (client: PythStakingClient) => { + const publishers = await client.getPublishers(); + + return publishers.map(({ pubkey: publisher }) => ({ + apyHistory: [], // TODO + isSelf: false, // TODO + name: undefined, // TODO + numFeeds: 0, // TODO + poolCapacity: 100n, // TODO + poolUtilization: 0n, // TODO + publicKey: publisher, + qualityRanking: 0, // TODO + selfStake: 0n, // TODO + })); }; export const loadAccountHistory = async ( @@ -221,6 +262,13 @@ export const loadAccountHistory = async ( return mkMockHistory(); }; +export const createStakeAccountAndDeposit = async ( + _client: PythStakingClient, + _amount: bigint, +): Promise => { + throw new NotImplementedError(); +}; + export const deposit = async ( client: PythStakingClient, stakeAccount: PublicKey, diff --git a/apps/staking/src/components/AccountHistory/index.tsx b/apps/staking/src/components/AccountHistory/index.tsx index 817e2759a..7516b8d12 100644 --- a/apps/staking/src/components/AccountHistory/index.tsx +++ b/apps/staking/src/components/AccountHistory/index.tsx @@ -6,11 +6,14 @@ import { AccountHistoryItemType, StakeType, } from "../../api"; -import { StateType, useAccountHistory } from "../../hooks/use-account-history"; +import type { States, StateType as ApiStateType } from "../../hooks/use-api"; +import { StateType, useData } from "../../hooks/use-data"; import { Tokens } from "../Tokens"; -export const AccountHistory = () => { - const history = useAccountHistory(); +type Props = { api: States[ApiStateType.Loaded] }; + +export const AccountHistory = ({ api }: Props) => { + const history = useData(api.accountHisoryCacheKey, api.loadAccountHistory); switch (history.type) { case StateType.NotLoaded: diff --git a/apps/staking/src/components/AccountSummary/index.tsx b/apps/staking/src/components/AccountSummary/index.tsx index 8a3a4bfc4..cdb0a17ba 100644 --- a/apps/staking/src/components/AccountSummary/index.tsx +++ b/apps/staking/src/components/AccountSummary/index.tsx @@ -6,14 +6,15 @@ import { } from "react-aria-components"; import background from "./background.png"; -import { deposit, withdraw, claim } from "../../api"; -import { StateType, useTransfer } from "../../hooks/use-transfer"; +import { type States, StateType as ApiStateType } from "../../hooks/use-api"; +import { StateType, useAsync } from "../../hooks/use-async"; import { Button } from "../Button"; import { ModalDialog } from "../ModalDialog"; import { Tokens } from "../Tokens"; import { TransferButton } from "../TransferButton"; type Props = { + api: States[ApiStateType.Loaded] | States[ApiStateType.LoadedNoStakeAccount]; total: bigint; locked: bigint; unlockSchedule: { @@ -38,6 +39,7 @@ type Props = { }; export const AccountSummary = ({ + api, locked, unlockSchedule, lastSlash, @@ -114,7 +116,7 @@ export const AccountSummary = ({ actionDescription="Add funds to your balance" actionName="Add Tokens" max={walletAmount} - transfer={deposit} + transfer={api.deposit} /> @@ -130,8 +132,9 @@ export const AccountSummary = ({ actionDescription="Move funds from your account back to your wallet" actionName="Withdraw" max={availableToWithdraw} - transfer={withdraw} - isDisabled={availableToWithdraw === 0n} + {...(api.type === ApiStateType.Loaded && { + transfer: api.withdraw, + })} /> } /> @@ -139,7 +142,15 @@ export const AccountSummary = ({ name="Available Rewards" amount={availableRewards} description="Rewards you have earned from OIS" - action={} + action={ + api.type === ApiStateType.Loaded ? ( + + ) : ( + + ) + } {...(expiringRewards !== undefined && expiringRewards.amount > 0n && { warning: ( @@ -187,13 +198,15 @@ const BalanceCategory = ({ ); -const ClaimButton = ( - props: Omit< - ComponentProps, - "onClick" | "disabled" | "loading" - >, -) => { - const { state, execute } = useTransfer(claim); +type ClaimButtonProps = Omit< + ComponentProps, + "onClick" | "disabled" | "loading" +> & { + api: States[ApiStateType.Loaded]; +}; + +const ClaimButton = ({ api, ...props }: ClaimButtonProps) => { + const { state, execute } = useAsync(api.claim); const doClaim = useCallback(() => { execute().catch(() => { @@ -207,7 +220,7 @@ const ClaimButton = ( variant="secondary" onPress={doClaim} isDisabled={state.type !== StateType.Base} - isLoading={state.type === StateType.Submitting} + isLoading={state.type === StateType.Running} {...props} > Claim diff --git a/apps/staking/src/components/Button/index.tsx b/apps/staking/src/components/Button/index.tsx index c531fbbf9..bfe29c813 100644 --- a/apps/staking/src/components/Button/index.tsx +++ b/apps/staking/src/components/Button/index.tsx @@ -11,9 +11,10 @@ type VariantProps = { size?: "small" | "nopad" | undefined; }; -type ButtonProps = ComponentProps & +type ButtonProps = Omit, "isDisabled"> & VariantProps & { isLoading?: boolean | undefined; + isDisabled?: boolean | undefined; }; export const Button = ({ diff --git a/apps/staking/src/components/Dashboard/index.tsx b/apps/staking/src/components/Dashboard/index.tsx index df026f172..c3f0c4627 100644 --- a/apps/staking/src/components/Dashboard/index.tsx +++ b/apps/staking/src/components/Dashboard/index.tsx @@ -1,12 +1,14 @@ import { type ComponentProps, useMemo } from "react"; import { Tabs, TabList, Tab, TabPanel } from "react-aria-components"; +import type { States, StateType as ApiStateType } from "../../hooks/use-api"; import { AccountSummary } from "../AccountSummary"; import { Governance } from "../Governance"; import { OracleIntegrityStaking } from "../OracleIntegrityStaking"; import { Styled } from "../Styled"; type Props = { + api: States[ApiStateType.Loaded] | States[ApiStateType.LoadedNoStakeAccount]; total: bigint; lastSlash: | { @@ -39,6 +41,7 @@ type Props = { }; export const Dashboard = ({ + api, total, lastSlash, walletAmount, @@ -108,6 +111,7 @@ export const Dashboard = ({ return (
); diff --git a/apps/staking/src/components/Home/index.tsx b/apps/staking/src/components/Home/index.tsx index 33ef3fa80..018a743a8 100644 --- a/apps/staking/src/components/Home/index.tsx +++ b/apps/staking/src/components/Home/index.tsx @@ -5,13 +5,14 @@ import { useCallback } from "react"; import { useIsSSR } from "react-aria"; import { - StateType as DashboardDataStateType, - useDashboardData, -} from "../../hooks/use-dashboard-data"; + type States, + StateType as ApiStateType, + useApi, +} from "../../hooks/use-api"; import { - StateType as StakeAccountStateType, - useStakeAccount, -} from "../../hooks/use-stake-account"; + StateType as DashboardDataStateType, + useData, +} from "../../hooks/use-data"; import { Button } from "../Button"; import { Dashboard } from "../Dashboard"; import { Error as ErrorPage } from "../Error"; @@ -24,33 +25,22 @@ export const Home = () => { }; const MountedHome = () => { - const stakeAccountState = useStakeAccount(); + const api = useApi(); - switch (stakeAccountState.type) { - case StakeAccountStateType.Initialized: - case StakeAccountStateType.Loading: { + switch (api.type) { + case ApiStateType.NotLoaded: + case ApiStateType.LoadingStakeAccounts: { return ; } - case StakeAccountStateType.NoAccounts: { - return ( -
-

No stake account found for your wallet!

-
- ); - } - case StakeAccountStateType.NoWallet: { + case ApiStateType.NoWallet: { return ; } - case StakeAccountStateType.Error: { - return ( - - ); + case ApiStateType.ErrorLoadingStakeAccounts: { + return ; } - case StakeAccountStateType.Loaded: { - return ; + case ApiStateType.LoadedNoStakeAccount: + case ApiStateType.Loaded: { + return ; } } }; @@ -78,8 +68,12 @@ const NoWalletHome = () => { ); }; -const StakeAccountLoadedHome = () => { - const data = useDashboardData(); +type StakeAccountLoadedHomeProps = { + api: States[ApiStateType.Loaded] | States[ApiStateType.LoadedNoStakeAccount]; +}; + +const StakeAccountLoadedHome = ({ api }: StakeAccountLoadedHomeProps) => { + const data = useData(api.dashboardDataCacheKey, api.loadData); switch (data.type) { case DashboardDataStateType.NotLoaded: @@ -94,7 +88,7 @@ const StakeAccountLoadedHome = () => { case DashboardDataStateType.Loaded: { return (
- +
); } diff --git a/apps/staking/src/components/OracleIntegrityStaking/index.tsx b/apps/staking/src/components/OracleIntegrityStaking/index.tsx index 0ded2050b..c285f065d 100644 --- a/apps/staking/src/components/OracleIntegrityStaking/index.tsx +++ b/apps/staking/src/components/OracleIntegrityStaking/index.tsx @@ -3,7 +3,6 @@ import { XMarkIcon, MagnifyingGlassIcon, } from "@heroicons/react/24/outline"; -import type { PythStakingClient } from "@pythnetwork/staking-sdk"; import { PublicKey } from "@solana/web3.js"; import clsx from "clsx"; import { @@ -23,12 +22,8 @@ import { Label, } from "react-aria-components"; -import { - delegateIntegrityStaking, - cancelWarmupIntegrityStaking, - unstakeIntegrityStaking, - calculateApy, -} from "../../api"; +import { calculateApy } from "../../api"; +import { type States, StateType as ApiStateType } from "../../hooks/use-api"; import { Button } from "../Button"; import { ProgramSection } from "../ProgramSection"; import { SparkChart } from "../SparkChart"; @@ -40,6 +35,7 @@ import { AmountType, TransferButton } from "../TransferButton"; const PAGE_SIZE = 10; type Props = { + api: States[ApiStateType.Loaded] | States[ApiStateType.LoadedNoStakeAccount]; availableToStake: bigint; locked: bigint; warmup: bigint; @@ -50,6 +46,7 @@ type Props = { }; export const OracleIntegrityStaking = ({ + api, availableToStake, locked, warmup, @@ -111,6 +108,7 @@ export const OracleIntegrityStaking = ({ Quality ranking - {availableToStake > 0n && ( - - )} + {paginatedPublishers.map((publisher) => ( {publisher.numFeeds} + + {publisher.qualityRanking} + - {publisher.qualityRanking} + - {availableToStake > 0 && ( - - - - )} {(warmup !== undefined || staked !== undefined) && ( @@ -635,6 +633,7 @@ const Publisher = ({ const PublisherTableCell = Styled("td", "py-4 px-5 whitespace-nowrap"); type StakeToPublisherButtonProps = { + api: States[ApiStateType.Loaded] | States[ApiStateType.LoadedNoStakeAccount]; publisherName: string | undefined; publisherKey: PublicKey; availableToStake: bigint; @@ -644,6 +643,7 @@ type StakeToPublisherButtonProps = { }; const StakeToPublisherButton = ({ + api, publisherName, publisherKey, poolCapacity, @@ -652,7 +652,7 @@ const StakeToPublisherButton = ({ isSelf, }: StakeToPublisherButtonProps) => { const delegate = useTransferActionForPublisher( - delegateIntegrityStaking, + api.type === ApiStateType.Loaded ? api.delegateIntegrityStaking : undefined, publisherKey, ); @@ -686,17 +686,14 @@ const StakeToPublisherButton = ({ }; const useTransferActionForPublisher = ( - action: ( - client: PythStakingClient, - stakingAccount: PublicKey, - publisher: PublicKey, - amount: bigint, - ) => Promise, + action: ((publisher: PublicKey, amount: bigint) => Promise) | undefined, publisher: PublicKey, ) => - useCallback( - (client: PythStakingClient, stakingAccount: PublicKey, amount: bigint) => - action(client, stakingAccount, publisher, amount), + useMemo( + () => + action === undefined + ? undefined + : (amount: bigint) => action(publisher, amount), [action, publisher], ); diff --git a/apps/staking/src/components/ProgramSection/index.tsx b/apps/staking/src/components/ProgramSection/index.tsx index 724863882..7eed64f37 100644 --- a/apps/staking/src/components/ProgramSection/index.tsx +++ b/apps/staking/src/components/ProgramSection/index.tsx @@ -14,15 +14,11 @@ type Props = HTMLAttributes & { staked: bigint; cooldown: bigint; cooldown2: bigint; + available: bigint; availableToStakeDetails?: ReactNode | ReactNode[] | undefined; } & ( + | { stake?: never; stakeDescription?: never } | { - stake?: never; - stakeDescription?: never; - available?: bigint | undefined; - } - | { - available: bigint; stake: ComponentProps["transfer"] | undefined; stakeDescription: string; } @@ -73,32 +69,27 @@ export const ProgramSection = ({

{name}

{description}

- {available !== undefined && ( - <> - 0n && { - actions: ( - - - - ), - })} - > - {available} - - - - )} + + + + ), + })} + > + {available} + + ), - ...(cancelWarmup !== undefined && { - actions: ( - - ), - }), + })} + {...(cancelWarmupDescription !== undefined && { + actions: ( + + ), })} > {warmup} @@ -130,21 +121,20 @@ export const ProgramSection = ({ 0n && { - actions: ( - - - - ), - })} + {...(unstakeDescription !== undefined && { + actions: ( + + + + ), + })} > {staked} diff --git a/apps/staking/src/components/Root/index.tsx b/apps/staking/src/components/Root/index.tsx index a9badcc16..7cc59fbbb 100644 --- a/apps/staking/src/components/Root/index.tsx +++ b/apps/staking/src/components/Root/index.tsx @@ -12,8 +12,8 @@ import { RPC, IS_MAINNET, } from "../../config/server"; +import { ApiProvider } from "../../hooks/use-api"; import { LoggerProvider } from "../../hooks/use-logger"; -import { StakeAccountProvider } from "../../hooks/use-stake-account"; import { Amplitude } from "../Amplitude"; import { Footer } from "../Footer"; import { Header } from "../Header"; @@ -48,7 +48,7 @@ export const Root = ({ children }: Props) => ( : WalletAdapterNetwork.Devnet } > - + ( {AMPLITUDE_API_KEY && } {!IS_PRODUCTION_SERVER && } - + diff --git a/apps/staking/src/components/TransferButton/index.tsx b/apps/staking/src/components/TransferButton/index.tsx index 67ba4e005..0f2ca15e3 100644 --- a/apps/staking/src/components/TransferButton/index.tsx +++ b/apps/staking/src/components/TransferButton/index.tsx @@ -1,5 +1,3 @@ -import type { PythStakingClient } from "@pythnetwork/staking-sdk"; -import type { PublicKey } from "@solana/web3.js"; import { type ComponentProps, type ReactNode, @@ -17,7 +15,7 @@ import { Group, } from "react-aria-components"; -import { StateType, useTransfer } from "../../hooks/use-transfer"; +import { StateType, useAsync } from "../../hooks/use-async"; import { stringToTokens, tokensToString } from "../../tokens"; import { Button } from "../Button"; import { ModalDialog } from "../ModalDialog"; @@ -35,11 +33,7 @@ type Props = Omit, "children"> & { | ReactNode | ReactNode[] | undefined; - transfer: ( - client: PythStakingClient, - stakingAccount: PublicKey, - amount: bigint, - ) => Promise; + transfer?: ((amount: bigint) => Promise) | undefined; }; export const TransferButton = ({ @@ -50,11 +44,16 @@ export const TransferButton = ({ max, transfer, children, + isDisabled, ...props }: Props) => { const [closeDisabled, setCloseDisabled] = useState(false); - return ( + return transfer === undefined || isDisabled === true || max === 0n ? ( + + ) : ( Promise; setCloseDisabled: (value: boolean) => void; submitButtonText: string; close: () => void; @@ -118,14 +117,14 @@ const DialogContents = ({ }, [amount]); const doTransfer = useCallback( - (client: PythStakingClient, stakingAccount: PublicKey) => + () => amount.type === AmountType.Valid - ? transfer(client, stakingAccount, amount.amount) + ? transfer(amount.amount) : Promise.reject(new InvalidAmountError()), [amount, transfer], ); - const { execute, state } = useTransfer(doTransfer); + const { execute, state } = useAsync(doTransfer); const handleSubmit = useCallback( (e: FormEvent) => { @@ -176,7 +175,7 @@ const DialogContents = ({ variant="secondary" className="pointer-events-auto" onPress={setMax} - isDisabled={state.type === StateType.Submitting} + isDisabled={state.type === StateType.Running} > max @@ -194,7 +193,7 @@ const DialogContents = ({