diff --git a/package.json b/package.json index 9487ca9e..43eb5ded 100644 --- a/package.json +++ b/package.json @@ -23,11 +23,5 @@ "husky": "catalog:", "rev-dep": "catalog:", "turbo": "catalog:" - }, - "pnpm": { - "patchedDependencies": { - "purify-ts": "patches/purify-ts.patch", - "@stakekit/rainbowkit@2.2.11": "patches/@stakekit__rainbowkit@2.2.11.patch" - } } } diff --git a/packages/widget/package.json b/packages/widget/package.json index c26d1b74..4458d1d4 100644 --- a/packages/widget/package.json +++ b/packages/widget/package.json @@ -75,6 +75,7 @@ "@cosmos-kit/keplr": "catalog:", "@cosmos-kit/leap": "catalog:", "@cosmos-kit/walletconnect": "catalog:", + "@effect/atom-react": "catalog:", "@effect/openapi-generator": "catalog:", "@effect/platform-node": "catalog:", "@faker-js/faker": "catalog:", @@ -117,7 +118,6 @@ "@vitejs/plugin-react": "catalog:", "@vitest/browser-playwright": "catalog:", "@xstate/react": "catalog:", - "@xstate/store": "catalog:", "autoprefixer": "catalog:", "babel-plugin-react-compiler": "catalog:", "bignumber.js": "catalog:", diff --git a/packages/widget/src/Dashboard.tsx b/packages/widget/src/Dashboard.tsx index 91fb2ca0..0fb2240d 100644 --- a/packages/widget/src/Dashboard.tsx +++ b/packages/widget/src/Dashboard.tsx @@ -6,7 +6,6 @@ import { PendingCompletePage } from "./pages/complete/pages/pending-complete.pag import { StakeCompletePage } from "./pages/complete/pages/stake-complete.page"; import { UnstakeCompletePage } from "./pages/complete/pages/unstake-complete.page"; import { EarnPageContextProvider } from "./pages/details/earn-page/state/earn-page-context"; -import { EarnPageStateUsageBoundaryProvider } from "./pages/details/earn-page/state/earn-page-state-context"; import { StakeReviewPage } from "./pages/review"; import { PendingReviewPage } from "./pages/review/pages/pending-review.page"; import { UnstakeReviewPage } from "./pages/review/pages/unstake-review.page"; @@ -40,77 +39,73 @@ export const Dashboard = () => { return ( - - - - }> - {/* Earn Tab */} - }> - } /> + + + }> + {/* Earn Tab */} + }> + } /> - }> - } /> - } /> - } /> - + }> + } /> + } /> + } /> + - {/* Manage Tab */} - } /> + {/* Manage Tab */} + } /> - {/* Position Details */} - } - > - } /> + {/* Position Details */} + } + > + } /> - {/* Staking */} - - } /> - } /> - } /> - } /> - + {/* Staking */} + + } /> + } /> + } /> + } /> + - } - /> + } + /> - {/* Unstaking */} - - } /> - } /> - } /> - } /> - + {/* Unstaking */} + + } /> + } /> + } /> + } /> + - {/* Pending Actions */} - - } /> - } /> - } /> - + {/* Pending Actions */} + + } /> + } /> + } /> + - {/* Rewards Tab */} - {/* } /> */} + {/* Rewards Tab */} + {/* } /> */} - {/* Activity Tab */} - }> - } /> - } - /> - + {/* Activity Tab */} + }> + } /> + } + /> - - - + + + diff --git a/packages/widget/src/common/get-token-balances.ts b/packages/widget/src/common/get-token-balances.ts deleted file mode 100644 index f526b639..00000000 --- a/packages/widget/src/common/get-token-balances.ts +++ /dev/null @@ -1,68 +0,0 @@ -import type { QueryClient } from "@tanstack/react-query"; -import { EitherAsync, Right } from "purify-ts"; -import type { SKWallet } from "../domain/types/wallet"; -import type { DashboardYieldCategory } from "../domain/types/yields"; -import { - getDefaultTokens, - getYieldTypesForDashboardCategory, -} from "../hooks/api/use-default-tokens"; -import { getTokenBalancesScan } from "../hooks/api/use-token-balances-scan"; -import type { ApiClient } from "../providers/api/api-client"; -import type { SettingsProps } from "../providers/settings/types"; - -export const getTokenBalances = ({ - additionalAddresses, - address, - apiClient, - network, - selectedDashboardYieldCategory, - queryClient, - tokensForEnabledYieldsOnly, -}: { - additionalAddresses: SKWallet["additionalAddresses"]; - address: SKWallet["address"]; - apiClient: ApiClient; - queryClient: QueryClient; - network: SKWallet["network"]; - selectedDashboardYieldCategory?: DashboardYieldCategory | null; - tokensForEnabledYieldsOnly: SettingsProps["tokensForEnabledYieldsOnly"]; -}) => - EitherAsync.fromPromise(() => - Promise.all([ - getDefaultTokens({ - apiClient, - queryClient, - network: network ?? undefined, - enabledYieldsOnly: tokensForEnabledYieldsOnly, - yieldTypes: getYieldTypesForDashboardCategory( - selectedDashboardYieldCategory - ), - }), - EitherAsync.liftEither( - Right({ additionalAddresses, address, network }) - ).chain(async (params) => { - if (!params.address || !params.network) { - return Right([]); - } - - return getTokenBalancesScan({ - apiClient, - queryClient, - tokenBalanceScanDto: { - addresses: { - address: params.address, - additionalAddresses: params.additionalAddresses ?? undefined, - }, - network: params.network, - }, - }).mapLeft(() => new Error("could not get token balances scan")); - }), - ]).then(([defaultTokens, tokenBalances]) => - defaultTokens.chain((d) => - tokenBalances.map((b) => ({ - defaultTokens: d, - tokenBalancesScan: b, - })) - ) - ) - ).mapLeft(() => new Error("could not get tokens")); diff --git a/packages/widget/src/domain/types/stake.ts b/packages/widget/src/domain/types/stake.ts index 748cbd08..77fc9509 100644 --- a/packages/widget/src/domain/types/stake.ts +++ b/packages/widget/src/domain/types/stake.ts @@ -151,9 +151,6 @@ const yieldsWithEnterMinBasedOnPosition = new Map>([ [Networks.Polkadot, new Set(["polkadot-dot-validator-staking"])], ]); -export const isNetworkWithEnterMinBasedOnPosition = (network: Networks) => - yieldsWithEnterMinBasedOnPosition.has(network); - const isYieldWithEnterMinBasedOnPosition = (yieldDto: YieldBase) => Maybe.fromNullable( yieldsWithEnterMinBasedOnPosition.get( diff --git a/packages/widget/src/hooks/api/use-dashboard-yield-catalog.ts b/packages/widget/src/hooks/api/use-dashboard-yield-catalog.ts deleted file mode 100644 index 6ee2dfa8..00000000 --- a/packages/widget/src/hooks/api/use-dashboard-yield-catalog.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { useQueries } from "@tanstack/react-query"; -import { useMemo } from "react"; -import type { TokenDto } from "../../domain/types/tokens"; -import { - type DashboardYieldCategory, - getApiYieldTypesForDashboardCategory, -} from "../../domain/types/yields"; -import { useApiClient } from "../../providers/api/api-client-provider"; -import { useSettings } from "../../providers/settings"; -import { useSKWallet } from "../../providers/sk-wallet"; -import { - DEFAULT_YIELD_SUMMARIES_PAGE_LIMIT, - fetchYieldSummariesPage, - getYieldSummariesQueryKey, - isVisibleYieldSummary, - type YieldSummariesParams, - type YieldSummary, -} from "./use-yield-summaries"; - -type DashboardCategoryInitialSelection = { - token: TokenDto; - yieldDto: YieldSummary; - yieldId: string; -}; - -const staleTime = 1000 * 60 * 2; - -/** - * Discovers available dashboard earn categories with one network-scoped, - * reward-rate-sorted probe per category (no dependency on the wallet's token - * holdings, no per-yield legacy hydration). The first visible summary of each - * probe seeds that category's initial token + yield selection. - */ -export const useDashboardYieldCatalog = ({ - enabled = true, -}: { - enabled?: boolean; -} = {}) => { - const { network, isConnecting } = useSKWallet(); - const apiClient = useApiClient(); - const { dashboardYieldCategoryOrder } = useSettings(); - - const probeEnabled = enabled && !isConnecting; - - const results = useQueries({ - queries: dashboardYieldCategoryOrder.map((category) => { - const params: YieldSummariesParams = { - ...(network ? { network: network } : {}), - types: getApiYieldTypesForDashboardCategory(category), - sort: "rewardRateDesc", - limit: DEFAULT_YIELD_SUMMARIES_PAGE_LIMIT, - }; - - return { - enabled: probeEnabled, - staleTime, - queryKey: getYieldSummariesQueryKey(params), - queryFn: ({ signal }: { signal: AbortSignal }) => - fetchYieldSummariesPage({ apiClient, params, signal }), - }; - }), - }); - - return useMemo(() => { - const initialSelectionByCategory = new Map< - DashboardYieldCategory, - DashboardCategoryInitialSelection - >(); - const availableCategories: DashboardYieldCategory[] = []; - - dashboardYieldCategoryOrder.forEach((category, index) => { - const firstVisible = (results[index]?.data ?? []).find( - isVisibleYieldSummary - ); - - if (!firstVisible) return; - - availableCategories.push(category); - initialSelectionByCategory.set(category, { - token: firstVisible.token, - yieldDto: firstVisible, - yieldId: firstVisible.id, - }); - }); - - return { - availableCategories, - initialSelectionByCategory, - isLoading: probeEnabled && results.some((result) => result.isLoading), - }; - }, [dashboardYieldCategoryOrder, results, probeEnabled]); -}; diff --git a/packages/widget/src/hooks/api/use-default-tokens.ts b/packages/widget/src/hooks/api/use-default-tokens.ts deleted file mode 100644 index d6f28f80..00000000 --- a/packages/widget/src/hooks/api/use-default-tokens.ts +++ /dev/null @@ -1,312 +0,0 @@ -import { - type InfiniteData, - type QueryClient, - useInfiniteQuery, - useQuery, -} from "@tanstack/react-query"; -import { EitherAsync } from "purify-ts"; -import type { TokenBalanceScanResponseDto } from "../../domain/types/token-balance"; -import type { TokenDto } from "../../domain/types/tokens"; -import { - type DashboardYieldCategory, - getApiYieldTypesForDashboardCategory, -} from "../../domain/types/yields"; -import type { - TokenControllerGetTokensParams as LegacyTokenGetTokensParams, - TokenWithAvailableYieldsDto as LegacyTokenWithAvailableYieldsDto, -} from "../../generated/api/legacy"; -import type { - TokensControllerGetTokensParams as YieldTokenGetTokensParams, - TokenWithAvailableYieldsDto as YieldTokenWithAvailableYieldsDto, -} from "../../generated/api/yield"; -import type { ApiClient } from "../../providers/api/api-client"; -import { useApiClient } from "../../providers/api/api-client-provider"; -import { useSettings } from "../../providers/settings"; -import { useSKWallet } from "../../providers/sk-wallet"; - -const DEFAULT_TOKENS_PAGE_LIMIT = 100; -const DEFAULT_TOKENS_PAGE_CONCURRENCY = 5; - -type YieldTokenTypes = YieldTokenGetTokensParams["yieldTypes"]; -type DefaultTokensQueryParams = { - enabledYieldsOnly?: boolean; - network?: TokenDto["network"]; - yieldTypes?: YieldTokenTypes; -}; -type DefaultTokensPage = { - limit?: number; - nextOffset?: number; - offset?: number; - tokens: TokenBalanceScanResponseDto[]; - total?: number; -}; -type DefaultTokensPages = { - pages: DefaultTokensPage[]; - pageParams: number[]; -}; -type FetchDefaultTokensPageParams = DefaultTokensQueryParams & { - apiClient: ApiClient; - limit?: number; - offset?: number; - signal?: AbortSignal; -}; - -const noopFetchNextPage = () => undefined; - -const getTokenGetTokensQueryKey = (params?: DefaultTokensQueryParams) => - ["/v1/tokens", ...(params ? [params] : [])] as const; - -const getAllDefaultTokensQueryKey = (params?: DefaultTokensQueryParams) => - ["/v1/tokens/all-pages", ...(params ? [params] : [])] as const; - -const getNextOffset = ({ - limit, - offset, - total, -}: { - limit: number; - offset: number; - total: number; -}) => { - const nextOffset = offset + limit; - - return nextOffset < total ? nextOffset : undefined; -}; - -const shouldUseYieldTokensApi = ({ - enabledYieldsOnly, - yieldTypes, -}: Pick) => - !!enabledYieldsOnly || !!yieldTypes?.length; - -const toTokenBalanceScanResponse = ( - tokenWithYields: - | LegacyTokenWithAvailableYieldsDto - | YieldTokenWithAvailableYieldsDto -): TokenBalanceScanResponseDto => ({ - token: tokenWithYields.token, - availableYields: tokenWithYields.availableYields, - amount: "0", -}); - -export const getYieldTypesForDashboardCategory = ( - yieldCategory?: DashboardYieldCategory | null -): YieldTokenTypes => - yieldCategory - ? getApiYieldTypesForDashboardCategory(yieldCategory) - : undefined; - -export const fetchDefaultTokens = async ({ - apiClient, - enabledYieldsOnly, - limit = DEFAULT_TOKENS_PAGE_LIMIT, - network, - offset: firstOffset = 0, - signal, - yieldTypes, -}: FetchDefaultTokensPageParams): Promise => { - if (shouldUseYieldTokensApi({ enabledYieldsOnly, yieldTypes })) { - const yieldTokenParams = { - networks: network - ? [ - network as NonNullable< - YieldTokenGetTokensParams["networks"] - >[number], - ] - : undefined, - yieldTypes, - limit, - }; - - const fetchYieldTokensPage = async ( - offset: number - ): Promise => { - const page = await apiClient - .withOptions({ signal }) - .yield.TokensControllerGetTokens({ - params: { ...yieldTokenParams, offset }, - }); - - return { - limit: page.limit, - tokens: (page.items ?? []).map(toTokenBalanceScanResponse), - nextOffset: getNextOffset(page), - offset: page.offset, - total: page.total, - }; - }; - - const firstPage = await fetchYieldTokensPage(firstOffset); - - if (firstPage.nextOffset === undefined) { - return { pages: [firstPage], pageParams: [firstOffset] }; - } - - const remainingOffsets: number[] = []; - for ( - let offset = firstPage.offset! + firstPage.limit!; - offset < firstPage.total!; - offset += firstPage.limit! - ) { - remainingOffsets.push(offset); - } - - const pages = [firstPage]; - const pageParams = [firstOffset, ...remainingOffsets]; - - for ( - let i = 0; - i < remainingOffsets.length; - i += DEFAULT_TOKENS_PAGE_CONCURRENCY - ) { - const chunk = remainingOffsets.slice( - i, - i + DEFAULT_TOKENS_PAGE_CONCURRENCY - ); - const chunkPages = await Promise.all( - chunk.map((offset) => fetchYieldTokensPage(offset)) - ); - - pages.push(...chunkPages); - } - - return { pages, pageParams }; - } - - const tokens = await apiClient - .withOptions({ signal }) - .legacy.TokenControllerGetTokens({ - params: { - enabledYieldsOnly: enabledYieldsOnly || undefined, - network: network as LegacyTokenGetTokensParams["network"], - }, - }); - - return { - pages: [{ tokens: tokens.map(toTokenBalanceScanResponse) }], - pageParams: [firstOffset], - }; -}; - -export const useDefaultTokens = ({ - yieldCategory, -}: { - yieldCategory?: DashboardYieldCategory | null; -} = {}) => { - const { network } = useSKWallet(); - const { tokensForEnabledYieldsOnly } = useSettings(); - const apiClient = useApiClient(); - const queryParams: DefaultTokensQueryParams = { - enabledYieldsOnly: !!tokensForEnabledYieldsOnly, - network: network ?? undefined, - yieldTypes: getYieldTypesForDashboardCategory(yieldCategory), - }; - const shouldFetchAllPages = !!queryParams.yieldTypes?.length; - - const allPagesQuery = useQuery({ - queryKey: getAllDefaultTokensQueryKey(queryParams), - enabled: shouldFetchAllPages, - queryFn: async ({ signal }) => { - const data = await fetchDefaultTokens({ - ...queryParams, - apiClient, - signal, - }); - - return data.pages.flatMap((page) => page.tokens); - }, - staleTime: 1000 * 60 * 5, - }); - - const infiniteQuery = useInfiniteQuery({ - queryKey: getTokenGetTokensQueryKey(queryParams), - enabled: !shouldFetchAllPages, - initialPageParam: 0, - queryFn: async ({ pageParam, signal }) => { - if (shouldUseYieldTokensApi(queryParams)) { - const page = await apiClient - .withOptions({ signal }) - .yield.TokensControllerGetTokens({ - params: { - networks: queryParams.network - ? [ - queryParams.network as NonNullable< - YieldTokenGetTokensParams["networks"] - >[number], - ] - : undefined, - yieldTypes: queryParams.yieldTypes, - offset: pageParam, - limit: DEFAULT_TOKENS_PAGE_LIMIT, - }, - }); - - return { - limit: page.limit, - tokens: (page.items ?? []).map(toTokenBalanceScanResponse), - nextOffset: getNextOffset(page), - offset: page.offset, - total: page.total, - }; - } - - const tokens = await apiClient - .withOptions({ signal }) - .legacy.TokenControllerGetTokens({ - params: { - enabledYieldsOnly: queryParams.enabledYieldsOnly || undefined, - network: - queryParams.network as LegacyTokenGetTokensParams["network"], - }, - }); - - return { - tokens: tokens.map(toTokenBalanceScanResponse), - }; - }, - getNextPageParam: (lastPage) => lastPage.nextOffset, - select: (data) => data.pages.flatMap((page) => page.tokens), - staleTime: 1000 * 60 * 5, - }); - - if (shouldFetchAllPages) { - return { - ...allPagesQuery, - fetchNextPage: noopFetchNextPage, - hasNextPage: false, - isFetchingNextPage: false, - }; - } - - return infiniteQuery; -}; - -export const getDefaultTokens = ( - params: Omit & { - queryClient: QueryClient; - } -) => { - const queryParams: DefaultTokensQueryParams = { - enabledYieldsOnly: params.enabledYieldsOnly, - network: params.network, - yieldTypes: params.yieldTypes, - }; - - return EitherAsync(() => - params.queryClient.fetchQuery({ - queryKey: getAllDefaultTokensQueryKey(queryParams), - queryFn: async () => { - const data = await fetchDefaultTokens(params); - params.queryClient.setQueryData< - InfiniteData - >(getTokenGetTokensQueryKey(queryParams), data); - - return data.pages.flatMap((page) => page.tokens); - }, - staleTime: 1000 * 60 * 5, - }) - ).mapLeft((e) => { - console.log(e); - return new Error("could not get default tokens"); - }); -}; diff --git a/packages/widget/src/hooks/api/use-multi-yields.ts b/packages/widget/src/hooks/api/use-multi-yields.ts index a00b2fe5..9d8c9202 100644 --- a/packages/widget/src/hooks/api/use-multi-yields.ts +++ b/packages/widget/src/hooks/api/use-multi-yields.ts @@ -1,118 +1,27 @@ -import { hashKey, type QueryClient, useQuery } 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 { type QueryClient, useQuery } from "@tanstack/react-query"; import { createSelector } from "reselect"; import { - defaultIfEmpty, EMPTY, filter, firstValueFrom, from, map, mergeMap, - Observable, - of, - repeat, - take, - tap, - timer, toArray, } from "rxjs"; -import { tokenString } from "../../domain"; -import { - isSupportedChain, - type SupportedSKChains, -} from "../../domain/types/chains"; -import type { InitParams } from "../../domain/types/init-params"; -import type { PositionsData } from "../../domain/types/positions"; -import { - canBeInitialYield, - type PreferredTokenYieldsPerNetwork, -} from "../../domain/types/stake"; +import { isSupportedChain } from "../../domain/types/chains"; import type { SKWallet } from "../../domain/types/wallet"; -import { - type DashboardYieldCategory, - getDashboardYieldCategory, - isNonZeroRewardRateYield, - type ValidatorsConfig, - type Yield, - type YieldBase, +import type { + ValidatorsConfig, + Yield, + YieldBase, } from "../../domain/types/yields"; import { useApiClient } from "../../providers/api/api-client-provider"; import { useSKQueryClient } from "../../providers/query-client"; import { useSKWallet } from "../../providers/sk-wallet"; import { useSavedRef } from "../use-saved-ref"; import { useValidatorsConfig } from "../use-validators-config"; -import { - getYieldOpportunities, - getYieldOpportunityFromSummary, -} from "./use-yield-opportunity/get-yield-opportunity"; -import { - fetchYieldSummariesWithProvidersByIds, - type YieldSummaryWithProvider, -} from "./use-yield-summaries"; - -const multiYieldsStore = createStore({ - context: { data: new Map>() }, - on: { - "yield-opportunity": ( - context, - event: { data: { key: string; yieldDto: YieldSummaryWithProvider } } - ) => { - const newMap = new Map(context.data); - const prev = newMap.get(event.data.key) ?? new Map(); - - prev.set(event.data.yieldDto.id, event.data.yieldDto); - newMap.set(event.data.key, prev); - - return { data: newMap }; - }, - }, -}); - -export const useStreamYieldSummaries = (yieldIds: ReadonlyArray) => { - const { network, isConnected, isLedgerLive } = useSKWallet(); - const apiClient = useApiClient(); - - const argsRef = useSavedRef({ - isLedgerLive, - queryClient: useSKQueryClient(), - apiClient, - network, - isConnected, - }); - - const hashedKey = useMemo(() => hashKey(yieldIds), [yieldIds]); - - const validatorsConfig = useValidatorsConfig(); - - useEffect(() => { - const sub = multipleYieldSummaries$({ - ...argsRef.current, - yieldIds, - validatorsConfig, - }) - .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, validatorsConfig]); - - return useSelector(multiYieldsStore, (state) => { - const map = state.context.data.get(hashedKey); - - return map ? Array.from(map.values()) : []; - }); -}; +import { getYieldOpportunities } from "./use-yield-opportunity/get-yield-opportunity"; export const useMultiYields = ( yieldIds: ReadonlyArray, @@ -149,22 +58,6 @@ export const useMultiYields = ( }); }; -export const getFirstEligibleYield = ( - params: Parameters[0] -) => - EitherAsync(() => - params.queryClient.fetchQuery({ - queryKey: getFirstEligibleYieldQueryKey({ - yieldIds: params.yieldIds, - dashboardYieldCategory: params.dashboardYieldCategory, - }), - queryFn: () => firstValueFrom(firstEligibleYield$(params)), - }) - ).mapLeft((e) => { - console.log(e); - return new Error("could not get first eligible yield"); - }); - const multipleYields$ = (args: { isLedgerLive: boolean; queryClient: QueryClient; @@ -200,119 +93,6 @@ const multipleYields$ = (args: { ) ); -const multipleYieldSummaries$ = (args: { - isLedgerLive: boolean; - queryClient: QueryClient; - apiClient: ReturnType; - isConnected: boolean; - network: SKWallet["network"]; - yieldIds: ReadonlyArray; - validatorsConfig: ValidatorsConfig; -}) => - args.yieldIds.length === 0 - ? EMPTY - : from( - fetchYieldSummariesWithProvidersByIds({ - apiClient: args.apiClient, - queryClient: args.queryClient, - yieldIds: args.yieldIds, - }) - ).pipe( - mergeMap((v) => from(v)), - filter( - (v): v is YieldSummaryWithProvider => - !!( - v && - defaultFiltered({ - data: [v], - isConnected: args.isConnected, - network: args.network, - isLedgerLive: args.isLedgerLive, - }).length > 0 - ) - ) - ); - -const firstEligibleYield$ = (args: { - isLedgerLive: boolean; - queryClient: QueryClient; - apiClient: ReturnType; - isConnected: boolean; - network: SKWallet["network"]; - yieldIds: ReadonlyArray; - dashboardYieldCategory?: DashboardYieldCategory | null; - initParams: InitParams; - positionsData: PositionsData; - tokenBalanceAmount: BigNumber; - validatorsConfig: ValidatorsConfig; - preferredTokenYieldsPerNetwork: PreferredTokenYieldsPerNetwork | null; -}) => { - let defaultYield: YieldSummaryWithProvider | null = null; - - const successStream = multipleYieldSummaries$(args).pipe( - filter( - (y) => - !args.dashboardYieldCategory || - getDashboardYieldCategory(y) === args.dashboardYieldCategory - ), - tap((v) => { - if (isNonZeroRewardRateYield(v) || !defaultYield) { - defaultYield = v; - } - }), - filter((y) => { - const preferredYieldId = Maybe.fromNullable( - args.preferredTokenYieldsPerNetwork?.[ - y.token.network as SupportedSKChains - ]?.[tokenString(y.token)] - ) - .altLazy(() => - Maybe.fromNullable(args.preferredTokenYieldsPerNetwork).chainNullable( - (v) => Object.values(v)[0][tokenString(y.token)] - ) - ) - .extractNullable(); - - if (preferredYieldId) { - return y.id === preferredYieldId || preferredYieldId === "*"; - } - - return canBeInitialYield({ - initQueryParams: Maybe.fromNullable(args.initParams), - yieldDto: y, - tokenBalanceAmount: args.tokenBalanceAmount, - positionsData: args.positionsData, - }); - }), - take(1), - defaultIfEmpty(null), - mergeMap((yieldSummary) => { - const selectedYield = yieldSummary ?? defaultYield; - - if (!selectedYield) { - return of(null); - } - - return from( - getYieldOpportunityFromSummary({ - isLedgerLive: args.isLedgerLive, - yieldDto: selectedYield, - queryClient: args.queryClient, - apiClient: args.apiClient, - }) - ).pipe(map((v) => (v.isRight() ? v.extract() : null))); - }) - ); - - return new Observable((subscriber) => { - successStream.subscribe({ - complete: () => subscriber.complete(), - next: (v) => subscriber.next(v), - error: (e) => subscriber.error(e), - }); - }); -}; - type SelectorInputData = { data: YieldBase[]; isConnected: boolean; @@ -342,26 +122,3 @@ const defaultFiltered = createSelector( return network === o.token.network && defaultFilter; }) ); - -const getFirstEligibleYieldQueryKey = ({ - yieldIds, - dashboardYieldCategory, -}: { - yieldIds: ReadonlyArray; - dashboardYieldCategory?: DashboardYieldCategory | null; -}) => ["first-eligible-yield", yieldIds, dashboardYieldCategory ?? null]; - -export const getCachedFirstEligibleYield = ({ - queryClient, - yieldIds, - dashboardYieldCategory, -}: { - queryClient: QueryClient; - yieldIds: ReadonlyArray; - dashboardYieldCategory?: DashboardYieldCategory | null; -}) => - Maybe.fromNullable( - queryClient.getQueryData( - getFirstEligibleYieldQueryKey({ yieldIds, dashboardYieldCategory }) - ) - ); diff --git a/packages/widget/src/hooks/api/use-token-balances-scan.ts b/packages/widget/src/hooks/api/use-token-balances-scan.ts index e1183999..9c686832 100644 --- a/packages/widget/src/hooks/api/use-token-balances-scan.ts +++ b/packages/widget/src/hooks/api/use-token-balances-scan.ts @@ -1,4 +1,3 @@ -import type { QueryClient } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query"; import { EitherAsync, Just, Maybe } from "purify-ts"; import { useCallback, useMemo } from "react"; @@ -59,19 +58,6 @@ export const useTokenBalancesScan = () => { }); }; -export const getTokenBalancesScan = ( - params: Parameters[0] & { queryClient: QueryClient } -) => - EitherAsync(() => - params.queryClient.fetchQuery({ - queryKey: getTokenTokenBalancesScanQueryKey(params.tokenBalanceScanDto), - queryFn: async () => (await queryFn(params)).unsafeCoerce(), - }) - ).mapLeft((e) => { - console.log(e); - return new Error("could not get multi yields"); - }); - const queryFn = ({ apiClient, tokenBalanceScanDto, diff --git a/packages/widget/src/hooks/api/use-yield-opportunity/get-yield-opportunity.ts b/packages/widget/src/hooks/api/use-yield-opportunity/get-yield-opportunity.ts index 058614f7..4c335469 100644 --- a/packages/widget/src/hooks/api/use-yield-opportunity/get-yield-opportunity.ts +++ b/packages/widget/src/hooks/api/use-yield-opportunity/get-yield-opportunity.ts @@ -24,9 +24,6 @@ type MultiParams = Omit & { type ParamsWithQueryClient = Params & { queryClient: QueryClient; }; -type ParamsWithYieldDto = Omit & { - yieldDto: YieldBase; -}; type MultiParamsWithQueryClient = MultiParams & { queryClient: QueryClient; }; @@ -78,18 +75,6 @@ export const getYieldOpportunity = (params: ParamsWithQueryClient) => return new Error("Could not get yield opportunity"); }); -export const getYieldOpportunityFromSummary = (params: ParamsWithYieldDto) => - EitherAsync(() => - params.queryClient.fetchQuery({ - queryKey: getKey({ ...params, yieldId: params.yieldDto.id }), - staleTime, - queryFn: () => hydrateYieldSummaryQueryFn(params), - }) - ).mapLeft((e) => { - console.log(e); - return new Error("Could not get yield opportunity"); - }); - export const getYieldOpportunities = (params: MultiParamsWithQueryClient) => EitherAsync(() => params.queryClient.fetchQuery({ @@ -171,32 +156,6 @@ const fn = ({ }); }; -const hydrateYieldSummaryQueryFn = async ({ - yieldDto, - queryClient, - signal, - suppressRichErrors, - apiClient, -}: ParamsWithYieldDto & { - signal?: AbortSignal; -}) => { - const client = apiClient.withOptions({ signal, suppressRichErrors }); - const provider = - yieldDto.provider ?? - (await fetchYieldProvider({ - client, - providerId: yieldDto.providerId, - queryClient, - })); - - return applyYieldOverrides( - createYield({ - provider, - yieldDto, - }) - ); -}; - const multiFn = ({ queryClient, yieldIds, diff --git a/packages/widget/src/hooks/api/use-yield-summaries.ts b/packages/widget/src/hooks/api/use-yield-summaries.ts index eb697dd4..ae8bcb65 100644 --- a/packages/widget/src/hooks/api/use-yield-summaries.ts +++ b/packages/widget/src/hooks/api/use-yield-summaries.ts @@ -1,14 +1,12 @@ import type { QueryClient } from "@tanstack/react-query"; +import { chunksOf } from "effect/Array"; import { isSupportedChain } from "../../domain/types/chains"; import { isEthenaUsdeStaking, isNonZeroRewardRateYield, type YieldProviderDetails, } from "../../domain/types/yields"; -import type { - YieldDto, - YieldsControllerGetYieldsParams, -} from "../../generated/api/yield"; +import type { YieldDto } from "../../generated/api/yield"; import type { useApiClient } from "../../providers/api/api-client-provider"; import { fetchYieldProviders } from "./use-yield-providers"; @@ -19,18 +17,12 @@ import { fetchYieldProviders } from "./use-yield-providers"; * `mechanics.type`, `providerId`). */ export type YieldSummary = YieldDto; -export type YieldSummaryWithProvider = YieldSummary & { +type YieldSummaryWithProvider = YieldSummary & { provider?: YieldProviderDetails; }; -export const DEFAULT_YIELD_SUMMARIES_PAGE_LIMIT = 50; const DEFAULT_YIELD_IDS_CHUNK_SIZE = 100; -export type YieldSummariesParams = Pick< - YieldsControllerGetYieldsParams, - "network" | "types" | "inputToken" | "sort" | "limit" ->; - /** * A summary is "visible" when it is enterable, on a supported chain, and has a * non-zero reward rate (matching the dashboard catalog's display semantics). @@ -40,16 +32,6 @@ export const isVisibleYieldSummary = (summary: YieldSummary): boolean => isSupportedChain(summary.token.network) && isNonZeroRewardRateYield(summary); -export const getYieldSummariesQueryKey = ( - params: YieldSummariesParams & { allPages?: boolean } -) => ["yield-summaries", params]; - -type FetchArgs = { - apiClient: ReturnType; - params: YieldSummariesParams; - signal?: AbortSignal; -}; - type FetchByIdsArgs = { apiClient: ReturnType; chunkSize?: number; @@ -64,30 +46,6 @@ type FetchByIdsWithProvidersArgs = FetchByIdsArgs & { const unique = (items: ReadonlyArray) => [...new Set(items)]; -const chunks = (items: ReadonlyArray, chunkSize: number): T[][] => { - const result: T[][] = []; - - for (let index = 0; index < items.length; index += chunkSize) { - result.push(items.slice(index, index + chunkSize)); - } - - return result; -}; - -/** - * Fetch a single page of yield summaries. - */ -export const fetchYieldSummariesPage = async ({ - apiClient, - params, - signal, -}: FetchArgs): Promise => { - const client = apiClient.withOptions({ signal }); - const result = await client.yield.YieldsControllerGetYields({ params }); - - return [...(result.items ?? [])]; -}; - /** * Fetch yield summaries by ID without ever sending an unbounded `yieldIds` * query array. Results are ordered according to the first occurrence of each @@ -110,7 +68,7 @@ export const fetchYieldSummariesByIds = async ({ const normalizedChunkSize = Math.max(1, chunkSize); const summariesById = new Map(); - for (const chunk of chunks(ids, normalizedChunkSize)) { + for (const chunk of chunksOf(ids, normalizedChunkSize)) { const result = await client.yield.YieldsControllerGetYields({ params: { yieldIds: chunk, diff --git a/packages/widget/src/hooks/use-handle-deep-links.ts b/packages/widget/src/hooks/use-handle-deep-links.ts index 82b1ce27..caa9acc6 100644 --- a/packages/widget/src/hooks/use-handle-deep-links.ts +++ b/packages/widget/src/hooks/use-handle-deep-links.ts @@ -3,7 +3,7 @@ import { useEffect } from "react"; import { useNavigate } from "react-router"; import { usePendingActionDeepLink } from "../pages/details/earn-page/state/use-pending-action-deep-link"; import { useMountAnimation } from "../providers/mount-animation"; -import { usePendingActionStore } from "../providers/pending-action-store"; +import { useSetPendingActionRequest } from "../providers/pending-action-store"; import { useSKWallet } from "../providers/sk-wallet"; import { useInitQueryParams } from "./use-init-query-params"; import { useSavedRef } from "./use-saved-ref"; @@ -11,7 +11,7 @@ import { useSavedRef } from "./use-saved-ref"; export const useHandleDeepLinks = () => { const pendingActionDeepLinkCheck = usePendingActionDeepLink(); const navigateRef = useSavedRef(useNavigate()); - const pendignActionStore = usePendingActionStore(); + const setPendingActionRequest = useSetPendingActionRequest(); const initQueryParams = useInitQueryParams(); const { mountAnimationFinished } = useMountAnimation(); @@ -52,9 +52,9 @@ export const useHandleDeepLinks = () => { appReady && val.type === "review" ) .ifJust((val) => { - pendignActionStore.send({ - type: "initFlow", - data: { + setPendingActionRequest( + Maybe.of({ + actionDto: Maybe.empty(), requestDto: val.pendingActionDto.requestDto, addresses: { address: val.pendingActionDto.address, @@ -64,14 +64,14 @@ export const useHandleDeepLinks = () => { integrationData: val.pendingActionDto.integrationData, interactedToken: val.balance.token, pendingActionType: val.pendingActionDto.requestDto.action, - }, - }); + }) + ); navigateRef.current( `positions/${val.yieldOp.id}/${val.balanceId}/pending-action/review` ); }); }, [ - pendignActionStore, + setPendingActionRequest, pendingActionDeepLinkCheck.data, appReady, navigateRef, diff --git a/packages/widget/src/pages-dashboard/activity/activity-details.page.tsx b/packages/widget/src/pages-dashboard/activity/activity-details.page.tsx index f8671356..d8cb240a 100644 --- a/packages/widget/src/pages-dashboard/activity/activity-details.page.tsx +++ b/packages/widget/src/pages-dashboard/activity/activity-details.page.tsx @@ -1,4 +1,3 @@ -import { useSelector } from "@xstate/store/react"; import { Box } from "../../components/atoms/box"; import { ActionStatus, type TransactionType } from "../../domain/types/action"; import { useActivityComplete } from "../../pages/complete/hooks/use-activity-complete.hook"; @@ -6,20 +5,14 @@ import { useComplete } from "../../pages/complete/hooks/use-complete.hook"; import { CompletePageComponent } from "../../pages/complete/pages/common.page"; import { CompleteCommonContextProvider } from "../../pages/complete/state"; import { ActionReviewPage } from "../../pages/review/pages/action-review.page"; -import { useActivityContext } from "../../providers/activity-provider"; +import { + useActivitySelectedAction, + useActivitySelectedYield, +} from "../../providers/activity-provider"; export const ActivityDetailsPage = () => { - const activityContext = useActivityContext(); - - const selectedAction = useSelector( - activityContext, - (state) => state.context.selectedAction - ).extractNullable(); - - const selectedYield = useSelector( - activityContext, - (state) => state.context.selectedYield - ).extractNullable(); + const selectedAction = useActivitySelectedAction().extractNullable(); + const selectedYield = useActivitySelectedYield().extractNullable(); if (!selectedYield || !selectedAction) { return null; diff --git a/packages/widget/src/pages-dashboard/activity/activity.page.tsx b/packages/widget/src/pages-dashboard/activity/activity.page.tsx index bf8e16ec..ed4818ff 100644 --- a/packages/widget/src/pages-dashboard/activity/activity.page.tsx +++ b/packages/widget/src/pages-dashboard/activity/activity.page.tsx @@ -1,5 +1,4 @@ import { useConnectModal } from "@stakekit/rainbowkit"; -import { useSelector } from "@xstate/store/react"; import { Maybe } from "purify-ts"; import { useEffect } from "react"; import { useTranslation } from "react-i18next"; @@ -17,7 +16,10 @@ import { } from "../../pages/details/activity-page/state/activity-page.context"; import type { ActionYieldDto } from "../../pages/details/activity-page/types"; import { FallbackContent } from "../../pages/details/positions-page/components/fallback-content"; -import { useActivityContext } from "../../providers/activity-provider"; +import { + useActivitySelectedAction, + useSetActivitySelection, +} from "../../providers/activity-provider"; import { useSKWallet } from "../../providers/sk-wallet"; import { container } from "./styles.css"; @@ -100,7 +102,7 @@ const ActivityPageComponent = () => { const _ActivityPage = () => { const value = useActivityPageContext(); - const activityStore = useActivityContext(); + const setActivitySelection = useSetActivitySelection(); const { openConnectModal } = useConnectModal(); const { isConnected, network } = useSKWallet(); @@ -111,14 +113,13 @@ const _ActivityPage = () => { data.actionData.status === ActionStatus.SUCCESS || data.actionData.status === ActionStatus.PROCESSING ) { - activityStore.send({ - type: "setSelectedAction", - data: Maybe.of({ + setActivitySelection( + Maybe.of({ selectedAction: data.actionData, selectedYield: data.yieldData, selectedValidators: data.validatorsData, - }), - }); + }) + ); } if ( @@ -126,40 +127,30 @@ const _ActivityPage = () => { data.actionData.status === ActionStatus.WAITING_FOR_NEXT || data.actionData.status === ActionStatus.FAILED ) { - activityStore.send({ - type: "setSelectedAction", - data: Maybe.of({ + setActivitySelection( + Maybe.of({ selectedAction: data.actionData, selectedYield: data.yieldData, selectedValidators: data.validatorsData, - }), - }); + }) + ); } return; }; - const selectedAction = useSelector( - activityStore, - (state) => state.context.selectedAction - ); + const selectedAction = useActivitySelectedAction(); // biome-ignore lint: false useEffect(() => { - activityStore.send({ - type: "setSelectedAction", - data: Maybe.empty(), - }); - }, [network, activityStore]); + setActivitySelection(Maybe.empty()); + }, [network, setActivitySelection]); useEffect(() => { if (!isConnected && selectedAction.isJust()) { - activityStore.send({ - type: "setSelectedAction", - data: Maybe.empty(), - }); + setActivitySelection(Maybe.empty()); } - }, [isConnected, selectedAction, activityStore]); + }, [isConnected, selectedAction, setActivitySelection]); return ( diff --git a/packages/widget/src/pages-dashboard/activity/index.tsx b/packages/widget/src/pages-dashboard/activity/index.tsx index 385f71a7..b74d4abb 100644 --- a/packages/widget/src/pages-dashboard/activity/index.tsx +++ b/packages/widget/src/pages-dashboard/activity/index.tsx @@ -1,10 +1,12 @@ -import { useSelector } from "@xstate/store/react"; import { Maybe } from "purify-ts"; import { Outlet, useNavigate } from "react-router"; import { Box } from "../../components/atoms/box"; import { CaretLeftIcon } from "../../components/atoms/icons/caret-left"; import { AnimationPage } from "../../navigation/containers/animation-page"; -import { useActivityContext } from "../../providers/activity-provider"; +import { + useActivitySelectedAction, + useSetActivitySelection, +} from "../../providers/activity-provider"; import { useSettings } from "../../providers/settings"; import { combineRecipeWithVariant } from "../../utils/styles"; import { ActivityPage } from "./activity.page"; @@ -13,17 +15,13 @@ import { activityDetailsContainer } from "./styles.css"; export const ActivityTabPage = () => { const { variant } = useSettings(); const navigate = useNavigate(); - const activityStore = useActivityContext(); - - const selectedAction = useSelector( - activityStore, - (state) => state.context.selectedAction - ); + const selectedAction = useActivitySelectedAction(); + const setActivitySelection = useSetActivitySelection(); const showDetails = selectedAction.isJust(); const onBack = () => { - activityStore.send({ type: "setSelectedAction", data: Maybe.empty() }); + setActivitySelection(Maybe.empty()); navigate("/activity"); }; diff --git a/packages/widget/src/pages-dashboard/position-details/components/position-details-stake-actions.tsx b/packages/widget/src/pages-dashboard/position-details/components/position-details-stake-actions.tsx index 61f3134c..43f5c074 100644 --- a/packages/widget/src/pages-dashboard/position-details/components/position-details-stake-actions.tsx +++ b/packages/widget/src/pages-dashboard/position-details/components/position-details-stake-actions.tsx @@ -1,18 +1,30 @@ -import { useEffect } from "react"; import { useTranslation } from "react-i18next"; import { Navigate } from "react-router"; import { Box } from "../../../components/atoms/box"; +import { selectTokenButton } from "../../../components/atoms/button/styles.css"; +import { ContentLoaderSquare } from "../../../components/atoms/content-loader"; +import { Dropdown } from "../../../components/atoms/dropdown"; +import { MaxButton } from "../../../components/atoms/max-button"; +import { NumberInput } from "../../../components/atoms/number-input"; import { Spinner } from "../../../components/atoms/spinner"; +import { TokenIcon } from "../../../components/atoms/token-icon"; import { Text } from "../../../components/atoms/typography/text"; +import * as AmountToggle from "../../../components/molecules/amount-toggle"; import { KycGateCard } from "../../../components/molecules/kyc-gate-card"; +import type { TronResourceType } from "../../../domain/types/tron"; +import { getYieldActionArg } from "../../../domain/types/yields"; import { useUnstakeOrPendingActionParams } from "../../../hooks/navigation/use-unstake-or-pending-action-params"; +import { MetaInfo } from "../../../pages/components/meta-info"; import { PageCtaButton } from "../../../pages/components/page-cta"; -import { ExtraArgsSelection } from "../../../pages/details/earn-page/components/extra-args-selection"; -import { Footer } from "../../../pages/details/earn-page/components/footer"; -import { SelectTokenSection } from "../../../pages/details/earn-page/components/select-token-section"; -import { useEarnPageContext } from "../../../pages/details/earn-page/state/earn-page-context"; -import { useEarnPageDispatch } from "../../../pages/details/earn-page/state/earn-page-state-context"; -import { usePositionDetails } from "../../../pages/position-details/hooks/use-position-details"; +import { + minMaxContainer, + priceTxt, + selectTokenBalance, + selectTokenSection, +} from "../../../pages/details/earn-page/components/select-token-section/styles.css"; +import { useSettings } from "../../../providers/settings"; +import { combineRecipeWithVariant } from "../../../utils/styles"; +import { usePositionDetailsStake } from "../hooks/use-position-details-stake"; import { PositionDetailsActionTabs } from "./position-details-action-tabs"; import { positionDetailsActionsHasContent, @@ -20,47 +32,270 @@ import { } from "./position-details-actions"; import { container } from "./styles.css"; -const StakeKycGateSection = () => { - const { kycGate, kycGateIsChecking, kycProviderName, onKycStatusRefresh } = - useEarnPageContext(); +type PositionDetailsStakeState = ReturnType; - if (kycGate.state === "pass" && !kycGateIsChecking) return null; +const StakeKycGateSection = ({ + stake, +}: { + stake: PositionDetailsStakeState; +}) => { + if (stake.kycGate.state === "pass" && !stake.kycGateIsChecking) return null; return ( ); }; -const PositionDetailsStakeStateInitializer = ({ - positionDetails, +const FixedToken = ({ stake }: { stake: PositionDetailsStakeState }) => { + const { variant } = useSettings(); + + return stake.selectedToken + .map((token) => { + return ( + + + {token.symbol} + + ); + }) + .extractNullable(); +}; + +const PositionDetailsStakeTokenSection = ({ + stake, }: { - positionDetails: ReturnType; + stake: PositionDetailsStakeState; }) => { - const dispatch = useEarnPageDispatch(); - const positionYield = positionDetails.integrationData.extractNullable(); + const { t } = useTranslation(); + const { variant } = useSettings(); - useEffect(() => { - if (!positionYield) { - return; - } + const isLoading = stake.appLoading; + const { + submitted, + errors: { + stakeAmountGreaterThanAvailableAmount, + stakeAmountGreaterThanMax, + stakeAmountLessThanMin, + stakeAmountIsZero, + }, + } = stake.validation; + const errorInput = + (submitted && stakeAmountIsZero) || + stakeAmountGreaterThanAvailableAmount || + stakeAmountGreaterThanMax || + stakeAmountLessThanMin; + const errorBalance = stakeAmountGreaterThanAvailableAmount; + const min = stake.stakeMinAmount + .map((value) => `${t("shared.min")} ${value} ${stake.symbol}`) + .extractNullable(); + const max = stake.stakeMaxAmount + .map((value) => `${t("shared.max")} ${value} ${stake.symbol}`) + .extractNullable(); + const minMax = min || max; - dispatch({ type: "positionDetails/stake/initialize", data: positionYield }); - }, [dispatch, positionYield]); + return isLoading ? ( + + + + ) : ( + + + + + + + + + + - return null; + {minMax ? ( + + + {min && max ? `${min} / ${max}` : (min ?? max)} + + + ) : null} + + + + + {stake.formattedPrice} + + + + + + + {stake.selectedTokenAvailableAmount + .map((value) => ( + + + {({ state }) => ( + + {state === "full" + ? value.fullFormattedAmount + : value.shortFormattedAmount} +  {value.symbol} {t("shared.available")} + + )} + + + )) + .extractNullable()} + + + + {!stake.isStakeTokenSameAsGasToken && ( + + )} + + + + ); +}; + +const PositionDetailsStakeFooter = ({ + stake, +}: { + stake: PositionDetailsStakeState; +}) => ( + +); + +const PositionDetailsStakeExtraArgs = ({ + stake, +}: { + stake: PositionDetailsStakeState; +}) => { + const { t } = useTranslation(); + + return stake.selectedStake + .chainNullable((selectedStake) => + getYieldActionArg(selectedStake, "enter", "tronResource") + ) + .map((tronResources) => { + const options = (tronResources.options ?? []).map((value) => ({ + label: value, + value: value as TronResourceType, + })); + const selectedOption = stake.tronResource + .map((value) => ({ value, label: value })) + .extract(); + const isError = + stake.validation.submitted && stake.validation.errors.tronResource; + + return ( + + + + {t("details.tron_resources.label")} + + + + stake.onTronResourceSelect(value)} + selectedOption={selectedOption} + placeholder={t("details.tron_resources.placeholder")} + isError={isError} + /> + + ); + }) + .extractNullable(); }; export const PositionDetailsStakeActions = () => { - const positionDetails = usePositionDetails(); + const stake = usePositionDetailsStake(); + const { positionDetails } = stake; const { plain } = useUnstakeOrPendingActionParams(); - const { cta } = useEarnPageContext(); const { t } = useTranslation(); if (positionDetails.isLoading) { @@ -117,20 +352,16 @@ export const PositionDetailsStakeActions = () => { canUnstake={canUnstake} /> - - - + -