Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 34 additions & 17 deletions packages/widget/src/domain/types/yields.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,40 @@ export const dashboardYieldCategories = [
"rwa",
] as const satisfies ReadonlyArray<DashboardYieldCategory>;

/**
* Maps every API `YieldType` to exactly one dashboard category. The
* `satisfies Record<ApiYieldType, ...>` 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<ApiYieldType, DashboardYieldCategory>;

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 => {
Expand All @@ -91,23 +125,6 @@ export const getDashboardYieldCategory = (
return null;
};

export const getAvailableDashboardYieldCategories = (
yields: Iterable<Yield>
): DashboardYieldCategory[] => {
const categories = new Set<DashboardYieldCategory>();

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,
Expand Down
88 changes: 88 additions & 0 deletions packages/widget/src/hooks/api/use-dashboard-yield-catalog.ts
Original file line number Diff line number Diff line change
@@ -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 }),
};
Comment on lines +43 to +57

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Single-page probing can falsely mark categories as unavailable.

Line 55 fetches only one page (Line 48 limit), and Line 69 searches visibility only within that page. If visible yields start after the first page, the category is incorrectly hidden and no initial selection is seeded.

💡 Suggested fix
 import {
   DEFAULT_YIELD_SUMMARIES_PAGE_LIMIT,
+  fetchAllYieldSummaries,
   fetchYieldSummariesPage,
   getYieldSummariesQueryKey,
   isVisibleYieldSummary,
   type YieldSummariesParams,
 } from "./use-yield-summaries";
@@
       return {
         enabled: probeEnabled,
         staleTime,
-        queryKey: getYieldSummariesQueryKey(params),
+        queryKey: getYieldSummariesQueryKey({ ...params, allPages: true }),
         queryFn: ({ signal }: { signal: AbortSignal }) =>
-          fetchYieldSummariesPage({ apiClient, params, signal }),
+          fetchAllYieldSummaries({ apiClient, params, signal }),
       };
     }),
   });

Also applies to: 68-74

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/widget/src/hooks/api/use-dashboard-yield-catalog.ts` around lines 43
- 57, The probe currently fetches only a single page in the dashboard category
queries (see queries mapping in use-dashboard-yield-catalog.ts using
fetchYieldSummariesPage with DEFAULT_YIELD_SUMMARIES_PAGE_LIMIT and
getYieldSummariesQueryKey) which can falsely mark a category as unavailable if
visible yields are on later pages; change the probe logic to page through
results (or request an unbounded/large limit if supported) until either a
visible yield is found or no more pages remain, and then base the visibility
check and initial selection seeding on that aggregated result instead of the
single first page; ensure the same fix is applied to both probe usages (the
query mapping and the second probe at lines ~68-74) and preserve abort signal
handling when iterating pages.

}),
});

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]);
};
108 changes: 92 additions & 16 deletions packages/widget/src/hooks/api/use-token-list-yields.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -20,6 +28,44 @@ const getUniqueYieldIds = (
tokenBalances: ReadonlyArray<TokenBalanceScanResponseDto>
) => [...new Set(tokenBalances.flatMap((tb) => tb.availableYields))];

export const fetchTokenListYieldSummaries = ({
apiClient,
signal,
tokenBalances,
}: {
apiClient: ReturnType<typeof useApiClient>;
signal?: AbortSignal;
tokenBalances: ReadonlyArray<TokenBalanceScanResponseDto>;
}) =>
fetchYieldSummariesByIds({
apiClient,
signal,
yieldIds: getUniqueYieldIds(tokenBalances),
});

export const getDashboardCategoryYieldIdsForToken = (
availableYieldIds: ReadonlyArray<string>,
yieldsById: ReadonlyMap<string, YieldDto>,
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<string>,
yieldsById: ReadonlyMap<string, YieldDto>
Expand Down Expand Up @@ -56,7 +102,8 @@ export const getMaxYieldRateForToken = (
};

export const useTokenListYields = (
tokenBalances: ReadonlyArray<TokenBalanceScanResponseDto>
tokenBalances: ReadonlyArray<TokenBalanceScanResponseDto>,
dashboardYieldCategory?: DashboardYieldCategory | null
) => {
const apiClient = useApiClient();
const yieldIds = getUniqueYieldIds(tokenBalances);
Expand All @@ -65,29 +112,56 @@ 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<string, string[]>();

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(() => {
const map = new Map<string, TokenMaxYieldRate>();

for (const tokenBalance of tokenBalances) {
const maxYieldRate = getMaxYieldRateForToken(
tokenBalance.availableYields,
yieldIdsByToken.get(tokenString(tokenBalance.token)) ?? [],
yieldsById
);

Expand All @@ -97,9 +171,11 @@ export const useTokenListYields = (
}

return map;
}, [tokenBalances, yieldsById]);
}, [tokenBalances, yieldIdsByToken, yieldsById]);

return {
yieldIdsByToken,
yieldCountsByToken,
maxYieldRatesByToken,
isLoading: query.isLoading,
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
Loading