From 20b6d4b1933ad5c30e045aea12708d1e9f901c27 Mon Sep 17 00:00:00 2001 From: Petar Todorovic Date: Tue, 9 Jun 2026 09:21:00 +0200 Subject: [PATCH] feat(widget): scope dashboard yield discovery by category Implement category-scoped-yield-discovery for dashboard earn. Replace the all-yield union load with network-scoped category probes. Add yield summary fetching and category-to-API-type helpers. Keep token switching, yield counts, and max rates scoped to the active category. Add focused tests for mappings, summary visibility, pagination, and token filtering. --- packages/widget/src/domain/types/yields.ts | 51 +++-- .../hooks/api/use-dashboard-yield-catalog.ts | 88 +++++++ .../src/hooks/api/use-token-list-yields.ts | 108 +++++++-- .../get-yield-opportunity.ts | 13 +- .../src/hooks/api/use-yield-summaries.ts | 152 ++++++++++++ .../select-token-list-item.tsx | 16 +- .../select-token-section/select-token.tsx | 92 ++++++-- .../earn-page/state/earn-page-context.tsx | 144 +++++++++--- .../state/earn-page-state-context.tsx | 27 ++- .../pages/details/earn-page/state/types.ts | 9 + .../dashboard-yield-category-types.test.ts | 52 +++++ .../tests/domain/token-list-yields.test.ts | 176 +++++++++++++- .../tests/domain/yield-summaries.test.ts | 216 ++++++++++++++++++ 13 files changed, 1041 insertions(+), 103 deletions(-) create mode 100644 packages/widget/src/hooks/api/use-dashboard-yield-catalog.ts create mode 100644 packages/widget/src/hooks/api/use-yield-summaries.ts create mode 100644 packages/widget/tests/domain/dashboard-yield-category-types.test.ts create mode 100644 packages/widget/tests/domain/yield-summaries.test.ts diff --git a/packages/widget/src/domain/types/yields.ts b/packages/widget/src/domain/types/yields.ts index 88af400b..96d55516 100644 --- a/packages/widget/src/domain/types/yields.ts +++ b/packages/widget/src/domain/types/yields.ts @@ -75,6 +75,40 @@ export const dashboardYieldCategories = [ "rwa", ] as const satisfies ReadonlyArray; +/** + * Maps every API `YieldType` to exactly one dashboard category. The + * `satisfies Record` guarantees exhaustiveness: a new server + * yield type fails the build here until it is assigned a category. This mirrors + * `getDashboardYieldCategory` (which classifies hydrated yields) but is keyed by + * the API `type` so it can drive `types[]` query filters. + */ +const apiYieldTypeToDashboardCategory = { + staking: "stake", + restaking: "stake", + lending: "defi", + vault: "defi", + fixed_yield: "defi", + concentrated_liquidity_pool: "defi", + liquidity_pool: "defi", + real_world_asset: "rwa", +} as const satisfies Record; + +export const getApiYieldTypesForDashboardCategory = ( + category: DashboardYieldCategory +): ApiYieldType[] => + ( + Object.entries(apiYieldTypeToDashboardCategory) as [ + ApiYieldType, + DashboardYieldCategory, + ][] + ) + .filter(([, mapped]) => mapped === category) + .map(([yieldType]) => yieldType); + +export const getDashboardYieldCategoryForApiYieldType = ( + yieldType: ApiYieldType +): DashboardYieldCategory => apiYieldTypeToDashboardCategory[yieldType]; + export const getDashboardYieldCategory = ( yieldDto: Yield ): DashboardYieldCategory | null => { @@ -91,23 +125,6 @@ export const getDashboardYieldCategory = ( return null; }; -export const getAvailableDashboardYieldCategories = ( - yields: Iterable -): DashboardYieldCategory[] => { - const categories = new Set(); - - for (const yieldDto of yields) { - if (!isNonZeroRewardRateYield(yieldDto)) continue; - - const category = getDashboardYieldCategory(yieldDto); - if (category) categories.add(category); - } - - return dashboardYieldCategories.filter((category) => - categories.has(category) - ); -}; - export const filterValidators = ({ validatorsConfig, validators, diff --git a/packages/widget/src/hooks/api/use-dashboard-yield-catalog.ts b/packages/widget/src/hooks/api/use-dashboard-yield-catalog.ts new file mode 100644 index 00000000..451c140d --- /dev/null +++ b/packages/widget/src/hooks/api/use-dashboard-yield-catalog.ts @@ -0,0 +1,88 @@ +import { useQueries } from "@tanstack/react-query"; +import { useMemo } from "react"; +import type { TokenDto } from "../../domain/types/tokens"; +import { + type DashboardYieldCategory, + dashboardYieldCategories, + getApiYieldTypesForDashboardCategory, +} from "../../domain/types/yields"; +import { useApiClient } from "../../providers/api/api-client-provider"; +import { useSKWallet } from "../../providers/sk-wallet"; +import { + DEFAULT_YIELD_SUMMARIES_PAGE_LIMIT, + fetchYieldSummariesPage, + getYieldSummariesQueryKey, + isVisibleYieldSummary, + type YieldSummariesParams, +} from "./use-yield-summaries"; + +type DashboardCategoryInitialSelection = { + token: TokenDto; + 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 } = useSKWallet(); + const apiClient = useApiClient(); + + const probeEnabled = enabled && !!network; + + const results = useQueries({ + queries: dashboardYieldCategories.map((category) => { + const params: YieldSummariesParams = { + network: network ?? undefined, + 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[] = []; + + dashboardYieldCategories.forEach((category, index) => { + const firstVisible = (results[index]?.data ?? []).find( + isVisibleYieldSummary + ); + + if (!firstVisible) return; + + availableCategories.push(category); + initialSelectionByCategory.set(category, { + token: firstVisible.token, + yieldId: firstVisible.id, + }); + }); + + return { + availableCategories, + initialSelectionByCategory, + isLoading: probeEnabled && results.some((result) => result.isLoading), + }; + }, [results, probeEnabled]); +}; diff --git a/packages/widget/src/hooks/api/use-token-list-yields.ts b/packages/widget/src/hooks/api/use-token-list-yields.ts index 852ad6d4..f88468da 100644 --- a/packages/widget/src/hooks/api/use-token-list-yields.ts +++ b/packages/widget/src/hooks/api/use-token-list-yields.ts @@ -2,12 +2,20 @@ import { useQuery } from "@tanstack/react-query"; import { useMemo } from "react"; import type { TokenBalanceScanResponseDto } from "../../domain/types/token-balance"; import { tokenString } from "../../domain/types/tokens"; +import { + type DashboardYieldCategory, + getDashboardYieldCategoryForApiYieldType, +} from "../../domain/types/yields"; import type { YieldDto } from "../../generated/api/yield"; import { useApiClient } from "../../providers/api/api-client-provider"; import { getRewardRateFormatted, getRewardTypeFormatted, } from "../../utils/formatters"; +import { + fetchYieldSummariesByIds, + isVisibleYieldSummary, +} from "./use-yield-summaries"; const staleTime = 1000 * 60 * 2; @@ -20,6 +28,44 @@ const getUniqueYieldIds = ( tokenBalances: ReadonlyArray ) => [...new Set(tokenBalances.flatMap((tb) => tb.availableYields))]; +export const fetchTokenListYieldSummaries = ({ + apiClient, + signal, + tokenBalances, +}: { + apiClient: ReturnType; + signal?: AbortSignal; + tokenBalances: ReadonlyArray; +}) => + fetchYieldSummariesByIds({ + apiClient, + signal, + yieldIds: getUniqueYieldIds(tokenBalances), + }); + +export const getDashboardCategoryYieldIdsForToken = ( + availableYieldIds: ReadonlyArray, + yieldsById: ReadonlyMap, + category: DashboardYieldCategory +) => + availableYieldIds + .filter((id) => { + const yieldDto = yieldsById.get(id); + + return ( + !!yieldDto && + isVisibleYieldSummary(yieldDto) && + getDashboardYieldCategoryForApiYieldType(yieldDto.mechanics.type) === + category + ); + }) + .sort((a, b) => { + const left = yieldsById.get(a)?.rewardRate?.total ?? 0; + const right = yieldsById.get(b)?.rewardRate?.total ?? 0; + + return right - left; + }); + export const getMaxYieldRateForToken = ( availableYieldIds: ReadonlyArray, yieldsById: ReadonlyMap @@ -56,7 +102,8 @@ export const getMaxYieldRateForToken = ( }; export const useTokenListYields = ( - tokenBalances: ReadonlyArray + tokenBalances: ReadonlyArray, + dashboardYieldCategory?: DashboardYieldCategory | null ) => { const apiClient = useApiClient(); const yieldIds = getUniqueYieldIds(tokenBalances); @@ -65,21 +112,48 @@ export const useTokenListYields = ( queryKey: ["token-list-yields", yieldIds], enabled: yieldIds.length > 0, staleTime, - queryFn: async ({ signal }) => { - const client = apiClient.withOptions({ signal }); - const result = await client.yield.YieldsControllerGetYields({ - params: { - yieldIds, - limit: yieldIds.length, - }, - }); - - return result.items ?? []; - }, + queryFn: async ({ signal }) => + fetchTokenListYieldSummaries({ + apiClient, + signal, + tokenBalances, + }), }); - const yieldsById = new Map( - (query.data ?? []).map((yieldDto) => [yieldDto.id, yieldDto]) + const yieldsById = useMemo( + () => + new Map((query.data ?? []).map((yieldDto) => [yieldDto.id, yieldDto])), + [query.data] + ); + + const yieldIdsByToken = useMemo(() => { + const map = new Map(); + + for (const tokenBalance of tokenBalances) { + map.set( + tokenString(tokenBalance.token), + dashboardYieldCategory + ? getDashboardCategoryYieldIdsForToken( + tokenBalance.availableYields, + yieldsById, + dashboardYieldCategory + ) + : [...tokenBalance.availableYields] + ); + } + + return map; + }, [dashboardYieldCategory, tokenBalances, yieldsById]); + + const yieldCountsByToken = useMemo( + () => + new Map( + [...yieldIdsByToken.entries()].map(([token, tokenYieldIds]) => [ + token, + tokenYieldIds.length, + ]) + ), + [yieldIdsByToken] ); const maxYieldRatesByToken = useMemo(() => { @@ -87,7 +161,7 @@ export const useTokenListYields = ( for (const tokenBalance of tokenBalances) { const maxYieldRate = getMaxYieldRateForToken( - tokenBalance.availableYields, + yieldIdsByToken.get(tokenString(tokenBalance.token)) ?? [], yieldsById ); @@ -97,9 +171,11 @@ export const useTokenListYields = ( } return map; - }, [tokenBalances, yieldsById]); + }, [tokenBalances, yieldIdsByToken, yieldsById]); return { + yieldIdsByToken, + yieldCountsByToken, maxYieldRatesByToken, isLoading: query.isLoading, }; 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 9cc195ee..0a7aba70 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 @@ -6,6 +6,7 @@ import { type YieldProviderDetails, } from "../../../domain/types/yields"; import type { ApiClient } from "../../../providers/api/api-client"; +import { fetchYieldSummariesByIds } from "../use-yield-summaries"; type Params = { yieldId: string; @@ -219,14 +220,14 @@ const multiFn = ({ }) => { return EitherAsync(async () => { const client = apiClient.withOptions({ signal, suppressRichErrors }); - const newYieldsResult = await client.yield.YieldsControllerGetYields({ - params: { - yieldIds, - limit: yieldIds.length, - }, + const newYields = await fetchYieldSummariesByIds({ + apiClient, + signal, + suppressRichErrors, + yieldIds, }); const newYieldsById = new Map( - (newYieldsResult.items ?? []).map((yieldDto) => [yieldDto.id, yieldDto]) + newYields.map((yieldDto) => [yieldDto.id, yieldDto]) ); const providersById = await fetchYieldProviders({ client, diff --git a/packages/widget/src/hooks/api/use-yield-summaries.ts b/packages/widget/src/hooks/api/use-yield-summaries.ts new file mode 100644 index 00000000..9c6b8a2b --- /dev/null +++ b/packages/widget/src/hooks/api/use-yield-summaries.ts @@ -0,0 +1,152 @@ +import { isSupportedChain } from "../../domain/types/chains"; +import { isNonZeroRewardRateYield } from "../../domain/types/yields"; +import type { + YieldDto, + YieldsControllerGetYieldsParams, +} from "../../generated/api/yield"; +import type { useApiClient } from "../../providers/api/api-client-provider"; + +/** + * A yield "summary" is the new yields API DTO, returned by + * `YieldsControllerGetYields`. It contains everything category discovery and + * list rendering need (`token`, `rewardRate`, `status`, `metadata`, + * `mechanics.type`, `providerId`) and intentionally does NOT include the legacy + * `__fallback__` hydration, which is only required for a selected yield. + */ +export type YieldSummary = YieldDto; + +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). + */ +export const isVisibleYieldSummary = (summary: YieldSummary): boolean => + summary.status.enter && + 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; + signal?: AbortSignal; + suppressRichErrors?: boolean; + yieldIds: ReadonlyArray; +}; + +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 + * requested ID. + */ +export const fetchYieldSummariesByIds = async ({ + apiClient, + chunkSize = DEFAULT_YIELD_IDS_CHUNK_SIZE, + signal, + suppressRichErrors, + yieldIds, +}: FetchByIdsArgs): Promise => { + const ids = unique(yieldIds); + + if (ids.length === 0) { + return []; + } + + const client = apiClient.withOptions({ signal, suppressRichErrors }); + const normalizedChunkSize = Math.max(1, chunkSize); + const summariesById = new Map(); + + for (const chunk of chunks(ids, normalizedChunkSize)) { + const result = await client.yield.YieldsControllerGetYields({ + params: { + yieldIds: chunk, + limit: chunk.length, + }, + }); + + for (const summary of result.items ?? []) { + summariesById.set(summary.id, summary); + } + } + + return ids.flatMap((id) => { + const summary = summariesById.get(id); + + return summary ? [summary] : []; + }); +}; + +/** + * Fetch every page of yield summaries for the given params, looping `offset` + * until all `total` items have been retrieved. + */ +export const fetchAllYieldSummaries = async ({ + apiClient, + params, + signal, +}: FetchArgs): Promise => { + const client = apiClient.withOptions({ signal }); + const limit = params.limit ?? DEFAULT_YIELD_SUMMARIES_PAGE_LIMIT; + + const all: YieldSummary[] = []; + let offset = 0; + + while (true) { + const result = await client.yield.YieldsControllerGetYields({ + params: { ...params, limit, offset }, + }); + + const items = result.items ?? []; + all.push(...items); + + if (items.length === 0 || all.length >= result.total) { + break; + } + + offset += items.length; + } + + return all; +}; diff --git a/packages/widget/src/pages/details/earn-page/components/select-token-section/select-token-list-item.tsx b/packages/widget/src/pages/details/earn-page/components/select-token-section/select-token-list-item.tsx index 30e3fa7e..5fe1c854 100644 --- a/packages/widget/src/pages/details/earn-page/components/select-token-section/select-token-list-item.tsx +++ b/packages/widget/src/pages/details/earn-page/components/select-token-section/select-token-list-item.tsx @@ -19,6 +19,8 @@ type Props = { item: TokenBalanceScanResponseDto; isConnected: boolean; isSelected: boolean; + availableYieldsCount?: number; + canSelectToken?: boolean; maxYieldRate?: TokenMaxYieldRate; onTokenBalanceSelect: (tokenBalance: TokenBalanceScanResponseDto) => void; }; @@ -28,6 +30,8 @@ export const SelectTokenListItem = memo( item, isConnected, isSelected, + availableYieldsCount = item.availableYields.length, + canSelectToken = true, maxYieldRate, onTokenBalanceSelect, }: Props) => { @@ -38,6 +42,8 @@ export const SelectTokenListItem = memo( const _onItemClick: ComponentProps["onItemClick"] = ({ closeModal }) => { + if (!canSelectToken) return; + trackEvent("tokenSelected", { token: item.token.symbol }); onTokenBalanceSelect(item); closeModal(); @@ -48,9 +54,11 @@ export const SelectTokenListItem = memo( @@ -74,7 +82,7 @@ export const SelectTokenListItem = memo( variant={{ type: "muted", weight: "normal", size: "small" }} > {t("select_token.yields_available", { - count: item.availableYields.length, + count: availableYieldsCount, token_name: item.token.name, })} diff --git a/packages/widget/src/pages/details/earn-page/components/select-token-section/select-token.tsx b/packages/widget/src/pages/details/earn-page/components/select-token-section/select-token.tsx index 780f6ce5..ebfe38ea 100644 --- a/packages/widget/src/pages/details/earn-page/components/select-token-section/select-token.tsx +++ b/packages/widget/src/pages/details/earn-page/components/select-token-section/select-token.tsx @@ -12,8 +12,8 @@ import { SelectModal } from "../../../../../components/atoms/select-modal"; import { TokenIcon } from "../../../../../components/atoms/token-icon"; import { Text } from "../../../../../components/atoms/typography/text"; import { VirtualList } from "../../../../../components/atoms/virtual-list"; +import type { TokenBalanceScanResponseDto } from "../../../../../domain/types/token-balance"; import { equalTokens, tokenString } from "../../../../../domain/types/tokens"; -import { useTokenListYields } from "../../../../../hooks/api/use-token-list-yields"; import { useTrackEvent } from "../../../../../hooks/tracking/use-track-event"; import { useSettings } from "../../../../../providers/settings"; import { useSKWallet } from "../../../../../providers/sk-wallet"; @@ -22,6 +22,24 @@ import { useEarnPageContext } from "../../state/earn-page-context"; import { validatorVirtuosoContainer } from "../../styles.css"; import { SelectTokenListItem } from "./select-token-list-item"; +const getAvailableYieldsCount = ({ + item, + selectedDashboardYieldCategory, + tokenListYieldsIsLoading, + tokenYieldCountsByToken, +}: { + item: TokenBalanceScanResponseDto; + selectedDashboardYieldCategory: unknown; + tokenListYieldsIsLoading: boolean; + tokenYieldCountsByToken: ReadonlyMap; +}) => { + if (!selectedDashboardYieldCategory || tokenListYieldsIsLoading) { + return item.availableYields.length; + } + + return tokenYieldCountsByToken.get(tokenString(item.token)) ?? 0; +}; + export const SelectToken = ({ canSelect = true }: { canSelect?: boolean }) => { const { onSelectTokenClose, @@ -30,6 +48,10 @@ export const SelectToken = ({ canSelect = true }: { canSelect?: boolean }) => { selectedToken, onTokenSearch, tokenSearch, + selectedDashboardYieldCategory, + tokenMaxYieldRatesByToken, + tokenYieldCountsByToken, + tokenListYieldsIsLoading, } = useEarnPageContext(); const { variant } = useSettings(); @@ -43,17 +65,31 @@ export const SelectToken = ({ canSelect = true }: { canSelect?: boolean }) => { const data = useMemo( () => selectedToken - .map((st) => ({ - st, - tokenBalances: - tokenBalancesData.map((v) => v.filtered).extract() ?? [], - })) - .extractNullable(), - [selectedToken, tokenBalancesData] - ); + .map((st) => { + const tokenBalances = + tokenBalancesData.map((v) => v.filtered).extract() ?? []; - const { maxYieldRatesByToken } = useTokenListYields( - data?.tokenBalances ?? [] + return { + st, + tokenBalances: tokenBalances.filter( + (item) => + getAvailableYieldsCount({ + item, + selectedDashboardYieldCategory, + tokenListYieldsIsLoading, + tokenYieldCountsByToken, + }) > 0 + ), + }; + }) + .extractNullable(), + [ + selectedDashboardYieldCategory, + selectedToken, + tokenBalancesData, + tokenListYieldsIsLoading, + tokenYieldCountsByToken, + ] ); if (!data) return null; @@ -87,6 +123,7 @@ export const SelectToken = ({ canSelect = true }: { canSelect?: boolean }) => { searchValue={tokenSearch} onClose={onSelectTokenClose} onOpen={() => trackEvent("selectTokenModalOpened")} + isLoading={!!selectedDashboardYieldCategory && tokenListYieldsIsLoading} trigger={ { className={validatorVirtuosoContainer} data={data.tokenBalances} estimateSize={() => 60} - itemContent={(_index, item) => ( - - )} + itemContent={(_index, item) => { + const tokenKey = tokenString(item.token); + const availableYieldsCount = getAvailableYieldsCount({ + item, + selectedDashboardYieldCategory, + tokenListYieldsIsLoading, + tokenYieldCountsByToken, + }); + const canSelectToken = + !selectedDashboardYieldCategory || + (!tokenListYieldsIsLoading && availableYieldsCount > 0); + + return ( + + ); + }} /> ); 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 702928a2..34ae15ff 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 @@ -28,7 +28,6 @@ import type { TronResourceType } from "../../../../domain/types/tron"; import { type DashboardYieldCategory, type ExtendedYieldType, - getAvailableDashboardYieldCategories, getDashboardYieldCategory, getExtendedYieldType, getYieldRewardTokens, @@ -41,12 +40,15 @@ import { type Yield, } from "../../../../domain/types/yields"; import type { ValidatorDto } from "../../../../generated/api/yield"; +import { useDashboardYieldCatalog } from "../../../../hooks/api/use-dashboard-yield-catalog"; import { useDefaultTokens } from "../../../../hooks/api/use-default-tokens"; import { useStreamMultiYields } from "../../../../hooks/api/use-multi-yields"; import { useTokenBalancesScan } from "../../../../hooks/api/use-token-balances-scan"; +import { useTokenListYields } from "../../../../hooks/api/use-token-list-yields"; import { useTokensPrices } from "../../../../hooks/api/use-tokens-prices"; import { useYieldKycGate } from "../../../../hooks/api/use-yield-kyc-gate"; import { useYieldOpportunity } from "../../../../hooks/api/use-yield-opportunity"; +import { getYieldOpportunity } from "../../../../hooks/api/use-yield-opportunity/get-yield-opportunity"; import { useYieldValidators } from "../../../../hooks/api/use-yield-validators"; import { useNavigateWithScrollToTop } from "../../../../hooks/navigation/use-navigate-with-scroll-to-top"; import { @@ -64,8 +66,10 @@ import { useProvidersDetails } from "../../../../hooks/use-provider-details"; import { useRewardTokenDetails } from "../../../../hooks/use-reward-token-details"; import { useSavedRef } from "../../../../hooks/use-saved-ref"; import { useYieldType } from "../../../../hooks/use-yield-type"; +import { useApiClient } from "../../../../providers/api/api-client-provider"; import { useEnterStakeStore } from "../../../../providers/enter-stake-store"; import { useMountAnimation } from "../../../../providers/mount-animation"; +import { useSKQueryClient } from "../../../../providers/query-client"; import { useSettings } from "../../../../providers/settings"; import { useSKWallet } from "../../../../providers/sk-wallet"; import { useWagmiConfig } from "../../../../providers/wagmi"; @@ -113,8 +117,16 @@ export const EarnPageContextProvider = ({ const { dashboardVariant, externalProviders, variant } = useSettings(); - const { isConnected, isConnecting, isLedgerLiveAccountPlaceholder, chain } = - useSKWallet(); + const { + isConnected, + isConnecting, + isLedgerLiveAccountPlaceholder, + isLedgerLive, + chain, + } = useSKWallet(); + + const apiClient = useApiClient(); + const queryClient = useSKQueryClient(); const yieldType = useYieldType(selectedStake).mapOrDefault( (y) => y.title, @@ -255,29 +267,48 @@ export const EarnPageContextProvider = ({ [defaultTokens.data, deferredTokenSearch, tokenBalancesScan.data] ); - const dashboardYieldIds = useMemo( - () => - tokenBalancesData - .map((data) => [ - ...new Set(data.all.flatMap((token) => token.availableYields)), - ]) - .orDefault([]), + const dashboardYieldCatalog = useDashboardYieldCatalog({ + enabled: dashboardVariant, + }); + + const availableDashboardYieldCategories = + dashboardYieldCatalog.availableCategories; + + const selectedDashboardYieldCategory = selectedStake + .chainNullable(getDashboardYieldCategory) + .extractNullable(); + + const tokenBalancesForYieldMetadata = useMemo( + () => tokenBalancesData.map((v) => v.filtered).orDefault([]), [tokenBalancesData] ); - const dashboardYields = useStreamMultiYields(dashboardYieldIds); + const tokenListYields = useTokenListYields( + tokenBalancesForYieldMetadata, + dashboardVariant ? selectedDashboardYieldCategory : null + ); - const availableDashboardYieldCategories = useMemo( - () => - dashboardYields.length > 0 - ? getAvailableDashboardYieldCategories(dashboardYields) - : [], - [dashboardYields] + const dashboardSelectionByCategoryRef = useRef( + new Map< + DashboardYieldCategory, + { token: TokenBalanceScanResponseDto["token"]; yieldId: Yield["id"] } + >() ); - const selectedDashboardYieldCategory = selectedStake - .chainNullable(getDashboardYieldCategory) - .extractNullable(); + useEffect(() => { + if (!dashboardVariant) return; + + const token = selectedToken.extractNullable(); + const yieldDto = selectedStake.extractNullable(); + const category = yieldDto ? getDashboardYieldCategory(yieldDto) : null; + + if (!token || !yieldDto || !category) return; + + dashboardSelectionByCategoryRef.current.set(category, { + token, + yieldId: yieldDto.id, + }); + }, [dashboardVariant, selectedStake, selectedToken]); const selectedStakeData = useMemo>( () => @@ -469,10 +500,56 @@ export const EarnPageContextProvider = ({ const onValidatorSearch: SelectModalProps["onSearch"] = (val) => setValidatorSearch(val); + const selectDashboardTokenYield = useCallback( + ({ + token, + yieldId, + }: { + token: TokenBalanceScanResponseDto["token"]; + yieldId: Yield["id"]; + }) => { + getYieldOpportunity({ + yieldId, + isLedgerLive, + apiClient, + queryClient, + }) + .map((yieldDto) => { + dispatch({ + type: "dashboard/token-yield/select", + data: { token, yieldDto }, + }); + return yieldDto; + }) + .run(); + }, + [apiClient, dispatch, isLedgerLive, queryClient] + ); + const onTokenBalanceSelect = useCallback( - (tokenBalance: TokenBalanceScanResponseDto) => - dispatch({ type: "token/select", data: tokenBalance.token }), - [dispatch] + (tokenBalance: TokenBalanceScanResponseDto) => { + const category = dashboardVariant ? selectedDashboardYieldCategory : null; + + if (!category) { + dispatch({ type: "token/select", data: tokenBalance.token }); + return; + } + + const yieldId = tokenListYields.yieldIdsByToken.get( + tokenString(tokenBalance.token) + )?.[0]; + + if (!yieldId) return; + + selectDashboardTokenYield({ token: tokenBalance.token, yieldId }); + }, + [ + dashboardVariant, + dispatch, + selectedDashboardYieldCategory, + selectDashboardTokenYield, + tokenListYields.yieldIdsByToken, + ] ); const onYieldSelect = (yieldId: string) => { @@ -484,21 +561,13 @@ export const EarnPageContextProvider = ({ const onDashboardYieldCategorySelect = (category: DashboardYieldCategory) => { if (selectedDashboardYieldCategory === category) return; - const availableYieldsById = new Map( - [ - ...selectedStakeData.map((data) => data.all).orDefault([]), - ...dashboardYields, - ].map((yieldDto) => [yieldDto.id, yieldDto]) - ); - - const targetYield = [...availableYieldsById.values()] - .filter((yieldDto) => getDashboardYieldCategory(yieldDto) === category) - .sort((a, b) => b.rewardRate.total - a.rewardRate.total)[0]; + const target = + dashboardSelectionByCategoryRef.current.get(category) ?? + dashboardYieldCatalog.initialSelectionByCategory.get(category); - if (!targetYield) return; + if (!target) return; - dispatch({ type: "token/select", data: targetYield.token }); - dispatch({ type: "yield/select", data: targetYield }); + selectDashboardTokenYield(target); }; const onValidatorSelect = (item: ValidatorDto) => @@ -831,6 +900,9 @@ export const EarnPageContextProvider = ({ yieldOpportunityLoading, tokenBalancesScanLoading, tokenBalancesData, + tokenMaxYieldRatesByToken: tokenListYields.maxYieldRatesByToken, + tokenYieldCountsByToken: tokenListYields.yieldCountsByToken, + tokenListYieldsIsLoading: tokenListYields.isLoading, onTokenSearch, onValidatorSearch, buttonCTAText, 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 850ecd2b..2da1ed08 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 @@ -89,6 +89,29 @@ export const EarnPageStateProvider = ({ children }: PropsWithChildren) => { .orDefault(state); } + case "dashboard/token-yield/select": { + return Maybe.fromFalsy( + state.selectedToken + .map((v) => !equalTokens(v, action.data.token)) + .orDefault(true) || + state.selectedStakeId + .map((v) => v !== action.data.yieldDto.id) + .orDefault(true) + ) + .map(() => + onYieldSelectState({ + yieldDto: action.data.yieldDto, + positionsData: positionsData.data, + }) + ) + .map((val) => ({ + ...getInitialState(), + selectedToken: Maybe.of(action.data.token), + ...val, + })) + .orDefault(state); + } + case "yield/select": { return Maybe.fromFalsy( state.selectedStakeId.map((v) => v !== action.data.id).orDefault(true) @@ -298,8 +321,8 @@ export const EarnPageStateProvider = ({ children }: PropsWithChildren) => { useEffect(() => { if (shouldWaitForPositionsData) return; - initYield.ifJust(setYield); - }, [initYield, shouldWaitForPositionsData, setYield]); + selectedStakeId.ifNothing(() => initYield.ifJust(setYield)); + }, [initYield, selectedStakeId, shouldWaitForPositionsData, setYield]); useEffect(() => { if (shouldWaitForPositionsData) return; 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 1c475343..71598e37 100644 --- a/packages/widget/src/pages/details/earn-page/state/types.ts +++ b/packages/widget/src/pages/details/earn-page/state/types.ts @@ -9,6 +9,7 @@ import type { Yield, } from "../../../../domain/types/yields"; import type { ValidatorDto } from "../../../../generated/api/yield"; +import type { TokenMaxYieldRate } from "../../../../hooks/api/use-token-list-yields"; import type { useEstimatedRewards } from "../../../../hooks/use-estimated-rewards"; import type { useProvidersDetails } from "../../../../hooks/use-provider-details"; import type { useRewardTokenDetails } from "../../../../hooks/use-reward-token-details"; @@ -28,6 +29,10 @@ export type State = { }; type TokenBalanceSelectAction = Action<"token/select", TokenDto>; +type DashboardTokenYieldSelectAction = Action< + "dashboard/token-yield/select", + { token: TokenDto; yieldDto: Yield } +>; type YieldSelectAction = Action<"yield/select", Yield>; type StakeAmountChangeAction = Action<"stakeAmount/change", BigNumber>; @@ -47,6 +52,7 @@ type ProviderYieldIdSelectAction = Action< export type Actions = | TokenBalanceSelectAction + | DashboardTokenYieldSelectAction | YieldSelectAction | StakeAmountChangeAction | StakeAmountMaxAction @@ -117,6 +123,9 @@ export type EarnPageContextType = { all: TokenBalanceScanResponseDto[]; filtered: TokenBalanceScanResponseDto[]; }>; + tokenMaxYieldRatesByToken: ReadonlyMap; + tokenYieldCountsByToken: ReadonlyMap; + tokenListYieldsIsLoading: boolean; onTokenSearch: (value: string) => void; onValidatorSearch: (value: string) => void; validatorSearch: string; diff --git a/packages/widget/tests/domain/dashboard-yield-category-types.test.ts b/packages/widget/tests/domain/dashboard-yield-category-types.test.ts new file mode 100644 index 00000000..91f70e3b --- /dev/null +++ b/packages/widget/tests/domain/dashboard-yield-category-types.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; +import { + dashboardYieldCategories, + getApiYieldTypesForDashboardCategory, +} from "../../src/domain/types/yields"; + +const allApiYieldTypes = [ + "staking", + "restaking", + "lending", + "vault", + "fixed_yield", + "real_world_asset", + "concentrated_liquidity_pool", + "liquidity_pool", +] as const; + +describe("getApiYieldTypesForDashboardCategory", () => { + it("maps stake to staking + restaking", () => { + expect(getApiYieldTypesForDashboardCategory("stake").sort()).toEqual( + ["restaking", "staking"].sort() + ); + }); + + it("maps defi to the deposit yield types", () => { + expect(getApiYieldTypesForDashboardCategory("defi").sort()).toEqual( + [ + "lending", + "vault", + "fixed_yield", + "concentrated_liquidity_pool", + "liquidity_pool", + ].sort() + ); + }); + + it("maps rwa to real_world_asset", () => { + expect(getApiYieldTypesForDashboardCategory("rwa")).toEqual([ + "real_world_asset", + ]); + }); + + it("partitions every API yield type into exactly one category", () => { + const mapped = dashboardYieldCategories.flatMap((category) => + getApiYieldTypesForDashboardCategory(category) + ); + + expect(mapped.slice().sort()).toEqual([...allApiYieldTypes].sort()); + expect(new Set(mapped).size).toBe(mapped.length); + expect(mapped.length).toBe(allApiYieldTypes.length); + }); +}); diff --git a/packages/widget/tests/domain/token-list-yields.test.ts b/packages/widget/tests/domain/token-list-yields.test.ts index de5f1dea..08fe9bc0 100644 --- a/packages/widget/tests/domain/token-list-yields.test.ts +++ b/packages/widget/tests/domain/token-list-yields.test.ts @@ -1,7 +1,38 @@ -import { describe, expect, it } from "vitest"; -import { getMaxYieldRateForToken } from "../../src/hooks/api/use-token-list-yields"; +import { describe, expect, it, vi } from "vitest"; +import type { TokenBalanceScanResponseDto } from "../../src/domain/types/token-balance"; +import type { YieldDto } from "../../src/generated/api/yield"; +import { + fetchTokenListYieldSummaries, + getDashboardCategoryYieldIdsForToken, + getMaxYieldRateForToken, +} from "../../src/hooks/api/use-token-list-yields"; +import type { ApiClient } from "../../src/providers/api/api-client"; import { yieldApiYieldFixture, yieldRewardRateFixture } from "../fixtures"; +const yieldSummary = ({ + id, + type, + total = 0.01, + enter = true, +}: { + id: string; + type: YieldDto["mechanics"]["type"]; + total?: number; + enter?: boolean; +}) => { + const base = yieldApiYieldFixture(); + + return yieldApiYieldFixture({ + id, + rewardRate: yieldRewardRateFixture({ total }), + status: { enter, exit: true }, + mechanics: { + ...base.mechanics, + type, + }, + }); +}; + describe("getMaxYieldRateForToken", () => { it("formats uppercase reward rate types from the yield API", () => { const yieldDto = yieldApiYieldFixture({ @@ -23,3 +54,144 @@ describe("getMaxYieldRateForToken", () => { }); }); }); + +describe("getDashboardCategoryYieldIdsForToken", () => { + it("filters token yields to the active dashboard category and sorts by reward", () => { + const stakingYield = yieldSummary({ + id: "ethereum-eth-native-staking", + type: "staking", + total: 0.12, + }); + const vaultYield = yieldSummary({ + id: "ethereum-usdc-vault", + type: "vault", + total: 0.04, + }); + const lendingYield = yieldSummary({ + id: "ethereum-usdc-lending", + type: "lending", + total: 0.08, + }); + + const yieldsById = new Map( + [stakingYield, vaultYield, lendingYield].map((yieldDto) => [ + yieldDto.id, + yieldDto, + ]) + ); + + expect( + getDashboardCategoryYieldIdsForToken( + [stakingYield.id, vaultYield.id, lendingYield.id], + yieldsById, + "defi" + ) + ).toEqual([lendingYield.id, vaultYield.id]); + }); + + it("excludes invisible yields from category counts and selection candidates", () => { + const visibleYield = yieldSummary({ + id: "ethereum-usdc-vault", + type: "vault", + total: 0.04, + }); + const zeroRewardYield = yieldSummary({ + id: "ethereum-usdc-zero-vault", + type: "vault", + total: 0, + }); + const nonEnterableYield = yieldSummary({ + id: "ethereum-usdc-disabled-lending", + type: "lending", + enter: false, + total: 0.09, + }); + + const yieldsById = new Map( + [visibleYield, zeroRewardYield, nonEnterableYield].map((yieldDto) => [ + yieldDto.id, + yieldDto, + ]) + ); + + expect( + getDashboardCategoryYieldIdsForToken( + [visibleYield.id, zeroRewardYield.id, nonEnterableYield.id], + yieldsById, + "defi" + ) + ).toEqual([visibleYield.id]); + }); + + it("returns no candidates when a token has no yields in the active category", () => { + const stakingYield = yieldSummary({ + id: "ethereum-eth-native-staking", + type: "staking", + total: 0.12, + }); + + expect( + getDashboardCategoryYieldIdsForToken( + [stakingYield.id], + new Map([[stakingYield.id, stakingYield]]), + "defi" + ) + ).toEqual([]); + }); +}); + +describe("fetchTokenListYieldSummaries", () => { + it("loads displayed token yield metadata with bounded yield ID chunks", async () => { + const yieldIds = Array.from( + { length: 205 }, + (_, index) => `yield-${index}` + ); + const tokenBalances: TokenBalanceScanResponseDto[] = [ + { + amount: "0", + availableYields: yieldIds.slice(0, 120), + token: { + decimals: 18, + name: "Ethereum", + network: "ethereum", + symbol: "ETH", + }, + }, + { + amount: "0", + availableYields: [...yieldIds.slice(50, 205), "yield-100"], + token: { + decimals: 6, + name: "USD Coin", + network: "ethereum", + symbol: "USDC", + }, + }, + ]; + + const getYields = vi.fn( + async ({ params }: { params: { yieldIds: ReadonlyArray } }) => ({ + total: params.yieldIds.length, + offset: 0, + limit: params.yieldIds.length, + items: params.yieldIds.map((id) => yieldApiYieldFixture({ id })), + }) + ); + + const apiClient = { + withOptions: () => ({ yield: { YieldsControllerGetYields: getYields } }), + } as unknown as ApiClient; + + const result = await fetchTokenListYieldSummaries({ + apiClient, + tokenBalances, + }); + + expect(result).toHaveLength(205); + expect(result.map((item) => item.id)).toEqual(yieldIds); + expect(getYields).toHaveBeenCalledTimes(3); + expect( + getYields.mock.calls.map(([arg]) => arg.params.yieldIds.length) + ).toEqual([100, 100, 5]); + }); +}); diff --git a/packages/widget/tests/domain/yield-summaries.test.ts b/packages/widget/tests/domain/yield-summaries.test.ts new file mode 100644 index 00000000..693a8319 --- /dev/null +++ b/packages/widget/tests/domain/yield-summaries.test.ts @@ -0,0 +1,216 @@ +import { describe, expect, it, vi } from "vitest"; +import { + fetchAllYieldSummaries, + fetchYieldSummariesByIds, + isVisibleYieldSummary, + type YieldSummary, +} from "../../src/hooks/api/use-yield-summaries"; +import type { ApiClient } from "../../src/providers/api/api-client"; +import { yieldApiYieldFixture, yieldRewardRateFixture } from "../fixtures"; + +const summary = (overrides?: Parameters[0]) => + yieldApiYieldFixture(overrides) as YieldSummary; + +describe("isVisibleYieldSummary", () => { + it("includes enterable, supported-chain, non-zero-reward summaries", () => { + expect(isVisibleYieldSummary(summary())).toBe(true); + }); + + it("excludes summaries that are not enterable", () => { + expect( + isVisibleYieldSummary(summary({ status: { enter: false, exit: true } })) + ).toBe(false); + }); + + it("excludes summaries with a zero reward rate", () => { + expect( + isVisibleYieldSummary( + summary({ rewardRate: yieldRewardRateFixture({ total: 0 }) }) + ) + ).toBe(false); + }); + + it("includes whitelisted zero-reward summaries", () => { + expect( + isVisibleYieldSummary( + summary({ + id: "optimism-usdc-gtusdcb-0x4ffc4e5f1f1f5c43dc9bc27b53728da13b02be35-4626-vault", + token: { + name: "USD Coin", + symbol: "USDC", + decimals: 6, + network: "optimism", + }, + rewardRate: yieldRewardRateFixture({ total: 0 }), + }) + ) + ).toBe(true); + }); +}); + +describe("fetchAllYieldSummaries", () => { + it("loops offset until all pages are fetched", async () => { + const items = Array.from({ length: 5 }, (_, index) => + summary({ id: `yield-${index}` }) + ); + + const getYields = vi.fn( + async ({ params }: { params: { offset?: number; limit?: number } }) => { + const offset = params.offset ?? 0; + const limit = params.limit ?? 2; + + return { + total: items.length, + offset, + limit, + items: items.slice(offset, offset + limit), + }; + } + ); + + const apiClient = { + withOptions: () => ({ yield: { YieldsControllerGetYields: getYields } }), + } as unknown as ApiClient; + + const result = await fetchAllYieldSummaries({ + apiClient, + params: { network: "ethereum", limit: 2 }, + }); + + expect(result.map((item) => item.id)).toEqual([ + "yield-0", + "yield-1", + "yield-2", + "yield-3", + "yield-4", + ]); + expect(getYields).toHaveBeenCalledTimes(3); + expect(getYields.mock.calls.map(([arg]) => arg.params.offset)).toEqual([ + 0, 2, 4, + ]); + }); + + it("stops when the API returns an empty page", async () => { + const getYields = vi.fn(async () => ({ + total: 100, + offset: 0, + limit: 50, + items: [], + })); + + const apiClient = { + withOptions: () => ({ yield: { YieldsControllerGetYields: getYields } }), + } as unknown as ApiClient; + + const result = await fetchAllYieldSummaries({ + apiClient, + params: { network: "ethereum" }, + }); + + expect(result).toEqual([]); + expect(getYields).toHaveBeenCalledTimes(1); + }); +}); + +describe("fetchYieldSummariesByIds", () => { + it("splits yield IDs into bounded chunks", async () => { + const items = Array.from({ length: 5 }, (_, index) => + summary({ id: `yield-${index}` }) + ); + + const getYields = vi.fn( + async ({ params }: { params: { yieldIds: ReadonlyArray } }) => ({ + total: params.yieldIds.length, + offset: 0, + limit: params.yieldIds.length, + items: params.yieldIds.flatMap( + (yieldId) => items.find((item) => item.id === yieldId) ?? [] + ), + }) + ); + + const apiClient = { + withOptions: () => ({ yield: { YieldsControllerGetYields: getYields } }), + } as unknown as ApiClient; + + const result = await fetchYieldSummariesByIds({ + apiClient, + chunkSize: 2, + yieldIds: items.map((item) => item.id), + }); + + expect(result.map((item) => item.id)).toEqual([ + "yield-0", + "yield-1", + "yield-2", + "yield-3", + "yield-4", + ]); + expect(getYields).toHaveBeenCalledTimes(3); + expect(getYields.mock.calls.map(([arg]) => arg.params.yieldIds)).toEqual([ + ["yield-0", "yield-1"], + ["yield-2", "yield-3"], + ["yield-4"], + ]); + }); + + it("uses a single request when IDs fit within the chunk size", async () => { + const getYields = vi.fn(async () => ({ + total: 2, + offset: 0, + limit: 2, + items: [summary({ id: "yield-0" }), summary({ id: "yield-1" })], + })); + + const apiClient = { + withOptions: () => ({ yield: { YieldsControllerGetYields: getYields } }), + } as unknown as ApiClient; + + await fetchYieldSummariesByIds({ + apiClient, + chunkSize: 2, + yieldIds: ["yield-0", "yield-1"], + }); + + expect(getYields).toHaveBeenCalledTimes(1); + expect(getYields).toHaveBeenCalledWith({ + params: { + yieldIds: ["yield-0", "yield-1"], + limit: 2, + }, + }); + }); + + it("deduplicates requested IDs and returns summaries in first occurrence order", async () => { + const getYields = vi.fn( + async ({ params }: { params: { yieldIds: ReadonlyArray } }) => ({ + total: params.yieldIds.length, + offset: 0, + limit: params.yieldIds.length, + items: [...params.yieldIds] + .reverse() + .map((yieldId) => summary({ id: yieldId })), + }) + ); + + const apiClient = { + withOptions: () => ({ yield: { YieldsControllerGetYields: getYields } }), + } as unknown as ApiClient; + + const result = await fetchYieldSummariesByIds({ + apiClient, + chunkSize: 2, + yieldIds: ["yield-2", "yield-1", "yield-2", "yield-0"], + }); + + expect(result.map((item) => item.id)).toEqual([ + "yield-2", + "yield-1", + "yield-0", + ]); + expect(getYields.mock.calls.map(([arg]) => arg.params.yieldIds)).toEqual([ + ["yield-2", "yield-1"], + ["yield-0"], + ]); + }); +});