From 8f6710c07b88718f979331fea22bd2be8e55082b Mon Sep 17 00:00:00 2001 From: Petar Todorovic Date: Sat, 27 Jun 2026 01:10:00 +0200 Subject: [PATCH 1/2] feat(widget): add effect atom earn flow --- package.json | 6 - packages/widget/package.json | 1 + packages/widget/src/Dashboard.tsx | 115 ++- .../widget/src/common/get-token-balances.ts | 68 -- packages/widget/src/domain/types/stake.ts | 3 - .../hooks/api/use-dashboard-yield-catalog.ts | 92 --- .../src/hooks/api/use-default-tokens.ts | 312 -------- .../widget/src/hooks/api/use-multi-yields.ts | 257 +------ .../src/hooks/api/use-token-balances-scan.ts | 14 - .../get-yield-opportunity.ts | 41 -- .../src/hooks/api/use-yield-summaries.ts | 50 +- .../position-details-stake-actions.tsx | 307 +++++++- .../hooks/use-position-details-stake.ts | 440 +++++++++++ .../position-details/state/stake-machine.ts | 88 +++ .../components/select-provider/index.tsx | 17 +- .../select-token-list-item.tsx | 6 +- .../src/pages/details/earn-page/earn.page.tsx | 9 +- .../earn-page/state/earn-page-context.tsx | 508 +++++++------ .../state/earn-page-state-context.tsx | 581 --------------- .../state/effect-atom-poc/catalog/atoms.ts | 691 ++++++++++++++++++ .../state/effect-atom-poc/catalog/keys.ts | 73 ++ .../effect-atom-poc/catalog/utilities.ts | 35 + .../effect-atom-poc/hooks/use-earn-machine.ts | 18 + .../state/effect-atom-poc/machine/actions.ts | 44 ++ .../state/effect-atom-poc/machine/atoms.ts | 30 + .../state/effect-atom-poc/machine/reducer.ts | 90 +++ .../effect-atom-poc/resolver/category.ts | 29 + .../state/effect-atom-poc/resolver/form.ts | 59 ++ .../effect-atom-poc/resolver/resolver.ts | 1 + .../state/effect-atom-poc/resolver/status.ts | 41 ++ .../state/effect-atom-poc/resolver/token.ts | 62 ++ .../effect-atom-poc/resolver/validators.ts | 32 + .../state/effect-atom-poc/resolver/view.ts | 334 +++++++++ .../state/effect-atom-poc/resolver/yield.ts | 134 ++++ .../state/effect-atom-poc/runtime/index.ts | 29 + .../earn-page/state/effect-atom-poc/types.ts | 176 +++++ .../pages/details/earn-page/state/types.ts | 99 +-- .../earn-page/state/use-get-init-yield.ts | 37 - .../state/use-get-token-balances-map.ts | 22 - .../details/earn-page/state/use-init-token.ts | 106 --- .../details/earn-page/state/use-init-yield.ts | 105 --- .../state/use-stake-enter-request-dto.ts | 31 +- .../earn-page/state/use-token-balance.ts | 43 -- .../earn-page/state/use-token-balances-map.ts | 20 - .../earn-page/state/use-track-state-events.ts | 45 -- .../pages/details/earn-page/state/utils.ts | 32 - .../widget/src/providers/api/api-client.ts | 26 +- .../providers/effect-atom-runtime/index.tsx | 18 + .../stakekit-api-service.ts | 18 + packages/widget/src/providers/index.tsx | 42 +- .../src/providers/misc/tron-connector.ts | 2 +- .../widget/tests/hooks/default-tokens.test.ts | 182 ----- pnpm-lock.yaml | 413 +++++------ pnpm-workspace.yaml | 11 +- 54 files changed, 3338 insertions(+), 2707 deletions(-) delete mode 100644 packages/widget/src/common/get-token-balances.ts delete mode 100644 packages/widget/src/hooks/api/use-dashboard-yield-catalog.ts delete mode 100644 packages/widget/src/hooks/api/use-default-tokens.ts create mode 100644 packages/widget/src/pages-dashboard/position-details/hooks/use-position-details-stake.ts create mode 100644 packages/widget/src/pages-dashboard/position-details/state/stake-machine.ts delete mode 100644 packages/widget/src/pages/details/earn-page/state/earn-page-state-context.tsx create mode 100644 packages/widget/src/pages/details/earn-page/state/effect-atom-poc/catalog/atoms.ts create mode 100644 packages/widget/src/pages/details/earn-page/state/effect-atom-poc/catalog/keys.ts create mode 100644 packages/widget/src/pages/details/earn-page/state/effect-atom-poc/catalog/utilities.ts create mode 100644 packages/widget/src/pages/details/earn-page/state/effect-atom-poc/hooks/use-earn-machine.ts create mode 100644 packages/widget/src/pages/details/earn-page/state/effect-atom-poc/machine/actions.ts create mode 100644 packages/widget/src/pages/details/earn-page/state/effect-atom-poc/machine/atoms.ts create mode 100644 packages/widget/src/pages/details/earn-page/state/effect-atom-poc/machine/reducer.ts create mode 100644 packages/widget/src/pages/details/earn-page/state/effect-atom-poc/resolver/category.ts create mode 100644 packages/widget/src/pages/details/earn-page/state/effect-atom-poc/resolver/form.ts create mode 100644 packages/widget/src/pages/details/earn-page/state/effect-atom-poc/resolver/resolver.ts create mode 100644 packages/widget/src/pages/details/earn-page/state/effect-atom-poc/resolver/status.ts create mode 100644 packages/widget/src/pages/details/earn-page/state/effect-atom-poc/resolver/token.ts create mode 100644 packages/widget/src/pages/details/earn-page/state/effect-atom-poc/resolver/validators.ts create mode 100644 packages/widget/src/pages/details/earn-page/state/effect-atom-poc/resolver/view.ts create mode 100644 packages/widget/src/pages/details/earn-page/state/effect-atom-poc/resolver/yield.ts create mode 100644 packages/widget/src/pages/details/earn-page/state/effect-atom-poc/runtime/index.ts create mode 100644 packages/widget/src/pages/details/earn-page/state/effect-atom-poc/types.ts delete mode 100644 packages/widget/src/pages/details/earn-page/state/use-get-init-yield.ts delete mode 100644 packages/widget/src/pages/details/earn-page/state/use-get-token-balances-map.ts delete mode 100644 packages/widget/src/pages/details/earn-page/state/use-init-token.ts delete mode 100644 packages/widget/src/pages/details/earn-page/state/use-init-yield.ts delete mode 100644 packages/widget/src/pages/details/earn-page/state/use-token-balance.ts delete mode 100644 packages/widget/src/pages/details/earn-page/state/use-token-balances-map.ts delete mode 100644 packages/widget/src/pages/details/earn-page/state/use-track-state-events.ts delete mode 100644 packages/widget/src/pages/details/earn-page/state/utils.ts create mode 100644 packages/widget/src/providers/effect-atom-runtime/index.tsx create mode 100644 packages/widget/src/providers/effect-atom-runtime/stakekit-api-service.ts delete mode 100644 packages/widget/tests/hooks/default-tokens.test.ts 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..ceb4170d 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:", 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/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} /> - - - + -