瀏覽代碼

feat(staking): create a stake account on initial deposit

Connor Prussin 1 年之前
父節點
當前提交
20ce372774

+ 130 - 61
apps/staking/src/api.ts

@@ -1,7 +1,7 @@
 // TODO remove these disables when moving off the mock APIs
 /* eslint-disable @typescript-eslint/no-unused-vars */
 
-import type { HermesClient } from "@pythnetwork/hermes-client";
+import type { HermesClient, PublisherCaps } from "@pythnetwork/hermes-client";
 import {
   epochToDate,
   extractPublisherData,
@@ -38,7 +38,6 @@ type Data = {
         expiry: Date;
       }
     | undefined;
-  locked: bigint;
   unlockSchedule: {
     date: Date;
     amount: bigint;
@@ -141,7 +140,7 @@ export type AccountHistoryAction = ReturnType<
   (typeof AccountHistoryAction)[keyof typeof AccountHistoryAction]
 >;
 
-type AccountHistory = {
+export type AccountHistory = {
   timestamp: Date;
   action: AccountHistoryAction;
   amount: bigint;
@@ -159,38 +158,59 @@ export const getStakeAccounts = async (
 export const loadData = async (
   client: PythStakingClient,
   hermesClient: HermesClient,
-  stakeAccount: StakeAccountPositions,
+  stakeAccount?: StakeAccountPositions | undefined,
+): Promise<Data> =>
+  stakeAccount === undefined
+    ? loadDataNoStakeAccount(client, hermesClient)
+    : loadDataForStakeAccount(client, hermesClient, stakeAccount);
+
+const loadDataNoStakeAccount = async (
+  client: PythStakingClient,
+  hermesClient: HermesClient,
 ): Promise<Data> => {
+  const { publishers, ...baseInfo } = await loadBaseInfo(client, hermesClient);
+
+  return {
+    ...baseInfo,
+    lastSlash: undefined,
+    availableRewards: 0n,
+    expiringRewards: undefined,
+    total: 0n,
+    governance: {
+      warmup: 0n,
+      staked: 0n,
+      cooldown: 0n,
+      cooldown2: 0n,
+    },
+    unlockSchedule: [],
+    integrityStakingPublishers: publishers.map(
+      ({ stakeAccount, ...publisher }) => ({
+        ...publisher,
+        isSelf: false,
+      }),
+    ),
+  };
+};
+
+const loadDataForStakeAccount = async (
+  client: PythStakingClient,
+  hermesClient: HermesClient,
+  stakeAccount: StakeAccountPositions,
+) => {
   const [
+    { publishers, ...baseInfo },
     stakeAccountCustody,
-    poolData,
-    ownerPythBalance,
     unlockSchedule,
-    poolConfig,
     claimableRewards,
     currentEpoch,
-    publisherRankingsResponse,
-    publisherCaps,
   ] = await Promise.all([
+    loadBaseInfo(client, hermesClient),
     client.getStakeAccountCustody(stakeAccount.address),
-    client.getPoolDataAccount(),
-    client.getOwnerPythBalance(),
     client.getUnlockSchedule(stakeAccount.address),
-    client.getPoolConfigAccount(),
     client.getClaimableRewards(stakeAccount.address),
     getCurrentEpoch(client.connection),
-    fetch("/api/publishers-ranking"),
-    hermesClient.getLatestPublisherCaps({
-      parsed: true,
-    }),
   ]);
 
-  const publishers = extractPublisherData(poolData);
-
-  const publisherRankings = publishersRankingSchema.parse(
-    await publisherRankingsResponse.json(),
-  );
-
   const filterGovernancePositions = (positionState: PositionState) =>
     getAmountByTargetAndState({
       stakeAccountPositions: stakeAccount,
@@ -210,19 +230,12 @@ export const loadData = async (
       epoch: currentEpoch,
     });
 
-  const getPublisherCap = (publisher: PublicKey) =>
-    BigInt(
-      publisherCaps.parsed?.[0]?.publisher_stake_caps.find(
-        ({ publisher: p }) => p === publisher.toBase58(),
-      )?.cap ?? 0,
-    );
-
   return {
+    ...baseInfo,
     lastSlash: undefined, // TODO
     availableRewards: claimableRewards,
     expiringRewards: undefined, // TODO
     total: stakeAccountCustody.amount,
-    yieldRate: poolConfig.y,
     governance: {
       warmup: filterGovernancePositions(PositionState.LOCKING),
       staked: filterGovernancePositions(PositionState.LOCKED),
@@ -230,51 +243,92 @@ export const loadData = async (
       cooldown2: filterGovernancePositions(PositionState.UNLOCKED),
     },
     unlockSchedule,
-    locked: unlockSchedule.reduce((sum, { amount }) => sum + amount, 0n),
-    walletAmount: ownerPythBalance,
-    integrityStakingPublishers: publishers.map((publisherData) => {
-      const publisherPubkeyString = publisherData.pubkey.toBase58();
-      const publisherRanking = publisherRankings.find(
-        (ranking) => ranking.publisher === publisherPubkeyString,
-      );
-      const apyHistory = publisherData.apyHistory.map(({ epoch, apy }) => ({
-        date: epochToDate(epoch + 1n),
-        apy: Number(apy),
-      }));
-      return {
-        apyHistory,
-        isSelf:
-          publisherData.stakeAccount?.equals(stakeAccount.address) ?? false,
-        name: undefined, // TODO
-        numFeeds: publisherRanking?.numSymbols ?? 0,
-        poolCapacity: getPublisherCap(publisherData.pubkey),
-        poolUtilization: publisherData.totalDelegation,
-        publicKey: publisherData.pubkey,
-        qualityRanking: publisherRanking?.rank ?? 0,
-        selfStake: publisherData.selfDelegation,
+    integrityStakingPublishers: publishers.map(
+      ({ stakeAccount: publisherStakeAccount, ...publisher }) => ({
+        ...publisher,
+        isSelf: publisherStakeAccount?.equals(stakeAccount.address) ?? false,
         positions: {
           warmup: filterOISPositions(
-            publisherData.pubkey,
+            publisher.publicKey,
             PositionState.LOCKING,
           ),
-          staked: filterOISPositions(
-            publisherData.pubkey,
-            PositionState.LOCKED,
-          ),
+          staked: filterOISPositions(publisher.publicKey, PositionState.LOCKED),
           cooldown: filterOISPositions(
-            publisherData.pubkey,
+            publisher.publicKey,
             PositionState.PREUNLOCKING,
           ),
           cooldown2: filterOISPositions(
-            publisherData.pubkey,
+            publisher.publicKey,
             PositionState.UNLOCKED,
           ),
         },
-      };
-    }),
+      }),
+    ),
   };
 };
 
+const loadBaseInfo = async (
+  client: PythStakingClient,
+  hermesClient: HermesClient,
+) => {
+  const [publishers, walletAmount, poolConfig] = await Promise.all([
+    loadPublisherData(client, hermesClient),
+    client.getOwnerPythBalance(),
+    client.getPoolConfigAccount(),
+  ]);
+
+  return { yieldRate: poolConfig.y, walletAmount, publishers };
+};
+
+const loadPublisherData = async (
+  client: PythStakingClient,
+  hermesClient: HermesClient,
+) => {
+  const [poolData, publisherRankings, publisherCaps] = await Promise.all([
+    client.getPoolDataAccount(),
+    getPublisherRankings(),
+    hermesClient.getLatestPublisherCaps({
+      parsed: true,
+    }),
+  ]);
+
+  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 }) => ({
+      date: epochToDate(epoch + 1n),
+      apy: Number(apy),
+    }));
+
+    return {
+      apyHistory,
+      name: undefined, // TODO
+      numFeeds: publisherRanking?.numSymbols ?? 0,
+      poolCapacity: getPublisherCap(publisherCaps, publisher.pubkey),
+      poolUtilization: publisher.totalDelegation,
+      publicKey: publisher.pubkey,
+      qualityRanking: publisherRanking?.rank ?? 0,
+      selfStake: publisher.selfDelegation,
+      stakeAccount: publisher.stakeAccount,
+    };
+  });
+};
+
+const getPublisherRankings = async () => {
+  const response = await fetch("/api/publishers-ranking");
+  const responseAsJson: unknown = await response.json();
+  return publishersRankingSchema.parseAsync(responseAsJson);
+};
+
+const getPublisherCap = (publisherCaps: PublisherCaps, publisher: PublicKey) =>
+  BigInt(
+    publisherCaps.parsed?.[0]?.publisher_stake_caps.find(
+      ({ publisher: p }) => p === publisher.toBase58(),
+    )?.cap ?? 0,
+  );
+
 export const loadAccountHistory = async (
   _client: PythStakingClient,
   _stakeAccount: PublicKey,
@@ -283,6 +337,14 @@ export const loadAccountHistory = async (
   return mkMockHistory();
 };
 
+export const createStakeAccountAndDeposit = async (
+  _client: PythStakingClient,
+  _amount: bigint,
+): Promise<StakeAccountPositions> => {
+  await new Promise((resolve) => setTimeout(resolve, MOCK_DELAY));
+  throw new NotImplementedError();
+};
+
 export const deposit = async (
   client: PythStakingClient,
   stakeAccount: PublicKey,
@@ -431,3 +493,10 @@ const mkMockHistory = (): AccountHistory => [
     locked: 0n,
   },
 ];
+
+class NotImplementedError extends Error {
+  constructor() {
+    super("Not yet implemented!");
+    this.name = "NotImplementedError";
+  }
+}

+ 6 - 3
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:

+ 27 - 14
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}
           />
         </div>
       </div>
@@ -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={<ClaimButton isDisabled={availableRewards === 0n} />}
+          action={
+            api.type === ApiStateType.Loaded ? (
+              <ClaimButton isDisabled={availableRewards === 0n} api={api} />
+            ) : (
+              <Button size="small" variant="secondary" isDisabled={true}>
+                Claim
+              </Button>
+            )
+          }
           {...(expiringRewards !== undefined &&
             expiringRewards.amount > 0n && {
               warning: (
@@ -187,13 +198,15 @@ const BalanceCategory = ({
   </div>
 );
 
-const ClaimButton = (
-  props: Omit<
-    ComponentProps<typeof Button>,
-    "onClick" | "disabled" | "loading"
-  >,
-) => {
-  const { state, execute } = useTransfer(claim);
+type ClaimButtonProps = Omit<
+  ComponentProps<typeof Button>,
+  "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

+ 2 - 1
apps/staking/src/components/Button/index.tsx

@@ -11,9 +11,10 @@ type VariantProps = {
   size?: "small" | "nopad" | undefined;
 };
 
-type ButtonProps = ComponentProps<typeof ReactAriaButton> &
+type ButtonProps = Omit<ComponentProps<typeof ReactAriaButton>, "isDisabled"> &
   VariantProps & {
     isLoading?: boolean | undefined;
+    isDisabled?: boolean | undefined;
   };
 
 export const Button = ({

+ 11 - 2
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:
     | {
@@ -22,7 +24,6 @@ type Props = {
         expiry: Date;
       }
     | undefined;
-  locked: bigint;
   unlockSchedule: {
     amount: bigint;
     date: Date;
@@ -40,6 +41,7 @@ type Props = {
 };
 
 export const Dashboard = ({
+  api,
   total,
   lastSlash,
   walletAmount,
@@ -47,7 +49,6 @@ export const Dashboard = ({
   expiringRewards,
   governance,
   integrityStakingPublishers,
-  locked,
   unlockSchedule,
   yieldRate,
 }: Props) => {
@@ -84,6 +85,11 @@ export const Dashboard = ({
     "cooldown2",
   );
 
+  const locked = useMemo(
+    () => unlockSchedule.reduce((sum, { amount }) => sum + amount, 0n),
+    [unlockSchedule],
+  );
+
   const availableToStakeIntegrity = useMemo(
     () =>
       total -
@@ -110,6 +116,7 @@ export const Dashboard = ({
   return (
     <div className="flex w-full flex-col gap-8">
       <AccountSummary
+        api={api}
         locked={locked}
         unlockSchedule={unlockSchedule}
         lastSlash={lastSlash}
@@ -142,6 +149,7 @@ export const Dashboard = ({
         </DashboardTabPanel>
         <DashboardTabPanel id={TabIds.Governance}>
           <Governance
+            api={api}
             availableToStake={availableToStakeGovernance}
             warmup={governance.warmup}
             staked={governance.staked}
@@ -151,6 +159,7 @@ export const Dashboard = ({
         </DashboardTabPanel>
         <DashboardTabPanel id={TabIds.IntegrityStaking}>
           <OracleIntegrityStaking
+            api={api}
             availableToStake={availableToStakeIntegrity}
             locked={locked}
             warmup={integrityStakingWarmup}

+ 10 - 8
apps/staking/src/components/Governance/index.tsx

@@ -1,11 +1,8 @@
-import {
-  stakeGovernance,
-  cancelWarmupGovernance,
-  unstakeGovernance,
-} from "../../api";
+import { type States, StateType as ApiStateType } from "../../hooks/use-api";
 import { ProgramSection } from "../ProgramSection";
 
 type Props = {
+  api: States[ApiStateType.Loaded] | States[ApiStateType.LoadedNoStakeAccount];
   availableToStake: bigint;
   warmup: bigint;
   staked: bigint;
@@ -14,6 +11,7 @@ type Props = {
 };
 
 export const Governance = ({
+  api,
   availableToStake,
   warmup,
   staked,
@@ -28,11 +26,15 @@ export const Governance = ({
     staked={staked}
     cooldown={cooldown}
     cooldown2={cooldown2}
-    stake={stakeGovernance}
+    stake={api.type === ApiStateType.Loaded ? api.stakeGovernance : undefined}
     stakeDescription="Stake funds to participate in governance votes"
-    cancelWarmup={cancelWarmupGovernance}
+    cancelWarmup={
+      api.type === ApiStateType.Loaded ? api.cancelWarmupGovernance : undefined
+    }
     cancelWarmupDescription="Cancel staking tokens for governance that are currently in warmup"
-    unstake={unstakeGovernance}
+    unstake={
+      api.type === ApiStateType.Loaded ? api.unstakeGovernance : undefined
+    }
     unstakeDescription="Unstake tokens from the Governance program"
   />
 );

+ 24 - 30
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 <Loading />;
     }
-    case StakeAccountStateType.NoAccounts: {
-      return (
-        <main className="my-20">
-          <p>No stake account found for your wallet!</p>
-        </main>
-      );
-    }
-    case StakeAccountStateType.NoWallet: {
+    case ApiStateType.NoWallet: {
       return <NoWalletHome />;
     }
-    case StakeAccountStateType.Error: {
-      return (
-        <ErrorPage
-          error={stakeAccountState.error}
-          reset={stakeAccountState.reset}
-        />
-      );
+    case ApiStateType.ErrorLoadingStakeAccounts: {
+      return <ErrorPage error={api.error} reset={api.reset} />;
     }
-    case StakeAccountStateType.Loaded: {
-      return <StakeAccountLoadedHome />;
+    case ApiStateType.LoadedNoStakeAccount:
+    case ApiStateType.Loaded: {
+      return <StakeAccountLoadedHome api={api} />;
     }
   }
 };
@@ -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 (
         <main className="mx-4 my-6">
-          <Dashboard {...data.data} />
+          <Dashboard {...data.data} api={api} />
         </main>
       );
     }

+ 40 - 42
apps/staking/src/components/OracleIntegrityStaking/index.tsx

@@ -3,7 +3,7 @@ import {
   XMarkIcon,
   MagnifyingGlassIcon,
 } from "@heroicons/react/24/outline";
-import { calculateApy, type PythStakingClient } from "@pythnetwork/staking-sdk";
+import { calculateApy } from "@pythnetwork/staking-sdk";
 import { PublicKey } from "@solana/web3.js";
 import clsx from "clsx";
 import {
@@ -23,11 +23,7 @@ import {
   Label,
 } from "react-aria-components";
 
-import {
-  delegateIntegrityStaking,
-  cancelWarmupIntegrityStaking,
-  unstakeIntegrityStaking,
-} from "../../api";
+import { type States, StateType as ApiStateType } from "../../hooks/use-api";
 import { Button } from "../Button";
 import { ProgramSection } from "../ProgramSection";
 import { SparkChart } from "../SparkChart";
@@ -39,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 +47,7 @@ type Props = {
 };
 
 export const OracleIntegrityStaking = ({
+  api,
   availableToStake,
   locked,
   warmup,
@@ -112,6 +110,7 @@ export const OracleIntegrityStaking = ({
               </thead>
               <tbody className="bg-pythpurple-400/10">
                 <Publisher
+                  api={api}
                   isSelf
                   availableToStake={availableToStake}
                   publisher={self}
@@ -130,6 +129,7 @@ export const OracleIntegrityStaking = ({
         )}
       >
         <PublisherList
+          api={api}
           title={self ? "Other Publishers" : "Publishers"}
           availableToStake={availableToStake}
           publishers={otherPublishers}
@@ -142,6 +142,7 @@ export const OracleIntegrityStaking = ({
 };
 
 type PublisherListProps = {
+  api: States[ApiStateType.Loaded] | States[ApiStateType.LoadedNoStakeAccount];
   title: string;
   availableToStake: bigint;
   totalStaked: bigint;
@@ -150,6 +151,7 @@ type PublisherListProps = {
 };
 
 const PublisherList = ({
+  api,
   title,
   availableToStake,
   publishers,
@@ -311,19 +313,17 @@ const PublisherList = ({
               field={SortField.QualityRanking}
               sort={sort}
               setSort={updateSort}
-              className={clsx({ "pr-4 sm:pr-10": availableToStake <= 0n })}
             >
               Quality ranking
             </SortablePublisherTableHeader>
-            {availableToStake > 0n && (
-              <PublisherTableHeader className="pr-4 sm:pr-10" />
-            )}
+            <PublisherTableHeader className="pr-4 sm:pr-10" />
           </tr>
         </thead>
 
         <tbody className="bg-white/5">
           {paginatedPublishers.map((publisher) => (
             <Publisher
+              api={api}
               key={publisher.publicKey.toBase58()}
               availableToStake={availableToStake}
               publisher={publisher}
@@ -421,6 +421,7 @@ const PublisherTableHeader = Styled(
 );
 
 type PublisherProps = {
+  api: States[ApiStateType.Loaded] | States[ApiStateType.LoadedNoStakeAccount];
   availableToStake: bigint;
   totalStaked: bigint;
   isSelf?: boolean;
@@ -447,6 +448,7 @@ type PublisherProps = {
 };
 
 const Publisher = ({
+  api,
   publisher,
   availableToStake,
   totalStaked,
@@ -471,11 +473,13 @@ const Publisher = ({
   );
 
   const cancelWarmup = useTransferActionForPublisher(
-    cancelWarmupIntegrityStaking,
+    api.type === ApiStateType.Loaded
+      ? api.cancelWarmupIntegrityStaking
+      : undefined,
     publisher.publicKey,
   );
   const unstake = useTransferActionForPublisher(
-    unstakeIntegrityStaking,
+    api.type === ApiStateType.Loaded ? api.unstakeIntegrityStaking : undefined,
     publisher.publicKey,
   );
   const utilizationPercent = useMemo(
@@ -561,29 +565,24 @@ const Publisher = ({
         <PublisherTableCell className="text-center">
           {publisher.numFeeds}
         </PublisherTableCell>
+        <PublisherTableCell className="text-center">
+          {publisher.qualityRanking}
+        </PublisherTableCell>
         <PublisherTableCell
-          className={clsx("text-center", {
-            "pr-4 sm:pr-10": availableToStake <= 0n && !isSelf,
-          })}
+          className={clsx("text-right", { "pr-4 sm:pr-10": !isSelf })}
         >
-          {publisher.qualityRanking}
+          <StakeToPublisherButton
+            api={api}
+            availableToStake={availableToStake}
+            poolCapacity={publisher.poolCapacity}
+            poolUtilization={publisher.poolUtilization}
+            publisherKey={publisher.publicKey}
+            publisherName={publisher.name}
+            isSelf={publisher.isSelf}
+            selfStake={publisher.selfStake}
+            yieldRate={yieldRate}
+          />
         </PublisherTableCell>
-        {availableToStake > 0 && (
-          <PublisherTableCell
-            className={clsx("text-right", { "pr-4 sm:pr-10": !isSelf })}
-          >
-            <StakeToPublisherButton
-              availableToStake={availableToStake}
-              poolCapacity={publisher.poolCapacity}
-              poolUtilization={publisher.poolUtilization}
-              publisherKey={publisher.publicKey}
-              publisherName={publisher.name}
-              isSelf={publisher.isSelf}
-              selfStake={publisher.selfStake}
-              yieldRate={yieldRate}
-            />
-          </PublisherTableCell>
-        )}
       </tr>
       {(warmup !== undefined || staked !== undefined) && (
         <tr>
@@ -659,6 +658,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;
@@ -670,6 +670,7 @@ type StakeToPublisherButtonProps = {
 };
 
 const StakeToPublisherButton = ({
+  api,
   publisherName,
   publisherKey,
   poolCapacity,
@@ -680,7 +681,7 @@ const StakeToPublisherButton = ({
   yieldRate,
 }: StakeToPublisherButtonProps) => {
   const delegate = useTransferActionForPublisher(
-    delegateIntegrityStaking,
+    api.type === ApiStateType.Loaded ? api.delegateIntegrityStaking : undefined,
     publisherKey,
   );
 
@@ -726,17 +727,14 @@ const StakeToPublisherButton = ({
 };
 
 const useTransferActionForPublisher = (
-  action: (
-    client: PythStakingClient,
-    stakingAccount: PublicKey,
-    publisher: PublicKey,
-    amount: bigint,
-  ) => Promise<void>,
+  action: ((publisher: PublicKey, amount: bigint) => Promise<void>) | 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],
   );
 

+ 51 - 61
apps/staking/src/components/ProgramSection/index.tsx

@@ -14,15 +14,11 @@ type Props = HTMLAttributes<HTMLDivElement> & {
   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<typeof TransferButton>["transfer"] | undefined;
         stakeDescription: string;
       }
@@ -73,32 +69,27 @@ export const ProgramSection = ({
     <h2 className="text-xl font-light sm:text-3xl">{name}</h2>
     <p className="text-sm sm:text-base">{description}</p>
     <div className="mt-8 flex flex-col items-stretch justify-center border border-neutral-600/50 bg-white/5 px-2 py-6 sm:p-10 xl:flex-row">
-      {available !== undefined && (
-        <>
-          <Position
-            name="Available to Stake"
-            nameClassName="bg-[rgba(43,_129,_167,_0.25)]"
-            details={availableToStakeDetails}
-            {...(stake !== undefined &&
-              available > 0n && {
-                actions: (
-                  <TransferButton
-                    size="small"
-                    actionDescription={stakeDescription}
-                    actionName="Stake"
-                    max={available}
-                    transfer={stake}
-                  >
-                    <StakingTimeline />
-                  </TransferButton>
-                ),
-              })}
-          >
-            {available}
-          </Position>
-          <Arrow />
-        </>
-      )}
+      <Position
+        name="Available to Stake"
+        nameClassName="bg-[rgba(43,_129,_167,_0.25)]"
+        details={availableToStakeDetails}
+        {...(stakeDescription !== undefined && {
+          actions: (
+            <TransferButton
+              size="small"
+              actionDescription={stakeDescription}
+              actionName="Stake"
+              max={available}
+              transfer={stake}
+            >
+              <StakingTimeline />
+            </TransferButton>
+          ),
+        })}
+      >
+        {available}
+      </Position>
+      <Arrow />
       <Position
         name="Warmup"
         nameClassName="bg-[rgba(206,_153,_247,_0.25)]"
@@ -108,20 +99,20 @@ export const ProgramSection = ({
               Staking {getUpcomingEpoch().toLocaleString()}
             </div>
           ),
-          ...(cancelWarmup !== undefined && {
-            actions: (
-              <TransferButton
-                size="small"
-                variant="secondary"
-                actionDescription={cancelWarmupDescription}
-                actionName="Cancel"
-                submitButtonText="Cancel Warmup"
-                title="Cancel Warmup"
-                max={warmup}
-                transfer={cancelWarmup}
-              />
-            ),
-          }),
+        })}
+        {...(cancelWarmupDescription !== undefined && {
+          actions: (
+            <TransferButton
+              size="small"
+              variant="secondary"
+              actionDescription={cancelWarmupDescription}
+              actionName="Cancel"
+              submitButtonText="Cancel Warmup"
+              title="Cancel Warmup"
+              max={warmup}
+              transfer={cancelWarmup}
+            />
+          ),
         })}
       >
         {warmup}
@@ -130,21 +121,20 @@ export const ProgramSection = ({
       <Position
         name="Staked"
         nameClassName="bg-[rgba(105,_24,_238,_0.25)]"
-        {...(unstake !== undefined &&
-          staked > 0n && {
-            actions: (
-              <TransferButton
-                size="small"
-                variant="secondary"
-                actionDescription={unstakeDescription}
-                actionName="Unstake"
-                max={staked}
-                transfer={unstake}
-              >
-                <StakingTimeline cooldownOnly />
-              </TransferButton>
-            ),
-          })}
+        {...(unstakeDescription !== undefined && {
+          actions: (
+            <TransferButton
+              size="small"
+              variant="secondary"
+              actionDescription={unstakeDescription}
+              actionName="Unstake"
+              max={staked}
+              transfer={unstake}
+            >
+              <StakingTimeline cooldownOnly />
+            </TransferButton>
+          ),
+        })}
       >
         {staked}
       </Position>

+ 3 - 3
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
         }
       >
-        <StakeAccountProvider>
+        <ApiProvider isMainnet={IS_MAINNET}>
           <html
             lang="en"
             dir="ltr"
@@ -65,7 +65,7 @@ export const Root = ({ children }: Props) => (
             {AMPLITUDE_API_KEY && <Amplitude apiKey={AMPLITUDE_API_KEY} />}
             {!IS_PRODUCTION_SERVER && <ReportAccessibility />}
           </html>
-        </StakeAccountProvider>
+        </ApiProvider>
       </WalletProvider>
     </LoggerProvider>
   </RouterProvider>

+ 14 - 15
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<ComponentProps<typeof Button>, "children"> & {
     | ReactNode
     | ReactNode[]
     | undefined;
-  transfer: (
-    client: PythStakingClient,
-    stakingAccount: PublicKey,
-    amount: bigint,
-  ) => Promise<void>;
+  transfer?: ((amount: bigint) => Promise<void>) | 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 ? (
+    <Button isDisabled={true} {...props}>
+      {actionName}
+    </Button>
+  ) : (
     <DialogTrigger>
       <Button {...props}>{actionName}</Button>
       <ModalDialog
@@ -81,7 +80,7 @@ export const TransferButton = ({
 type DialogContentsProps = {
   max: bigint;
   children: Props["children"];
-  transfer: Props["transfer"];
+  transfer: (amount: bigint) => Promise<void>;
   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<HTMLFormElement>) => {
@@ -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
             </Button>
@@ -194,7 +193,7 @@ const DialogContents = ({
       <Button
         className="mt-6 w-full"
         type="submit"
-        isLoading={state.type === StateType.Submitting}
+        isLoading={state.type === StateType.Running}
         isDisabled={amount.type !== AmountType.Valid}
       >
         {validationError ?? submitButtonText}

+ 72 - 50
apps/staking/src/components/WalletButton/index.tsx

@@ -33,9 +33,13 @@ import {
   SubmenuTrigger,
 } from "react-aria-components";
 
+import {
+  StateType as ApiStateType,
+  type States,
+  useApi,
+} from "../../hooks/use-api";
 import { useLogger } from "../../hooks/use-logger";
 import { usePrimaryDomain } from "../../hooks/use-primary-domain";
-import { StateType, useStakeAccount } from "../../hooks/use-stake-account";
 import { AccountHistory } from "../AccountHistory";
 import { Button } from "../Button";
 import { ModalDialog } from "../ModalDialog";
@@ -43,16 +47,36 @@ import { ModalDialog } from "../ModalDialog";
 type Props = Omit<ComponentProps<typeof Button>, "onClick" | "children">;
 
 export const WalletButton = (props: Props) => {
-  const wallet = useWallet();
+  const api = useApi();
 
-  return wallet.connected ? (
-    <ConnectedButton {...props} />
-  ) : (
-    <DisconnectedButton {...props} />
-  );
+  switch (api.type) {
+    case ApiStateType.NotLoaded:
+    case ApiStateType.NoWallet: {
+      return <DisconnectedButton {...props} />;
+    }
+
+    case ApiStateType.ErrorLoadingStakeAccounts:
+    case ApiStateType.Loaded:
+    case ApiStateType.LoadedNoStakeAccount:
+    case ApiStateType.LoadingStakeAccounts: {
+      return <ConnectedButton {...props} api={api} />;
+    }
+  }
 };
 
-const ConnectedButton = ({ className, ...props }: Props) => {
+type ConnectedButtonProps = Props & {
+  api:
+    | States[ApiStateType.ErrorLoadingStakeAccounts]
+    | States[ApiStateType.Loaded]
+    | States[ApiStateType.LoadedNoStakeAccount]
+    | States[ApiStateType.LoadingStakeAccounts];
+};
+
+const ConnectedButton = ({
+  className,
+  api,
+  ...props
+}: ConnectedButtonProps) => {
   const [accountHistoryOpen, setAccountHistoryOpen] = useState(false);
   const openAccountHistory = useCallback(() => {
     setAccountHistoryOpen(true);
@@ -61,9 +85,8 @@ const ConnectedButton = ({ className, ...props }: Props) => {
   const showModal = useCallback(() => {
     modal.setVisible(true);
   }, [modal]);
-  const stakeAccountState = useStakeAccount();
-  const wallet = useWallet();
   const logger = useLogger();
+  const wallet = useWallet();
   const disconnectWallet = useCallback(() => {
     wallet.disconnect().catch((error: unknown) => {
       logger.error(error);
@@ -86,46 +109,45 @@ const ConnectedButton = ({ className, ...props }: Props) => {
           <ChevronDownIcon className="size-4 flex-none opacity-60 transition duration-300 group-data-[pressed]:-rotate-180" />
         </ButtonComponent>
         <StyledMenu className="min-w-[var(--trigger-width)]">
-          {stakeAccountState.type === StateType.Loaded && (
+          {api.type === ApiStateType.Loaded && (
             <>
-              <SubmenuTrigger>
-                <WalletMenuItem
-                  icon={BanknotesIcon}
-                  textValue="Select stake account"
-                >
-                  <span>Select stake account</span>
-                  <ChevronRightIcon className="size-4" />
-                </WalletMenuItem>
-                <StyledMenu
-                  items={stakeAccountState.allAccounts.map((account) => ({
-                    account,
-                    id: account.address.toBase58(),
-                  }))}
-                >
-                  {(item) => (
-                    <WalletMenuItem
-                      onAction={() => {
-                        stakeAccountState.selectAccount(item.account);
-                      }}
-                      className={clsx({
-                        "font-semibold":
-                          item.account === stakeAccountState.account,
-                      })}
-                      isDisabled={item.account === stakeAccountState.account}
-                    >
-                      <CheckIcon
-                        className={clsx("size-4 text-pythpurple-600", {
-                          invisible: item.account !== stakeAccountState.account,
-                        })}
-                      />
-                      <pre>
-                        <TruncatedKey>{item.account.address}</TruncatedKey>
-                      </pre>
-                    </WalletMenuItem>
-                  )}
-                </StyledMenu>
-              </SubmenuTrigger>
               <Section className="flex w-full flex-col">
+                <SubmenuTrigger>
+                  <WalletMenuItem
+                    icon={BanknotesIcon}
+                    textValue="Select stake account"
+                  >
+                    <span>Select stake account</span>
+                    <ChevronRightIcon className="size-4" />
+                  </WalletMenuItem>
+                  <StyledMenu
+                    items={api.allAccounts.map((account) => ({
+                      account,
+                      id: account.address.toBase58(),
+                    }))}
+                  >
+                    {(item) => (
+                      <WalletMenuItem
+                        onAction={() => {
+                          api.selectAccount(item.account);
+                        }}
+                        className={clsx({
+                          "font-semibold": item.account === api.account,
+                        })}
+                        isDisabled={item.account === api.account}
+                      >
+                        <CheckIcon
+                          className={clsx("size-4 text-pythpurple-600", {
+                            invisible: item.account !== api.account,
+                          })}
+                        />
+                        <pre>
+                          <TruncatedKey>{item.account.address}</TruncatedKey>
+                        </pre>
+                      </WalletMenuItem>
+                    )}
+                  </StyledMenu>
+                </SubmenuTrigger>
                 <WalletMenuItem
                   onAction={openAccountHistory}
                   icon={TableCellsIcon}
@@ -146,14 +168,14 @@ const ConnectedButton = ({ className, ...props }: Props) => {
           </Section>
         </StyledMenu>
       </MenuTrigger>
-      {stakeAccountState.type === StateType.Loaded && (
+      {api.type === ApiStateType.Loaded && (
         <ModalDialog
           isOpen={accountHistoryOpen}
           onOpenChange={setAccountHistoryOpen}
           title="Account history"
           description="A history of events that have affected your account balances"
         >
-          <AccountHistory />
+          <AccountHistory api={api} />
         </ModalDialog>
       )}
     </>

+ 0 - 55
apps/staking/src/hooks/use-account-history.ts

@@ -1,55 +0,0 @@
-import { PublicKey } from "@solana/web3.js";
-import useSWR from "swr";
-
-import { useSelectedStakeAccount } from "./use-stake-account";
-import { loadAccountHistory } from "../api";
-
-const ONE_SECOND_IN_MS = 1000;
-const ONE_MINUTE_IN_MS = 60 * ONE_SECOND_IN_MS;
-const REFRESH_INTERVAL = 1 * ONE_MINUTE_IN_MS;
-
-export const getCacheKey = (stakeAccount: PublicKey) =>
-  `${stakeAccount.toBase58()}/history`;
-
-export const useAccountHistory = () => {
-  const { client, account } = useSelectedStakeAccount();
-
-  const { data, isLoading, ...rest } = useSWR(
-    getCacheKey(account.address),
-    () => loadAccountHistory(client, account.address),
-    {
-      refreshInterval: REFRESH_INTERVAL,
-    },
-  );
-  const error = rest.error as unknown;
-
-  if (error) {
-    return State.ErrorState(error);
-  } else if (isLoading) {
-    return State.Loading();
-  } else if (data) {
-    return State.Loaded(data);
-  } else {
-    return State.NotLoaded();
-  }
-};
-
-export enum StateType {
-  NotLoaded,
-  Loading,
-  Loaded,
-  Error,
-}
-const State = {
-  NotLoaded: () => ({ type: StateType.NotLoaded as const }),
-  Loading: () => ({ type: StateType.Loading as const }),
-  Loaded: (data: Awaited<ReturnType<typeof loadAccountHistory>>) => ({
-    type: StateType.Loaded as const,
-    data,
-  }),
-  ErrorState: (error: unknown) => ({
-    type: StateType.Error as const,
-    error,
-  }),
-};
-type State = ReturnType<(typeof State)[keyof typeof State]>;

+ 291 - 0
apps/staking/src/hooks/use-api.tsx

@@ -0,0 +1,291 @@
+"use client";
+
+import { HermesClient } from "@pythnetwork/hermes-client";
+import {
+  type StakeAccountPositions,
+  PythStakingClient,
+} from "@pythnetwork/staking-sdk";
+import { useConnection, useWallet } from "@solana/wallet-adapter-react";
+import type { PublicKey } from "@solana/web3.js";
+import {
+  type ComponentProps,
+  createContext,
+  useContext,
+  useCallback,
+  useState,
+  useEffect,
+  useRef,
+} from "react";
+import { useSWRConfig } from "swr";
+
+import * as api from "../api";
+
+const MAINNET_HERMES_URL = "https://hermes.pyth.network";
+const DEVNET_HERMES_URL = "https://hermes-beta.pyth.network";
+
+export enum StateType {
+  NotLoaded,
+  NoWallet,
+  LoadingStakeAccounts,
+  LoadedNoStakeAccount,
+  Loaded,
+  ErrorLoadingStakeAccounts,
+}
+
+const State = {
+  [StateType.NotLoaded]: () => ({ type: StateType.NotLoaded as const }),
+
+  [StateType.NoWallet]: () => ({ type: StateType.NoWallet as const }),
+
+  [StateType.LoadingStakeAccounts]: (
+    client: PythStakingClient,
+    hermesClient: HermesClient,
+  ) => ({
+    type: StateType.LoadingStakeAccounts as const,
+    client,
+    hermesClient,
+  }),
+
+  [StateType.LoadedNoStakeAccount]: (
+    client: PythStakingClient,
+    hermesClient: HermesClient,
+    onCreateAccount: (newAccount: StakeAccountPositions) => void,
+  ) => ({
+    type: StateType.LoadedNoStakeAccount as const,
+    client,
+    hermesClient,
+    dashboardDataCacheKey: client.wallet.publicKey.toBase58(),
+    loadData: () => api.loadData(client, hermesClient),
+    deposit: async (amount: bigint) => {
+      const account = await api.createStakeAccountAndDeposit(client, amount);
+      onCreateAccount(account);
+    },
+  }),
+
+  [StateType.Loaded]: (
+    client: PythStakingClient,
+    hermesClient: HermesClient,
+    account: StakeAccountPositions,
+    allAccounts: [StakeAccountPositions, ...StakeAccountPositions[]],
+    selectAccount: (account: StakeAccountPositions) => void,
+    mutate: ReturnType<typeof useSWRConfig>["mutate"],
+  ) => {
+    const dashboardDataCacheKey = account.address.toBase58();
+    const accountHisoryCacheKey = `${account.address.toBase58()}/history`;
+
+    const reload = async () => {
+      await Promise.all([
+        mutate(dashboardDataCacheKey),
+        mutate(accountHisoryCacheKey),
+      ]);
+    };
+
+    const bindApi =
+      <T extends unknown[]>(
+        fn: (
+          client: PythStakingClient,
+          stakeAccount: PublicKey,
+          ...args: T
+        ) => Promise<void>,
+      ) =>
+      async (...args: T) => {
+        await fn(client, account.address, ...args);
+        await reload();
+      };
+
+    return {
+      type: StateType.Loaded as const,
+      client,
+      hermesClient,
+      account,
+      allAccounts,
+      selectAccount,
+      dashboardDataCacheKey,
+      accountHisoryCacheKey,
+
+      loadData: () => api.loadData(client, hermesClient, account),
+      loadAccountHistory: () => api.loadAccountHistory(client, account.address),
+
+      claim: bindApi(api.claim),
+      deposit: bindApi(api.deposit),
+      withdraw: bindApi(api.withdraw),
+      stakeGovernance: bindApi(api.stakeGovernance),
+      cancelWarmupGovernance: bindApi(api.cancelWarmupGovernance),
+      unstakeGovernance: bindApi(api.unstakeGovernance),
+      delegateIntegrityStaking: bindApi(api.delegateIntegrityStaking),
+      unstakeIntegrityStaking: bindApi(api.unstakeIntegrityStaking),
+      cancelWarmupIntegrityStaking: bindApi(api.cancelWarmupIntegrityStaking),
+    };
+  },
+
+  [StateType.ErrorLoadingStakeAccounts]: (
+    client: PythStakingClient,
+    error: LoadStakeAccountsError,
+    reset: () => void,
+  ) => ({
+    type: StateType.ErrorLoadingStakeAccounts as const,
+    client,
+    error,
+    reset,
+  }),
+};
+
+export type States = {
+  [key in keyof typeof State]: ReturnType<(typeof State)[key]>;
+};
+export type State = States[keyof States];
+
+const ApiContext = createContext<State | undefined>(undefined);
+
+type ApiProviderProps = Omit<
+  ComponentProps<typeof ApiContext.Provider>,
+  "value"
+> & {
+  isMainnet: boolean;
+};
+
+export const ApiProvider = ({ isMainnet, ...props }: ApiProviderProps) => {
+  const state = useApiContext(isMainnet);
+
+  return <ApiContext.Provider value={state} {...props} />;
+};
+
+const useApiContext = (isMainnet: boolean) => {
+  const loading = useRef(false);
+  const wallet = useWallet();
+  const { connection } = useConnection();
+  const [state, setState] = useState<State>(State[StateType.NotLoaded]());
+  const { mutate } = useSWRConfig();
+
+  const setAccount = useCallback(
+    (account: StakeAccountPositions) => {
+      setState((cur) =>
+        cur.type === StateType.Loaded
+          ? State[StateType.Loaded](
+              cur.client,
+              cur.hermesClient,
+              account,
+              cur.allAccounts,
+              setAccount,
+              mutate,
+            )
+          : cur,
+      );
+    },
+    [setState, mutate],
+  );
+
+  const reset = useCallback(() => {
+    if (wallet.connected && !wallet.disconnecting && !loading.current) {
+      loading.current = true;
+      if (
+        !wallet.publicKey ||
+        !wallet.signAllTransactions ||
+        !wallet.signTransaction
+      ) {
+        throw new WalletConnectedButInvalidError();
+      }
+      const client = new PythStakingClient({
+        connection,
+        wallet: {
+          publicKey: wallet.publicKey,
+          signAllTransactions: wallet.signAllTransactions,
+          signTransaction: wallet.signTransaction,
+        },
+      });
+      const hermesClient = new HermesClient(
+        isMainnet ? MAINNET_HERMES_URL : DEVNET_HERMES_URL,
+      );
+      setState(State[StateType.LoadingStakeAccounts](client, hermesClient));
+      api
+        .getStakeAccounts(client)
+        .then((accounts) => {
+          const [firstAccount, ...otherAccounts] = accounts;
+          if (firstAccount) {
+            setState(
+              State[StateType.Loaded](
+                client,
+                hermesClient,
+                firstAccount,
+                [firstAccount, ...otherAccounts],
+                setAccount,
+                mutate,
+              ),
+            );
+          } else {
+            setState(
+              State[StateType.LoadedNoStakeAccount](
+                client,
+                hermesClient,
+                (newAccount) => {
+                  setState(
+                    State[StateType.Loaded](
+                      client,
+                      hermesClient,
+                      newAccount,
+                      [newAccount],
+                      setAccount,
+                      mutate,
+                    ),
+                  );
+                },
+              ),
+            );
+          }
+        })
+        .catch((error: unknown) => {
+          setState(
+            State[StateType.ErrorLoadingStakeAccounts](
+              client,
+              new LoadStakeAccountsError(error),
+              reset,
+            ),
+          );
+        })
+        .finally(() => {
+          loading.current = false;
+        });
+    }
+  }, [connection, setAccount, wallet, mutate, isMainnet]);
+
+  useEffect(() => {
+    reset();
+  }, [reset]);
+
+  return (wallet.connected && !wallet.disconnecting) || wallet.connecting
+    ? state
+    : State[StateType.NoWallet]();
+};
+
+export const useApi = () => {
+  const state = useContext(ApiContext);
+  if (state === undefined) {
+    throw new NotInitializedError();
+  } else {
+    return state;
+  }
+};
+
+class LoadStakeAccountsError extends Error {
+  constructor(cause: unknown) {
+    super(cause instanceof Error ? cause.message : "");
+    this.name = "LoadStakeAccountsError";
+    this.cause = cause;
+  }
+}
+
+class NotInitializedError extends Error {
+  constructor() {
+    super(
+      "This component must be a child of <WalletProvider> to use the `useWallet` hook",
+    );
+  }
+}
+
+class WalletConnectedButInvalidError extends Error {
+  constructor() {
+    super(
+      "The wallet is connected but is missing a public key or methods to sign transactions!",
+    );
+  }
+}

+ 52 - 0
apps/staking/src/hooks/use-async.ts

@@ -0,0 +1,52 @@
+import { useState, useCallback } from "react";
+
+import { useLogger } from "./use-logger";
+
+export const useAsync = (fn: () => Promise<void>) => {
+  const logger = useLogger();
+  const [state, setState] = useState<State>(State.Base());
+
+  const execute = useCallback(async () => {
+    if (state.type === StateType.Running) {
+      throw new AlreadyInProgressError();
+    }
+
+    setState(State.Running());
+    try {
+      await fn();
+      setState(State.Complete());
+    } catch (error: unknown) {
+      logger.error(error);
+      setState(State.ErrorState(error));
+      throw error;
+    }
+  }, [state, fn, setState, logger]);
+
+  return { state, execute };
+};
+
+export enum StateType {
+  Base,
+  Running,
+  Error,
+  Complete,
+}
+
+const State = {
+  Base: () => ({ type: StateType.Base as const }),
+  Running: () => ({ type: StateType.Running as const }),
+  Complete: () => ({ type: StateType.Complete as const }),
+  ErrorState: (error: unknown) => ({
+    type: StateType.Error as const,
+    error,
+  }),
+};
+
+type State = ReturnType<(typeof State)[keyof typeof State]>;
+
+class AlreadyInProgressError extends Error {
+  constructor() {
+    super("Can't run async hook when already in progress");
+    this.name = "AlreadyInProgressError";
+  }
+}

+ 5 - 18
apps/staking/src/hooks/use-dashboard-data.ts → apps/staking/src/hooks/use-data.ts

@@ -1,26 +1,15 @@
-import { PublicKey } from "@solana/web3.js";
 import { useCallback } from "react";
 import useSWR from "swr";
 
-import { useSelectedStakeAccount } from "./use-stake-account";
-import { loadData } from "../api";
-
 const ONE_SECOND_IN_MS = 1000;
 const ONE_MINUTE_IN_MS = 60 * ONE_SECOND_IN_MS;
 const REFRESH_INTERVAL = 1 * ONE_MINUTE_IN_MS;
 
-export const getCacheKey = (stakeAccount: PublicKey) => stakeAccount.toBase58();
-
-export const useDashboardData = () => {
-  const { client, hermesClient, account } = useSelectedStakeAccount();
+export const useData = <T>(cacheKey: string, loadData: () => Promise<T>) => {
+  const { data, isLoading, mutate, ...rest } = useSWR(cacheKey, loadData, {
+    refreshInterval: REFRESH_INTERVAL,
+  });
 
-  const { data, isLoading, mutate, ...rest } = useSWR(
-    getCacheKey(account.address),
-    () => loadData(client, hermesClient, account),
-    {
-      refreshInterval: REFRESH_INTERVAL,
-    },
-  );
   const error = rest.error as unknown;
 
   const reset = useCallback(() => {
@@ -50,7 +39,7 @@ export enum StateType {
 const State = {
   NotLoaded: () => ({ type: StateType.NotLoaded as const }),
   Loading: () => ({ type: StateType.Loading as const }),
-  Loaded: (data: Awaited<ReturnType<typeof loadData>>) => ({
+  Loaded: <T>(data: T) => ({
     type: StateType.Loaded as const,
     data,
   }),
@@ -61,8 +50,6 @@ const State = {
   }),
 };
 
-type State = ReturnType<(typeof State)[keyof typeof State]>;
-
 class LoadDashboardDataError extends Error {
   constructor(cause: unknown) {
     super(cause instanceof Error ? cause.message : "");

+ 0 - 207
apps/staking/src/hooks/use-stake-account.tsx

@@ -1,207 +0,0 @@
-"use client";
-
-import { HermesClient } from "@pythnetwork/hermes-client";
-import type { StakeAccountPositions } from "@pythnetwork/staking-sdk";
-import { PythStakingClient } from "@pythnetwork/staking-sdk";
-import { useConnection, useWallet } from "@solana/wallet-adapter-react";
-import {
-  type ComponentProps,
-  createContext,
-  useContext,
-  useCallback,
-  useState,
-  useEffect,
-  useRef,
-} from "react";
-
-import { getStakeAccounts } from "../api";
-
-export enum StateType {
-  Initialized,
-  NoWallet,
-  Loading,
-  NoAccounts,
-  Loaded,
-  Error,
-}
-
-const State = {
-  Initialized: () => ({ type: StateType.Initialized as const }),
-
-  NoWallet: () => ({ type: StateType.NoWallet as const }),
-
-  Loading: () => ({ type: StateType.Loading as const }),
-
-  NoAccounts: (client: PythStakingClient) => ({
-    type: StateType.NoAccounts as const,
-    client,
-  }),
-
-  Loaded: (
-    client: PythStakingClient,
-    hermesClient: HermesClient,
-    account: StakeAccountPositions,
-    allAccounts: [StakeAccountPositions, ...StakeAccountPositions[]],
-    selectAccount: (account: StakeAccountPositions) => void,
-  ) => ({
-    type: StateType.Loaded as const,
-    client,
-    hermesClient,
-    account,
-    allAccounts,
-    selectAccount,
-  }),
-
-  ErrorState: (
-    client: PythStakingClient,
-    error: LoadStakeAccountError,
-    reset: () => void,
-  ) => ({
-    type: StateType.Error as const,
-    client,
-    error,
-    reset,
-  }),
-};
-
-type State = ReturnType<(typeof State)[keyof typeof State]>;
-
-const StakeAccountContext = createContext<State | undefined>(undefined);
-
-export const StakeAccountProvider = (
-  props: Omit<ComponentProps<typeof StakeAccountContext.Provider>, "value">,
-) => {
-  const state = useStakeAccountState();
-
-  return <StakeAccountContext.Provider value={state} {...props} />;
-};
-
-const useStakeAccountState = () => {
-  const loading = useRef(false);
-  const wallet = useWallet();
-  const { connection } = useConnection();
-  const [state, setState] = useState<State>(State.Initialized());
-
-  const setAccount = useCallback(
-    (account: StakeAccountPositions) => {
-      setState((cur) =>
-        cur.type === StateType.Loaded
-          ? State.Loaded(
-              cur.client,
-              cur.hermesClient,
-              account,
-              cur.allAccounts,
-              setAccount,
-            )
-          : cur,
-      );
-    },
-    [setState],
-  );
-
-  const reset = useCallback(() => {
-    if (wallet.connected && !wallet.disconnecting && !loading.current) {
-      loading.current = true;
-      setState(State.Loading());
-      if (
-        !wallet.publicKey ||
-        !wallet.signAllTransactions ||
-        !wallet.signTransaction
-      ) {
-        throw new WalletConnectedButInvalidError();
-      }
-      const client = new PythStakingClient({
-        connection,
-        wallet: {
-          publicKey: wallet.publicKey,
-          signAllTransactions: wallet.signAllTransactions,
-          signTransaction: wallet.signTransaction,
-        },
-      });
-      // TODO: use env var to support mainnet
-      const hermesClient = new HermesClient("https://hermes-beta.pyth.network");
-      getStakeAccounts(client)
-        .then((accounts) => {
-          const [firstAccount, ...otherAccounts] = accounts;
-          if (firstAccount) {
-            setState(
-              State.Loaded(
-                client,
-                hermesClient,
-                firstAccount,
-                [firstAccount, ...otherAccounts],
-                setAccount,
-              ),
-            );
-          } else {
-            setState(State.NoAccounts(client));
-          }
-        })
-        .catch((error: unknown) => {
-          setState(
-            State.ErrorState(client, new LoadStakeAccountError(error), reset),
-          );
-        })
-        .finally(() => {
-          loading.current = false;
-        });
-    }
-  }, [connection, setAccount, wallet]);
-
-  useEffect(() => {
-    reset();
-  }, [reset]);
-
-  return wallet.connected && !wallet.disconnecting ? state : State.NoWallet();
-};
-
-export const useStakeAccount = () => {
-  const state = useContext(StakeAccountContext);
-  if (state === undefined) {
-    throw new NotInitializedError();
-  } else {
-    return state;
-  }
-};
-
-export const useSelectedStakeAccount = () => {
-  const state = useStakeAccount();
-  if (state.type === StateType.Loaded) {
-    return state;
-  } else {
-    throw new InvalidStateError();
-  }
-};
-
-class LoadStakeAccountError extends Error {
-  constructor(cause: unknown) {
-    super(cause instanceof Error ? cause.message : "");
-    this.name = "LoadStakeAccountError";
-    this.cause = cause;
-  }
-}
-
-class NotInitializedError extends Error {
-  constructor() {
-    super(
-      "This component must be a child of <StakeAccountProvider> to use the `useStakeAccount` hook",
-    );
-  }
-}
-
-class WalletConnectedButInvalidError extends Error {
-  constructor() {
-    super(
-      "The wallet is connected but is missing a public key or methods to sign transactions!",
-    );
-  }
-}
-
-class InvalidStateError extends Error {
-  constructor() {
-    super(
-      "Cannot use `useSelectedStakeAccount` when stake accounts aren't loaded or a stake account isn't selected!  Ensure this hook is only called when a stake account is selected.",
-    );
-    this.name = "InvalidStateError";
-  }
-}

+ 0 - 71
apps/staking/src/hooks/use-transfer.ts

@@ -1,71 +0,0 @@
-import type { PythStakingClient } from "@pythnetwork/staking-sdk";
-import type { PublicKey } from "@solana/web3.js";
-import { useState, useCallback } from "react";
-import { useSWRConfig } from "swr";
-
-import { getCacheKey as getAccountHistoryCacheKey } from "./use-account-history";
-import { getCacheKey as getDashboardDataCacheKey } from "./use-dashboard-data";
-import { useLogger } from "./use-logger";
-import { useSelectedStakeAccount } from "./use-stake-account";
-
-export const useTransfer = (
-  transfer: (
-    client: PythStakingClient,
-    stakingAccount: PublicKey,
-  ) => Promise<void>,
-) => {
-  const { client, account } = useSelectedStakeAccount();
-  const logger = useLogger();
-  const [state, setState] = useState<State>(State.Base());
-  const { mutate } = useSWRConfig();
-
-  const execute = useCallback(async () => {
-    if (state.type === StateType.Submitting) {
-      throw new DuplicateSubmitError();
-    }
-
-    setState(State.Submitting());
-    try {
-      await transfer(client, account.address);
-      // TODO enable mutate without awaiting?
-      // Prob by changing `api.ts` to encode the change & history item along with each update?
-      await Promise.all([
-        mutate(getDashboardDataCacheKey(account.address)),
-        mutate(getAccountHistoryCacheKey(account.address)),
-      ]);
-      setState(State.Complete());
-    } catch (error: unknown) {
-      logger.error(error);
-      setState(State.ErrorState(error));
-      throw error;
-    }
-  }, [state, client, account.address, transfer, setState, mutate, logger]);
-
-  return { state, execute };
-};
-
-export enum StateType {
-  Base,
-  Submitting,
-  Error,
-  Complete,
-}
-
-const State = {
-  Base: () => ({ type: StateType.Base as const }),
-  Submitting: () => ({ type: StateType.Submitting as const }),
-  Complete: () => ({ type: StateType.Complete as const }),
-  ErrorState: (error: unknown) => ({
-    type: StateType.Error as const,
-    error,
-  }),
-};
-
-type State = ReturnType<(typeof State)[keyof typeof State]>;
-
-class DuplicateSubmitError extends Error {
-  constructor() {
-    super("Attempted to submit a transaction when one is already in process");
-    this.name = "DuplicateSubmitError";
-  }
-}