Forráskód Böngészése

chore(staking): initialize pyth staking client only once

Connor Prussin 1 éve
szülő
commit
045bcac5bf

+ 71 - 243
apps/staking/src/api.ts

@@ -1,5 +1,5 @@
 // TODO remove these disables when moving off the mock APIs
-/* eslint-disable @typescript-eslint/no-unused-vars, @typescript-eslint/no-non-null-assertion */
+/* eslint-disable @typescript-eslint/no-unused-vars, @typescript-eslint/require-await */
 
 import {
   getAmountByTargetAndState,
@@ -8,14 +8,7 @@ import {
   PythStakingClient,
   type StakeAccountPositions,
 } from "@pythnetwork/staking-sdk";
-import type { AnchorWallet } from "@solana/wallet-adapter-react";
-import { PublicKey, type Connection } from "@solana/web3.js";
-
-export type Context = {
-  connection: Connection;
-  wallet: AnchorWallet;
-  stakeAccount: StakeAccountPositions;
-};
+import { PublicKey } from "@solana/web3.js";
 
 type Data = {
   total: bigint;
@@ -46,7 +39,7 @@ type Data = {
   };
   integrityStakingPublishers: {
     name: string;
-    publicKey: string;
+    publicKey: PublicKey;
     isSelf: boolean;
     selfStake: bigint;
     poolCapacity: bigint;
@@ -145,50 +138,42 @@ type AccountHistory = {
 }[];
 
 export const getStakeAccounts = async (
-  connection: Connection,
-  wallet: AnchorWallet,
-): Promise<StakeAccountPositions[]> => {
-  const pythStakingClient = new PythStakingClient({ connection, wallet });
-  return pythStakingClient.getAllStakeAccountPositions(wallet.publicKey);
-};
-
-export const loadData = async (context: Context): Promise<Data> => {
-  await new Promise((resolve) => setTimeout(resolve, MOCK_DELAY));
-  // While using mocks we need to clone the MOCK_DATA object every time
-  // `loadData` is called so that swr treats the response as changed and
-  // triggers a rerender.
-
-  const pythStakingClient = new PythStakingClient({
-    connection: context.connection,
-    wallet: context.wallet,
-  });
-  const stakeAccountPositions = context.stakeAccount;
-
-  const [stakeAccountCustody, publishers, ownerAtaAccount, currentEpoch] =
-    await Promise.all([
-      pythStakingClient.getStakeAccountCustody(stakeAccountPositions.address),
-      pythStakingClient.getPublishers(),
-      pythStakingClient.getOwnerPythAtaAccount(),
-      getCurrentEpoch(context.connection),
-    ]);
-
-  const unlockSchedule = await pythStakingClient.getUnlockSchedule({
-    stakeAccountPositions: stakeAccountPositions.address,
-  });
+  client: PythStakingClient,
+): Promise<StakeAccountPositions[]> =>
+  client.getAllStakeAccountPositions(client.wallet.publicKey);
+
+export const loadData = async (
+  client: PythStakingClient,
+  stakeAccount: StakeAccountPositions,
+): Promise<Data> => {
+  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,
+      stakeAccountPositions: stakeAccount,
       targetWithParameters: { voting: {} },
       positionState,
       epoch: currentEpoch,
     });
+
   const filterOISPositions = (
     publisher: PublicKey,
     positionState: PositionState,
   ) =>
     getAmountByTargetAndState({
-      stakeAccountPositions,
+      stakeAccountPositions: stakeAccount,
       targetWithParameters: { integrityPool: { publisher } },
       positionState,
       epoch: currentEpoch,
@@ -215,7 +200,7 @@ export const loadData = async (context: Context): Promise<Data> => {
       numFeeds: 0, // TODO
       poolCapacity: 100n, // TODO
       poolUtilization: 0n, // TODO
-      publicKey: publisher.toString(),
+      publicKey: publisher,
       qualityRanking: 0, // TODO
       selfStake: 0n, // TODO
       positions: {
@@ -229,135 +214,85 @@ export const loadData = async (context: Context): Promise<Data> => {
 };
 
 export const loadAccountHistory = async (
-  _context: Context,
+  _client: PythStakingClient,
+  _stakeAccount: PublicKey,
 ): Promise<AccountHistory> => {
   await new Promise((resolve) => setTimeout(resolve, MOCK_DELAY));
-  return MOCK_HISTORY["0x000000"]!;
+  return mkMockHistory();
 };
 
 export const deposit = async (
-  context: Context,
+  client: PythStakingClient,
+  stakeAccount: PublicKey,
   amount: bigint,
 ): Promise<void> => {
-  const pythStakingClient = new PythStakingClient({
-    connection: context.connection,
-    wallet: context.wallet,
-  });
-  await pythStakingClient.depositTokensToStakeAccountCustody(
-    context.stakeAccount.address,
-    amount,
-  );
+  await client.depositTokensToStakeAccountCustody(stakeAccount, amount);
 };
 
 export const withdraw = async (
-  context: Context,
+  client: PythStakingClient,
+  stakeAccount: PublicKey,
   amount: bigint,
 ): Promise<void> => {
-  const pythStakingClient = new PythStakingClient({
-    connection: context.connection,
-    wallet: context.wallet,
-  });
-  await pythStakingClient.withdrawTokensFromStakeAccountCustody(
-    context.stakeAccount.address,
-    amount,
-  );
+  await client.withdrawTokensFromStakeAccountCustody(stakeAccount, amount);
 };
 
-export const claim = async (context: Context): Promise<void> => {
-  const pythStakingClient = new PythStakingClient({
-    connection: context.connection,
-    wallet: context.wallet,
-  });
-  await pythStakingClient.advanceDelegationRecord({
-    stakeAccountPositions: context.stakeAccount.address,
-  });
+export const claim = async (
+  client: PythStakingClient,
+  stakeAccount: PublicKey,
+): Promise<void> => {
+  await client.advanceDelegationRecord(stakeAccount);
 };
 
 export const stakeGovernance = async (
-  context: Context,
+  client: PythStakingClient,
+  stakeAccount: PublicKey,
   amount: bigint,
 ): Promise<void> => {
-  const pythStakingClient = new PythStakingClient({
-    connection: context.connection,
-    wallet: context.wallet,
-  });
-  await pythStakingClient.stakeToGovernance(
-    context.stakeAccount.address,
-    amount,
-  );
+  await client.stakeToGovernance(stakeAccount, amount);
 };
 
 export const cancelWarmupGovernance = async (
-  _context: Context,
+  _client: PythStakingClient,
+  _stakeAccount: PublicKey,
   _amount: bigint,
 ): Promise<void> => {
-  await new Promise((resolve) => setTimeout(resolve, MOCK_DELAY));
+  throw new NotImplementedError();
 };
 
 export const unstakeGovernance = async (
-  _context: Context,
+  _client: PythStakingClient,
+  _stakeAccount: PublicKey,
   _amount: bigint,
 ): Promise<void> => {
-  await new Promise((resolve) => setTimeout(resolve, MOCK_DELAY));
+  throw new NotImplementedError();
 };
 
 export const delegateIntegrityStaking = async (
-  context: Context,
-  publisherKey: string,
+  client: PythStakingClient,
+  stakeAccount: PublicKey,
+  publisherKey: PublicKey,
   amount: bigint,
 ): Promise<void> => {
-  const pythStakingClient = new PythStakingClient({
-    connection: context.connection,
-    wallet: context.wallet,
-  });
-
-  await pythStakingClient.stakeToPublisher({
-    stakeAccountPositions: context.stakeAccount.address,
-    publisher: new PublicKey(publisherKey),
-    amount,
-  });
+  await client.stakeToPublisher(stakeAccount, publisherKey, amount);
 };
 
 export const cancelWarmupIntegrityStaking = async (
-  context: Context,
-  publisherKey: string,
-  amount: bigint,
+  _client: PythStakingClient,
+  _stakeAccount: PublicKey,
+  _publisherKey: PublicKey,
+  _amount: bigint,
 ): Promise<void> => {
-  await new Promise((resolve) => setTimeout(resolve, MOCK_DELAY));
-  const publisher = MOCK_DATA[
-    context.stakeAccount.address.toString()
-  ]!.integrityStakingPublishers.find(
-    (publisher) => publisher.publicKey === publisherKey,
-  );
-  if (publisher) {
-    if (publisher.positions?.warmup) {
-      publisher.positions.warmup -= amount;
-    }
-  } else {
-    throw new Error(`Invalid publisher key: "${publisherKey}"`);
-  }
+  throw new NotImplementedError();
 };
 
 export const unstakeIntegrityStaking = async (
-  context: Context,
-  publisherKey: string,
-  amount: bigint,
+  _client: PythStakingClient,
+  _stakeAccount: PublicKey,
+  _publisherKey: PublicKey,
+  _amount: bigint,
 ): Promise<void> => {
-  await new Promise((resolve) => setTimeout(resolve, MOCK_DELAY));
-  const publisher = MOCK_DATA[
-    context.stakeAccount.address.toString()
-  ]!.integrityStakingPublishers.find(
-    (publisher) => publisher.publicKey === publisherKey,
-  );
-  if (publisher) {
-    if (publisher.positions?.staked) {
-      publisher.positions.staked -= amount;
-      publisher.positions.cooldown =
-        (publisher.positions.cooldown ?? 0n) + amount;
-    }
-  } else {
-    throw new Error(`Invalid publisher key: "${publisherKey}"`);
-  }
+  throw new NotImplementedError();
 };
 
 export const calculateApy = (
@@ -394,115 +329,6 @@ export const getNextFullEpoch = (): Date => {
 
 const MOCK_DELAY = 500;
 
-const mkMockData = (isDouro: boolean): Data => ({
-  total: 15_000_000n,
-  availableRewards: 156_000n,
-  lastSlash: isDouro
-    ? undefined
-    : {
-        amount: 2147n,
-        date: new Date("2024-05-04T00:00:00Z"),
-      },
-  expiringRewards: {
-    amount: 56_000n,
-    expiry: new Date("2025-08-01T00:00:00Z"),
-  },
-  locked: isDouro ? 3_000_000n : 0n,
-  unlockSchedule: isDouro
-    ? [
-        {
-          amount: 1_000_000n,
-          date: new Date("2025-08-01T00:00:00Z"),
-        },
-        {
-          amount: 2_000_000n,
-          date: new Date("2025-09-01T00:00:00Z"),
-        },
-      ]
-    : [],
-  walletAmount: 5_000_000_000_000n,
-  governance: {
-    warmup: 2_670_000n,
-    staked: 4_150_000n,
-    cooldown: 1_850_000n,
-    cooldown2: 4_765_000n,
-  },
-  integrityStakingPublishers: [
-    {
-      name: "Douro Labs",
-      publicKey: "0xF00",
-      isSelf: isDouro,
-      selfStake: 5_000_000_000n,
-      poolCapacity: 500_000_000n,
-      poolUtilization: 200_000_000n,
-      numFeeds: 42,
-      qualityRanking: 1,
-      apyHistory: [
-        { date: new Date("2024-07-22"), apy: 5 },
-        { date: new Date("2024-07-23"), apy: 10 },
-        { date: new Date("2024-07-24"), apy: 25 },
-        { date: new Date("2024-07-25"), apy: 20 },
-      ],
-      positions: {
-        warmup: 5_000_000n,
-        staked: 4_000_000n,
-        cooldown: 1_000_000n,
-        cooldown2: 460_000n,
-      },
-    },
-    {
-      name: "Jump Trading",
-      publicKey: "0xBA4",
-      isSelf: false,
-      selfStake: 400_000_000n,
-      poolCapacity: 500_000_000n,
-      poolUtilization: 750_000_000n,
-      numFeeds: 84,
-      qualityRanking: 2,
-      apyHistory: [
-        { date: new Date("2024-07-24"), apy: 5 },
-        { date: new Date("2024-07-25"), apy: 10 },
-      ],
-      positions: {
-        staked: 1_000_000n,
-      },
-    },
-    {
-      name: "Cboe",
-      publicKey: "0xAA",
-      isSelf: false,
-      selfStake: 200_000_000n,
-      poolCapacity: 600_000_000n,
-      poolUtilization: 450_000_000n,
-      numFeeds: 17,
-      qualityRanking: 5,
-      apyHistory: [
-        { date: new Date("2024-07-24"), apy: 5 },
-        { date: new Date("2024-07-25"), apy: 10 },
-      ],
-    },
-    {
-      name: "Raydium",
-      publicKey: "0x111",
-      isSelf: false,
-      selfStake: 400_000_000n,
-      poolCapacity: 500_000_000n,
-      poolUtilization: 750_000_000n,
-      numFeeds: 84,
-      qualityRanking: 3,
-      apyHistory: [
-        { date: new Date("2024-07-24"), apy: 5 },
-        { date: new Date("2024-07-25"), apy: 10 },
-      ],
-    },
-  ],
-});
-
-const MOCK_DATA: Record<string, Data> = {
-  "0x000000": mkMockData(true),
-  "0x111111": mkMockData(false),
-};
-
 const mkMockHistory = (): AccountHistory => [
   {
     timestamp: new Date("2024-06-10T00:00:00Z"),
@@ -542,7 +368,9 @@ const mkMockHistory = (): AccountHistory => [
   },
 ];
 
-const MOCK_HISTORY: Record<string, AccountHistory> = {
-  "0x000000": mkMockHistory(),
-  "0x111111": mkMockHistory(),
-};
+class NotImplementedError extends Error {
+  constructor() {
+    super("Not yet implemented!");
+    this.name = "NotImplementedError";
+  }
+}

+ 12 - 9
apps/staking/src/components/OracleIntegrityStaking/index.tsx

@@ -1,8 +1,9 @@
+import type { PythStakingClient } from "@pythnetwork/staking-sdk";
+import { PublicKey } from "@solana/web3.js";
 import clsx from "clsx";
 import { useMemo, useCallback } from "react";
 
 import {
-  type Context,
   delegateIntegrityStaking,
   cancelWarmupIntegrityStaking,
   unstakeIntegrityStaking,
@@ -127,7 +128,7 @@ export const OracleIntegrityStaking = ({
             <tbody className="bg-white/5">
               {otherPublishers.map((publisher) => (
                 <Publisher
-                  key={publisher.publicKey}
+                  key={publisher.publicKey.toBase58()}
                   availableToStake={availableToStake}
                   publisher={publisher}
                   totalStaked={staked}
@@ -152,7 +153,7 @@ type PublisherProps = {
   isSelf?: boolean;
   publisher: {
     name: string;
-    publicKey: string;
+    publicKey: PublicKey;
     isSelf: boolean;
     selfStake: bigint;
     poolCapacity: bigint;
@@ -372,7 +373,7 @@ const PublisherTableCell = Styled("td", "py-4 px-5 whitespace-nowrap");
 
 type StakeToPublisherButtonProps = {
   publisherName: string;
-  publisherKey: string;
+  publisherKey: PublicKey;
   availableToStake: bigint;
   poolCapacity: bigint;
   poolUtilization: bigint;
@@ -423,13 +424,15 @@ const StakeToPublisherButton = ({
 
 const useTransferActionForPublisher = (
   action: (
-    context: Context,
-    publicKey: string,
+    client: PythStakingClient,
+    stakingAccount: PublicKey,
+    publisher: PublicKey,
     amount: bigint,
   ) => Promise<void>,
-  publicKey: string,
+  publisher: PublicKey,
 ) =>
   useCallback(
-    (context: Context, amount: bigint) => action(context, publicKey, amount),
-    [action, publicKey],
+    (client: PythStakingClient, stakingAccount: PublicKey, amount: bigint) =>
+      action(client, stakingAccount, publisher, amount),
+    [action, publisher],
   );

+ 9 - 4
apps/staking/src/components/TransferButton/index.tsx

@@ -1,4 +1,6 @@
 import { Field, Input, Label } from "@headlessui/react";
+import type { PythStakingClient } from "@pythnetwork/staking-sdk";
+import type { PublicKey } from "@solana/web3.js";
 import {
   type ChangeEvent,
   type ComponentProps,
@@ -8,7 +10,6 @@ import {
   useState,
 } from "react";
 
-import type { Context } from "../../api";
 import { useLogger } from "../../hooks/use-logger";
 import { StateType, useTransfer } from "../../hooks/use-transfer";
 import { stringToTokens, tokensToString } from "../../tokens";
@@ -28,7 +29,11 @@ type Props = {
     | ReactNode
     | ReactNode[]
     | undefined;
-  transfer: (context: Context, amount: bigint) => Promise<void>;
+  transfer: (
+    client: PythStakingClient,
+    stakingAccount: PublicKey,
+    amount: bigint,
+  ) => Promise<void>;
   className?: string | undefined;
   secondary?: boolean | undefined;
   small?: boolean | undefined;
@@ -51,9 +56,9 @@ export const TransferButton = ({
   const { amountInput, setAmount, updateAmount, resetAmount, amount } =
     useAmountInput(max);
   const doTransfer = useCallback(
-    (context: Context) =>
+    (client: PythStakingClient, stakingAccount: PublicKey) =>
       amount.type === AmountType.Valid
-        ? transfer(context, amount.amount)
+        ? transfer(client, stakingAccount, amount.amount)
         : Promise.reject(new InvalidAmountError()),
     [amount, transfer],
   );

+ 8 - 1
apps/staking/src/config/server.ts

@@ -12,7 +12,7 @@ const demand = (key: string): string => {
   if (value && value !== "") {
     return value;
   } else {
-    throw new Error(`Missing environment variable ${key}!`);
+    throw new MissingEnvironmentError(key);
   }
 };
 
@@ -36,3 +36,10 @@ export const WALLETCONNECT_PROJECT_ID = demandInProduction(
 );
 export const RPC = process.env.RPC;
 export const IS_MAINNET = process.env.IS_MAINNET !== undefined;
+
+class MissingEnvironmentError extends Error {
+  constructor(name: string) {
+    super(`Missing environment variable: ${name}!`);
+    this.name = "MissingEnvironmentError";
+  }
+}

+ 7 - 8
apps/staking/src/hooks/use-account-history.ts

@@ -1,23 +1,22 @@
+import { PublicKey } from "@solana/web3.js";
 import useSWR from "swr";
 
-import { useApiContext } from "./use-api-context";
+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,
-}: ReturnType<typeof useApiContext>) =>
-  `${stakeAccount.address.toBase58()}/history`;
+export const getCacheKey = (stakeAccount: PublicKey) =>
+  `${stakeAccount.toBase58()}/history`;
 
 export const useAccountHistory = () => {
-  const apiContext = useApiContext();
+  const { client, account } = useSelectedStakeAccount();
 
   const { data, isLoading, ...rest } = useSWR(
-    getCacheKey(apiContext),
-    () => loadAccountHistory(apiContext),
+    getCacheKey(account.address),
+    () => loadAccountHistory(client, account.address),
     {
       refreshInterval: REFRESH_INTERVAL,
     },

+ 0 - 40
apps/staking/src/hooks/use-api-context.ts

@@ -1,40 +0,0 @@
-import { useAnchorWallet, useConnection } from "@solana/wallet-adapter-react";
-import { useMemo } from "react";
-
-import { StateType, useStakeAccount } from "./use-stake-account";
-import type { Context } from "../api";
-
-export const useApiContext = (): Context => {
-  const wallet = useAnchorWallet();
-  const { connection } = useConnection();
-  const stakeAccount = useStakeAccount();
-
-  if (wallet === undefined) {
-    throw new NoWalletConnectedError();
-  }
-
-  if (stakeAccount.type !== StateType.Loaded) {
-    throw new NoStakeAccountSelectedError();
-  }
-
-  return useMemo(
-    () => ({ wallet, connection, stakeAccount: stakeAccount.account }),
-    [wallet, connection, stakeAccount],
-  );
-};
-
-class NoWalletConnectedError extends Error {
-  constructor() {
-    super(
-      "The `useApiContext` hook cannot be called if a wallet isn't connected!  Ensure all components that use this hook are only rendered if a wallet is connected!",
-    );
-  }
-}
-
-class NoStakeAccountSelectedError extends Error {
-  constructor() {
-    super(
-      "The `useApiContext` hook cannot be called before stake accounts have loaded!  Ensure all components that use this hook are only rendered if `useStakeAccount` returns a loaded state!",
-    );
-  }
-}

+ 6 - 7
apps/staking/src/hooks/use-dashboard-data.ts

@@ -1,23 +1,22 @@
+import { PublicKey } from "@solana/web3.js";
 import { useCallback } from "react";
 import useSWR from "swr";
 
-import { useApiContext } from "./use-api-context";
+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,
-}: ReturnType<typeof useApiContext>) => stakeAccount.address.toBase58();
+export const getCacheKey = (stakeAccount: PublicKey) => stakeAccount.toBase58();
 
 export const useDashboardData = () => {
-  const apiContext = useApiContext();
+  const { client, account } = useSelectedStakeAccount();
 
   const { data, isLoading, mutate, ...rest } = useSWR(
-    getCacheKey(apiContext),
-    () => loadData(apiContext),
+    getCacheKey(account.address),
+    () => loadData(client, account),
     {
       refreshInterval: REFRESH_INTERVAL,
     },

+ 51 - 10
apps/staking/src/hooks/use-stake-account.tsx

@@ -1,6 +1,7 @@
 "use 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,
@@ -25,21 +26,36 @@ export enum StateType {
 
 const State = {
   Initialized: () => ({ type: StateType.Initialized as const }),
+
   NoWallet: () => ({ type: StateType.NoWallet as const }),
+
   Loading: () => ({ type: StateType.Loading as const }),
-  NoAccounts: () => ({ type: StateType.NoAccounts as const }),
+
+  NoAccounts: (client: PythStakingClient) => ({
+    type: StateType.NoAccounts as const,
+    client,
+  }),
+
   Loaded: (
+    client: PythStakingClient,
     account: StakeAccountPositions,
     allAccounts: [StakeAccountPositions, ...StakeAccountPositions[]],
     selectAccount: (account: StakeAccountPositions) => void,
   ) => ({
     type: StateType.Loaded as const,
+    client,
     account,
     allAccounts,
     selectAccount,
   }),
-  ErrorState: (error: LoadStakeAccountError, reset: () => void) => ({
+
+  ErrorState: (
+    client: PythStakingClient,
+    error: LoadStakeAccountError,
+    reset: () => void,
+  ) => ({
     type: StateType.Error as const,
+    client,
     error,
     reset,
   }),
@@ -67,7 +83,7 @@ const useStakeAccountState = () => {
     (account: StakeAccountPositions) => {
       setState((cur) =>
         cur.type === StateType.Loaded
-          ? State.Loaded(account, cur.allAccounts, setAccount)
+          ? State.Loaded(cur.client, account, cur.allAccounts, setAccount)
           : cur,
       );
     },
@@ -85,27 +101,34 @@ const useStakeAccountState = () => {
       ) {
         throw new WalletConnectedButInvalidError();
       }
-      getStakeAccounts(connection, {
-        publicKey: wallet.publicKey,
-        signAllTransactions: wallet.signAllTransactions,
-        signTransaction: wallet.signTransaction,
-      })
+      const client = new PythStakingClient({
+        connection,
+        wallet: {
+          publicKey: wallet.publicKey,
+          signAllTransactions: wallet.signAllTransactions,
+          signTransaction: wallet.signTransaction,
+        },
+      });
+      getStakeAccounts(client)
         .then((accounts) => {
           const [firstAccount, ...otherAccounts] = accounts;
           if (firstAccount) {
             setState(
               State.Loaded(
+                client,
                 firstAccount,
                 [firstAccount, ...otherAccounts],
                 setAccount,
               ),
             );
           } else {
-            setState(State.NoAccounts());
+            setState(State.NoAccounts(client));
           }
         })
         .catch((error: unknown) => {
-          setState(State.ErrorState(new LoadStakeAccountError(error), reset));
+          setState(
+            State.ErrorState(client, new LoadStakeAccountError(error), reset),
+          );
         })
         .finally(() => {
           loading.current = false;
@@ -129,6 +152,15 @@ export const useStakeAccount = () => {
   }
 };
 
+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 : "");
@@ -152,3 +184,12 @@ class WalletConnectedButInvalidError extends Error {
     );
   }
 }
+
+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";
+  }
+}

+ 13 - 7
apps/staking/src/hooks/use-transfer.ts

@@ -1,14 +1,19 @@
+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 { useApiContext } from "./use-api-context";
 import { getCacheKey as getDashboardDataCacheKey } from "./use-dashboard-data";
+import { useSelectedStakeAccount } from "./use-stake-account";
 
 export const useTransfer = (
-  transfer: (context: ReturnType<typeof useApiContext>) => Promise<void>,
+  transfer: (
+    client: PythStakingClient,
+    stakingAccount: PublicKey,
+  ) => Promise<void>,
 ) => {
-  const context = useApiContext();
+  const { client, account } = useSelectedStakeAccount();
   const [state, setState] = useState<State>(State.Base());
   const { mutate } = useSWRConfig();
 
@@ -19,19 +24,19 @@ export const useTransfer = (
 
     setState(State.Submitting());
     try {
-      await transfer(context);
+      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(context)),
-        mutate(getAccountHistoryCacheKey(context)),
+        mutate(getDashboardDataCacheKey(account.address)),
+        mutate(getAccountHistoryCacheKey(account.address)),
       ]);
       setState(State.Complete());
     } catch (error: unknown) {
       setState(State.ErrorState(error));
       throw error;
     }
-  }, [state, context, transfer, setState, mutate]);
+  }, [state, client, account.address, transfer, setState, mutate]);
 
   return { state, execute };
 };
@@ -58,5 +63,6 @@ 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";
   }
 }

+ 2 - 2
governance/pyth_staking_sdk/package.json

@@ -8,14 +8,14 @@
   },
   "scripts": {
     "build": "tsc",
-    "test": "echo disabled # pnpm run test:format && pnpm run test:lint && pnpm run test:integration && pnpm run test:types",
+    "test": "pnpm run test:format && pnpm run test:lint && pnpm run test:integration && pnpm run test:types",
     "fix": "pnpm fix:lint && pnpm fix:format",
     "fix:format": "prettier --write .",
     "fix:lint": "eslint --fix .",
     "test:format": "prettier --check .",
     "test:lint": "jest --selectProjects lint",
     "test:types": "tsc",
-    "test:integration": "jest --selectProjects integration"
+    "test:integration": "echo disabled # jest --selectProjects integration"
   },
   "devDependencies": {
     "@cprussin/eslint-config": "^3.0.0",

+ 7 - 14
governance/pyth_staking_sdk/src/pyth-staking-client.ts

@@ -274,12 +274,11 @@ export class PythStakingClient {
     return sendTransaction([instruction], this.connection, this.wallet);
   }
 
-  public async stakeToPublisher(options: {
-    stakeAccountPositions: PublicKey;
-    publisher: PublicKey;
-    amount: bigint;
-  }) {
-    const { stakeAccountPositions, publisher, amount } = options;
+  public async stakeToPublisher(
+    stakeAccountPositions: PublicKey,
+    publisher: PublicKey,
+    amount: bigint,
+  ) {
     const instruction = await this.integrityPoolProgram.methods
       .delegate(convertBigIntToBN(amount))
       .accounts({
@@ -292,10 +291,7 @@ export class PythStakingClient {
     return sendTransaction([instruction], this.connection, this.wallet);
   }
 
-  public async getUnlockSchedule(options: {
-    stakeAccountPositions: PublicKey;
-  }) {
-    const { stakeAccountPositions } = options;
+  public async getUnlockSchedule(stakeAccountPositions: PublicKey) {
     const stakeAccountMetadataAddress = getStakeAccountMetadataAddress(
       stakeAccountPositions,
     );
@@ -317,11 +313,8 @@ export class PythStakingClient {
     });
   }
 
-  public async advanceDelegationRecord(options: {
-    stakeAccountPositions: PublicKey;
-  }) {
+  public async advanceDelegationRecord(stakeAccountPositions: PublicKey) {
     // TODO: optimize to only send transactions for publishers that have positive rewards
-    const { stakeAccountPositions } = options;
     const publishers = await this.getPublishers();
 
     // anchor does not calculate the correct pda for other programs