diff --git a/.changeset/tall-aliens-join.md b/.changeset/tall-aliens-join.md new file mode 100644 index 00000000..5082658b --- /dev/null +++ b/.changeset/tall-aliens-join.md @@ -0,0 +1,5 @@ +--- +"@stakekit/widget": patch +--- + +refactor: stream multi yield load diff --git a/packages/widget/src/domain/types/stake.ts b/packages/widget/src/domain/types/stake.ts index f341da53..231e8c2f 100644 --- a/packages/widget/src/domain/types/stake.ts +++ b/packages/widget/src/domain/types/stake.ts @@ -48,44 +48,28 @@ export const getInitialToken = (args: { .altLazy(() => List.find(hasYields, args.defaultTokens)) .map((val) => val.token); -const yieldTypeOrder: { [Key in YieldDto["metadata"]["type"]]: number } = { - staking: 1, - restaking: 2, - "liquid-staking": 3, - vault: 4, - lending: 5, -}; - -export const getInitialYield = (args: { +export const canBeInitialYield = (args: { initQueryParams: Maybe; - yieldDtos: YieldDto[]; + yieldDto: YieldDto; tokenBalanceAmount: BigNumber; positionsData: PositionsData; -}) => { - const sortedYields = args.yieldDtos.toSorted( - (a, b) => - yieldTypeOrder[a.metadata.type] - yieldTypeOrder[b.metadata.type] || - getMinStakeAmount(b, args.positionsData) - .minus(getMinStakeAmount(a, args.positionsData)) - .toNumber() - ); - - return args.initQueryParams - .filter((val) => !!val.yieldId) - .chain((val) => List.find((y) => val.yieldId === y.id, sortedYields)) +}) => + args.initQueryParams + .chain((queryParams) => + Maybe.fromFalsy( + !!queryParams.yieldId && queryParams.yieldId === args.yieldDto.id + ) + ) .altLazy(() => - List.find( - (yieldDto) => - balanceValidForYield({ - tokenBalanceAmount: args.tokenBalanceAmount, - yieldDto, - positionsData: args.positionsData, - }), - sortedYields + Maybe.fromFalsy( + balanceValidForYield({ + tokenBalanceAmount: args.tokenBalanceAmount, + yieldDto: args.yieldDto, + positionsData: args.positionsData, + }) ) ) - .altLazy(() => List.head(sortedYields)); -}; + .isJust(); const balanceValidForYield = ({ tokenBalanceAmount, diff --git a/packages/widget/src/hooks/api/use-multi-yields.ts b/packages/widget/src/hooks/api/use-multi-yields.ts index 66ec9bfa..2b1f43ad 100644 --- a/packages/widget/src/hooks/api/use-multi-yields.ts +++ b/packages/widget/src/hooks/api/use-multi-yields.ts @@ -1,142 +1,176 @@ +import type { InitParams } from "@sk-widget/domain/types/init-params"; +import type { PositionsData } from "@sk-widget/domain/types/positions"; +import { canBeInitialYield } from "@sk-widget/domain/types/stake"; +import { useSavedRef } from "@sk-widget/hooks/use-saved-ref"; import type { YieldDto } from "@stakekit/api-hooks"; -import type { QueryClient, UseQueryOptions } from "@tanstack/react-query"; -import { useQuery } from "@tanstack/react-query"; -import { EitherAsync, Maybe, Right } from "purify-ts"; +import { type QueryClient, hashKey } from "@tanstack/react-query"; +import { useSelector } from "@xstate/react"; +import { createStore } from "@xstate/store"; +import type { BigNumber } from "bignumber.js"; +import { EitherAsync, Maybe } from "purify-ts"; +import { useEffect, useMemo } from "react"; import { createSelector } from "reselect"; -import { config } from "../../config"; +import { + Observable, + defaultIfEmpty, + filter, + firstValueFrom, + from, + map, + merge, + repeat, + take, + tap, + timer, +} from "rxjs"; import type { SKWallet } from "../../domain/types"; import { isSupportedChain } from "../../domain/types/chains"; import { useSKQueryClient } from "../../providers/query-client"; import { useSKWallet } from "../../providers/sk-wallet"; -import { eitherAsyncPool } from "../../utils/either-async-pool"; -import { - getYieldOpportunity, - setYieldOpportunityInCache, -} from "./use-yield-opportunity"; +import { getYieldOpportunity } from "./use-yield-opportunity"; -const getMultiYieldsQueryKey = (yieldIds: string[]) => [ - "multi-yields", - yieldIds, -]; +const multiYieldsStore = createStore({ + context: { data: new Map>() }, + on: { + "yield-opportunity": ( + context, + event: { data: { key: string; yieldDto: YieldDto } } + ) => { + const newMap = new Map(context.data); + const prev = newMap.get(event.data.key) ?? new Map(); -export const getCachedMultiYields = ({ - queryClient, - yieldIds, -}: { - queryClient: QueryClient; - yieldIds: string[]; -}) => - Maybe.fromNullable( - queryClient.getQueryData(getMultiYieldsQueryKey(yieldIds)) - ); + prev.set(event.data.yieldDto.id, event.data.yieldDto); + newMap.set(event.data.key, prev); + + return { data: newMap }; + }, + }, +}); -export const useMultiYields = ( - yieldIds: string[], - opts?: { select?: UseQueryOptions["select"] } -) => { +export const useMultiYields = (yieldIds: string[]) => { const { network, isConnected, isLedgerLive } = useSKWallet(); - const queryClient = useSKQueryClient(); - - return useQuery({ - queryKey: getMultiYieldsQueryKey(yieldIds), - enabled: !!yieldIds.length, - staleTime: config.queryClient.cacheTime, - select: opts?.select, - queryFn: async () => - ( - await queryFn({ - isConnected, - isLedgerLive, - network, - queryClient, - yieldIds, - }) - ).unsafeCoerce(), + const argsRef = useSavedRef({ + isLedgerLive, + queryClient: useSKQueryClient(), + network, + isConnected, + }); + + const hashedKey = useMemo(() => hashKey(yieldIds), [yieldIds]); + + useEffect(() => { + const sub = multipleYields$({ + ...argsRef.current, + yieldIds, + }) + .pipe(repeat({ delay: () => timer(1000 * 60 * 2) })) + .subscribe({ + next: (v) => + multiYieldsStore.send({ + type: "yield-opportunity", + data: { yieldDto: v, key: hashedKey }, + }), + }); + + return () => sub.unsubscribe(); + }, [argsRef, yieldIds, hashedKey]); + + return useSelector(multiYieldsStore, (state) => { + const map = state.context.data.get(hashedKey); + + return map ? Array.from(map.values()) : []; }); }; -export const getMultipleYields = ( - params: Parameters[0] & { queryClient: QueryClient } +export const getFirstEligibleYield = ( + params: Parameters[0] ) => EitherAsync(() => params.queryClient.fetchQuery({ - queryKey: getMultiYieldsQueryKey(params.yieldIds), - queryFn: async () => (await queryFn(params)).unsafeCoerce(), + queryKey: getFirstEligibleYieldQueryKey(params.yieldIds), + queryFn: () => firstValueFrom(firstEligibleYield$(params)), }) ).mapLeft((e) => { console.log(e); - return new Error("could not get multi yields"); + return new Error("could not get first eligible yield"); }); -const queryFn = ({ - yieldIds, - isLedgerLive, - queryClient, - isConnected, - network, -}: { +const multipleYields$ = (args: { isLedgerLive: boolean; - yieldIds: string[]; queryClient: QueryClient; isConnected: boolean; network: SKWallet["network"]; + yieldIds: string[]; }) => - eitherAsyncPool( - yieldIds.map( - (y) => () => + merge( + ...args.yieldIds.map((v) => + from( getYieldOpportunity({ - isLedgerLive, - yieldId: y, - queryClient, - }).chainLeft(async () => Right(null)) - ), - 5 - )() - .map((val) => val.filter((v) => !!v)) - .map((data) => - defaultFiltered({ data, isConnected, network, isLedgerLive }) - ) - .ifRight((data) => { - /** - * Set the query data for each yield opportunity - */ - data.forEach((y) => - setYieldOpportunityInCache({ - isLedgerLive, - yieldDto: y, - queryClient, + isLedgerLive: args.isLedgerLive, + yieldId: v, + queryClient: args.queryClient, }) - ); - }); + ) + ) + ).pipe( + map((v) => (v.isRight() ? v.extract() : null)), + filter( + (v): v is YieldDto => + !!( + v && + defaultFiltered({ + data: [v], + isConnected: args.isConnected, + network: args.network, + isLedgerLive: args.isLedgerLive, + }).length > 0 + ) + ) + ); -type SelectorInputData = { - data: YieldDto[]; - isConnected: boolean; - network: SKWallet["network"]; +const firstEligibleYield$ = (args: { isLedgerLive: boolean; -}; - -const skFilter = ({ - o, - isConnected, - network, -}: { - o: YieldDto; + queryClient: QueryClient; isConnected: boolean; network: SKWallet["network"]; + yieldIds: string[]; + initParams: InitParams; + positionsData: PositionsData; + tokenBalanceAmount: BigNumber; }) => { - const defaultFilter = - !o.args.enter.args?.nfts && - o.id !== "binance-bnb-native-staking" && - o.id !== "binance-testnet-bnb-native-staking" && - o.id !== "avax-native-staking" && - o.status.enter && - isSupportedChain(o.token.network); + let defaultYield: YieldDto | null = null; - if (!isConnected) return defaultFilter; + const successStream = multipleYields$(args).pipe( + tap((v) => { + defaultYield = v; + }), + filter((y) => + canBeInitialYield({ + initQueryParams: Maybe.fromNullable(args.initParams), + yieldDto: y, + tokenBalanceAmount: args.tokenBalanceAmount, + positionsData: args.positionsData, + }) + ), + take(1), + defaultIfEmpty(null) + ); - return network === o.token.network && defaultFilter; + return new Observable((subscriber) => { + successStream.subscribe({ + complete: () => subscriber.complete(), + next: (v) => subscriber.next(v ?? defaultYield), + error: (e) => subscriber.error(e), + }); + }); +}; + +type SelectorInputData = { + data: YieldDto[]; + isConnected: boolean; + network: SKWallet["network"]; + isLedgerLive: boolean; }; const selectData = (val: SelectorInputData) => val.data; @@ -148,5 +182,33 @@ const defaultFiltered = createSelector( selectConnected, selectNetwork, (data, isConnected, network) => - data.filter((o) => skFilter({ o, isConnected, network })) + data.filter((o) => { + const defaultFilter = + !o.args.enter.args?.nfts && + o.id !== "binance-bnb-native-staking" && + o.id !== "binance-testnet-bnb-native-staking" && + o.id !== "avax-native-staking" && + o.status.enter && + isSupportedChain(o.token.network); + + if (!isConnected) return defaultFilter; + + return network === o.token.network && defaultFilter; + }) ); + +const getFirstEligibleYieldQueryKey = (yieldIds: string[]) => [ + "first-eligible-yield", + yieldIds, +]; + +export const getCachedFirstEligibleYield = ({ + queryClient, + yieldIds, +}: { + queryClient: QueryClient; + yieldIds: string[]; +}) => + Maybe.fromNullable( + queryClient.getQueryData(getFirstEligibleYieldQueryKey(yieldIds)) + ); diff --git a/packages/widget/src/pages/details/earn-page/state/earn-page-context.tsx b/packages/widget/src/pages/details/earn-page/state/earn-page-context.tsx index 5e741472..d018e711 100644 --- a/packages/widget/src/pages/details/earn-page/state/earn-page-context.tsx +++ b/packages/widget/src/pages/details/earn-page/state/earn-page-context.tsx @@ -5,6 +5,7 @@ import { useEarnPageDispatch, useEarnPageState, } from "@sk-widget/pages/details/earn-page/state/earn-page-state-context"; +import { useInitYield } from "@sk-widget/pages/details/earn-page/state/use-init-yield"; import { usePendingActionDeepLink } from "@sk-widget/pages/details/earn-page/state/use-pending-action-deep-link"; import { useEnterStakeStore } from "@sk-widget/providers/enter-stake-store"; import type { @@ -163,7 +164,9 @@ export const EarnPageContextProvider = ({ children }: PropsWithChildren) => { const [validatorSearch, setValidatorSearch] = useState(""); const deferredValidatorSearch = useDeferredValue(validatorSearch); - const multiYields = useMultiYields(availableYields.orDefault([])); + const multiYields = useMultiYields( + useMemo(() => availableYields.orDefault([]), [availableYields]) + ); const tokenBalancesScan = useTokenBalancesScan(); const defaultTokens = useDefaultTokens(); @@ -220,8 +223,7 @@ export const EarnPageContextProvider = ({ children }: PropsWithChildren) => { const selectedStakeData = useMemo>( () => - Maybe.fromNullable(multiYields.data) - .alt(Maybe.of([])) + Maybe.of(multiYields) .map((val) => val.toSorted((a, b) => b.apy - a.apy)) .map((val) => val.filter((v) => v.apy > 0)) .chain((yieldDtos) => @@ -297,7 +299,7 @@ export const EarnPageContextProvider = ({ children }: PropsWithChildren) => { groupsWithCounts, }; }), - [deferredStakeSearch, multiYields.data, t] + [deferredStakeSearch, multiYields, t] ); const validatorsData = useMemo( @@ -333,7 +335,7 @@ export const EarnPageContextProvider = ({ children }: PropsWithChildren) => { ); const onYieldSelect = (yieldId: string) => { - Maybe.fromNullable(multiYields.data) + Maybe.fromNullable(multiYields) .chain((val) => List.find((v) => v.id === yieldId, val)) .ifJust((val) => dispatch({ type: "yield/select", data: val })); }; @@ -491,15 +493,12 @@ export const EarnPageContextProvider = ({ children }: PropsWithChildren) => { isConnecting || !state.layout; - const multiYieldsLoading = multiYields.isLoading; const tokenBalancesScanLoading = tokenBalancesScan.isLoading; const defaultTokensIsLoading = defaultTokens.isLoading; - const isFetching = multiYields.isFetching || tokenBalancesScan.isFetching; + const isFetching = tokenBalancesScan.isFetching; - const isError = - (!multiYields.data && multiYields.isError) || - (!tokenBalancesScan.data && tokenBalancesScan.isError); + const isError = !tokenBalancesScan.data && tokenBalancesScan.isError; const buttonDisabled = isConnected && (isFetching || stakeEnterRequestDto.isNothing()); @@ -594,9 +593,11 @@ export const EarnPageContextProvider = ({ children }: PropsWithChildren) => { const selectTokenIsLoading = tokenBalancesScanLoading || defaultTokensIsLoading; + const initYieldRes = useInitYield({ selectedToken }); + const selectYieldIsLoading = (selectedStakeId.isNothing() && !hasNotYieldsForToken) || - multiYieldsLoading || + initYieldRes.isLoading || yieldOpportunityLoading || tokenBalancesScanLoading || defaultTokensIsLoading; @@ -604,13 +605,13 @@ export const EarnPageContextProvider = ({ children }: PropsWithChildren) => { const selectValidatorIsLoading = defaultTokensIsLoading || tokenBalancesScanLoading || - multiYieldsLoading || + initYieldRes.isLoading || yieldOpportunityLoading; const footerIsLoading = defaultTokensIsLoading || tokenBalancesScanLoading || - multiYieldsLoading || + initYieldRes.isLoading || yieldOpportunityLoading; const { referralCheck } = useSettings(); @@ -643,7 +644,6 @@ export const EarnPageContextProvider = ({ children }: PropsWithChildren) => { isConnected, appLoading, yieldOpportunityLoading, - multiYieldsLoading, tokenBalancesScanLoading, tokenBalancesData, onTokenSearch, diff --git a/packages/widget/src/pages/details/earn-page/state/earn-page-state-context.tsx b/packages/widget/src/pages/details/earn-page/state/earn-page-state-context.tsx index 44fecf95..7ecda169 100644 --- a/packages/widget/src/pages/details/earn-page/state/earn-page-state-context.tsx +++ b/packages/widget/src/pages/details/earn-page/state/earn-page-state-context.tsx @@ -1,5 +1,4 @@ import { isNetworkWithEnterMinBasedOnPosition } from "@sk-widget/domain/types/stake"; -import { useMultiYields } from "@sk-widget/hooks/api/use-multi-yields"; import { usePositionsData } from "@sk-widget/hooks/use-positions-data"; import type { TokenDto, YieldDto } from "@stakekit/api-hooks"; import type { Networks } from "@stakekit/common"; @@ -198,8 +197,6 @@ export const EarnPageStateProvider = ({ children }: PropsWithChildren) => { selectedToken, }); - const multiYields = useMultiYields(availableYields.orDefault([])); - const yieldOpportunity = useYieldOpportunity(selectedStakeId.extract()); const { minEnterOrExitAmount, maxEnterOrExitAmount, isForceMax } = @@ -247,8 +244,8 @@ export const EarnPageStateProvider = ({ children }: PropsWithChildren) => { const initYieldRef = useSavedRef(initYield); const hasNotYieldsForToken = - !multiYields.isLoading && - multiYields.data?.length === 0 && + !initYieldRes.isLoading && + !initYieldRes.data && initToken.isJust() && initYield.isNothing() && selectedStakeId.isNothing(); diff --git a/packages/widget/src/pages/details/earn-page/state/types.ts b/packages/widget/src/pages/details/earn-page/state/types.ts index 147b75d7..c7dcbe9c 100644 --- a/packages/widget/src/pages/details/earn-page/state/types.ts +++ b/packages/widget/src/pages/details/earn-page/state/types.ts @@ -93,7 +93,6 @@ export type EarnPageContextType = { isConnected: boolean; isLedgerLiveAccountPlaceholder: boolean; appLoading: boolean; - multiYieldsLoading: boolean; yieldOpportunityLoading: boolean; tokenBalancesScanLoading: boolean; selectedToken: State["selectedToken"]; diff --git a/packages/widget/src/pages/details/earn-page/state/use-get-init-yield.ts b/packages/widget/src/pages/details/earn-page/state/use-get-init-yield.ts index 83371aad..7ee4ce88 100644 --- a/packages/widget/src/pages/details/earn-page/state/use-get-init-yield.ts +++ b/packages/widget/src/pages/details/earn-page/state/use-get-init-yield.ts @@ -1,41 +1,25 @@ -import { usePositionsData } from "@sk-widget/hooks/use-positions-data"; +import { useSKQueryClient } from "@sk-widget/providers/query-client"; import type { TokenDto } from "@stakekit/api-hooks"; -import BigNumber from "bignumber.js"; import { Maybe } from "purify-ts"; import { useCallback } from "react"; import { tokenString } from "../../../../domain"; -import { getInitialYield } from "../../../../domain/types/stake"; -import { getCachedMultiYields } from "../../../../hooks/api/use-multi-yields"; -import { useInitParams } from "../../../../hooks/use-init-params"; -import { useSKQueryClient } from "../../../../providers/query-client"; +import { getCachedFirstEligibleYield } from "../../../../hooks/api/use-multi-yields"; import { useTokenBalancesMap } from "./use-token-balances-map"; export const useGetInitYield = () => { - const initParams = useInitParams(); const queryClient = useSKQueryClient(); const tokenBalancesMap = useTokenBalancesMap(); - const { data: positionsData } = usePositionsData(); return useCallback( ({ selectedToken }: { selectedToken: TokenDto }) => - Maybe.fromNullable(tokenBalancesMap.get(tokenString(selectedToken))) - .chain((val) => - getCachedMultiYields({ - queryClient, - yieldIds: val.availableYields, - }).map((yields) => ({ - yields, - availableAmount: new BigNumber(val.amount), - })) - ) - .chain((val) => - getInitialYield({ - initQueryParams: Maybe.fromNullable(initParams.data), - yieldDtos: val.yields, - tokenBalanceAmount: val.availableAmount, - positionsData, - }) - ), - [initParams.data, queryClient, tokenBalancesMap, positionsData] + Maybe.fromNullable( + tokenBalancesMap.get(tokenString(selectedToken)) + ).chain((val) => + getCachedFirstEligibleYield({ + queryClient, + yieldIds: val.availableYields, + }) + ), + [queryClient, tokenBalancesMap] ); }; diff --git a/packages/widget/src/pages/details/earn-page/state/use-init-token.ts b/packages/widget/src/pages/details/earn-page/state/use-init-token.ts index 94570cab..9313d355 100644 --- a/packages/widget/src/pages/details/earn-page/state/use-init-token.ts +++ b/packages/widget/src/pages/details/earn-page/state/use-init-token.ts @@ -1,12 +1,14 @@ import { getTokenBalances } from "@sk-widget/common/get-token-balances"; import { tokenString } from "@sk-widget/domain"; import { getInitialToken } from "@sk-widget/domain/types/stake"; -import { getMultipleYields } from "@sk-widget/hooks/api/use-multi-yields"; +import { getFirstEligibleYield } from "@sk-widget/hooks/api/use-multi-yields"; import { getInitParams } from "@sk-widget/hooks/use-init-params"; +import { usePositionsData } from "@sk-widget/hooks/use-positions-data"; import { useSKQueryClient } from "@sk-widget/providers/query-client"; import { useSettings } from "@sk-widget/providers/settings"; import { useSKWallet } from "@sk-widget/providers/sk-wallet"; import { useQuery } from "@tanstack/react-query"; +import { BigNumber } from "bignumber.js"; import { EitherAsync, Maybe } from "purify-ts"; import { useGetTokenBalancesMap } from "./use-get-token-balances-map"; @@ -19,6 +21,7 @@ export const useInitToken = () => { const { isLedgerLive, isConnected, network, additionalAddresses, address } = useSKWallet(); const queryClient = useSKQueryClient(); + const { data: positionsData } = usePositionsData(); const { externalProviders } = useSettings(); @@ -57,12 +60,15 @@ export const useInitToken = () => { ).toEither(new Error("could not get token balance")) ) .chain((tokenBalance) => - getMultipleYields({ + getFirstEligibleYield({ isConnected, isLedgerLive, queryClient, network, yieldIds: tokenBalance.availableYields, + initParams: initParams, + positionsData: positionsData, + tokenBalanceAmount: new BigNumber(tokenBalance.amount), }) ) .map(() => token) diff --git a/packages/widget/src/pages/details/earn-page/state/use-init-yield.ts b/packages/widget/src/pages/details/earn-page/state/use-init-yield.ts index 6e85283b..eba02730 100644 --- a/packages/widget/src/pages/details/earn-page/state/use-init-yield.ts +++ b/packages/widget/src/pages/details/earn-page/state/use-init-yield.ts @@ -1,7 +1,6 @@ import { getTokenBalances } from "@sk-widget/common/get-token-balances"; import { tokenString } from "@sk-widget/domain"; -import { getInitialYield } from "@sk-widget/domain/types/stake"; -import { getMultipleYields } from "@sk-widget/hooks/api/use-multi-yields"; +import { getFirstEligibleYield } from "@sk-widget/hooks/api/use-multi-yields"; import { getInitParams } from "@sk-widget/hooks/use-init-params"; import { usePositionsData } from "@sk-widget/hooks/use-positions-data"; import { useSKQueryClient } from "@sk-widget/providers/query-client"; @@ -59,22 +58,16 @@ export const useInitYield = ({ queryClient, externalProviders, }).chain((initParams) => - getMultipleYields({ + getFirstEligibleYield({ isConnected, isLedgerLive, queryClient, network, yieldIds: val.availableYields, - }).chain((multipleYields) => - EitherAsync.liftEither( - getInitialYield({ - initQueryParams: Maybe.fromNullable(initParams), - yieldDtos: multipleYields, - tokenBalanceAmount: new BigNumber(val.amount), - positionsData, - }).toEither(new Error("could not get initial yield")) - ) - ) + initParams: initParams, + positionsData: positionsData, + tokenBalanceAmount: new BigNumber(val.amount), + }) ) ) ) diff --git a/packages/widget/tests/use-cases/staking-flow/staking-flow.test.tsx b/packages/widget/tests/use-cases/staking-flow/staking-flow.test.tsx index e32a92f0..352eb72a 100644 --- a/packages/widget/tests/use-cases/staking-flow/staking-flow.test.tsx +++ b/packages/widget/tests/use-cases/staking-flow/staking-flow.test.tsx @@ -35,11 +35,13 @@ describe("Staking flow", () => { getByTestId("select-modal__container") ); - within(selectContainer) - .getByTestId("select-opportunity__item_avalanche-avax-liquid-staking", { - exact: false, - }) - .click(); + await waitFor(() => + within(selectContainer) + .getByTestId("select-opportunity__item_avalanche-avax-liquid-staking", { + exact: false, + }) + .click() + ); await waitFor(() => { const trigger = getByTestId("select-opportunity");