From a91b619983712495c067afb9263fbe874e7ce7fe Mon Sep 17 00:00:00 2001 From: chad-js Date: Fri, 20 Feb 2026 16:26:48 -0700 Subject: [PATCH 01/10] feat: show apy errors in breakdown --- src/components/APYBreakdown.tsx | 109 ++++++++++++++---- src/components/ApyInfoTooltip.tsx | 2 +- .../LeverageTokenTable.tsx | 4 +- .../portfolio/hooks/usePositionsAPY.ts | 57 ++++++--- src/routes/leverage-tokens.$chainId.$id.tsx | 16 ++- 5 files changed, 142 insertions(+), 46 deletions(-) diff --git a/src/components/APYBreakdown.tsx b/src/components/APYBreakdown.tsx index ead0a9cb..3ef018c2 100644 --- a/src/components/APYBreakdown.tsx +++ b/src/components/APYBreakdown.tsx @@ -23,6 +23,24 @@ export interface APYBreakdownData { yieldAveragingPeriod?: string borrowAveragingPeriod?: string } + errors?: { + stakingYield?: Error | null + restakingYield?: Error | null + borrowRate?: Error | null + rewardsAPR?: Error | null + rewardTokens?: Error | null + totalAPY?: Error | null + utilization?: Error | null + raw?: { + rawBorrowRate?: Error | null + rawStakingYield?: Error | null + rawRestakingYield?: Error | null + } + metadata?: { + yieldAveragingPeriod?: Error | null + borrowAveragingPeriod?: Error | null + } + } } interface APYBreakdownProps { @@ -37,6 +55,14 @@ export function APYBreakdown({ data, compact = false, className }: APYBreakdownP : 'space-y-4 p-4 min-w-[240px] rounded-lg border border-[var(--divider-line)] bg-[var(--surface-card)]' const titleClass = 'text-sm' const itemClass = 'text-sm' + const hasAnyError = + !!data.errors?.stakingYield || + !!data.errors?.restakingYield || + !!data.errors?.borrowRate || + !!data.errors?.rewardsAPR || + !!data.errors?.rewardTokens || + !!data.errors?.totalAPY || + !!data.errors?.utilization return (
@@ -56,6 +82,15 @@ export function APYBreakdown({ data, compact = false, className }: APYBreakdownP
)} + {/* Show staking yield: error if present */} + {data.errors?.stakingYield && ( +
+ Staking Yield:  + + {data.errors.stakingYield.message} + +
+ )} {/* Restaking Yield - only show if not zero */} {data.restakingYield !== 0 && ( @@ -66,6 +101,15 @@ export function APYBreakdown({ data, compact = false, className }: APYBreakdownP )} + {/* Show restaking yield: error if present */} + {data.errors?.restakingYield && ( +
+ Restaking Yield:  + + {data.errors.restakingYield.message} + +
+ )} {/* Borrow Rate - only show if not zero */} {data.borrowRate !== 0 && ( @@ -76,24 +120,37 @@ export function APYBreakdown({ data, compact = false, className }: APYBreakdownP )} + {/* Show borrow rate: error if present */} + {data.errors?.borrowRate && ( +
+ Borrow Rate:  + + {data.errors.borrowRate.message} + +
+ )} {/* Individual Reward Tokens - show breakdown if available */} {data.rewardTokens && data.rewardTokens.length > 0 - ? data.rewardTokens.map((rewardToken) => ( -
-
-
- {getTokenLogoComponent(rewardToken.tokenSymbol)} + ? data.rewardTokens.map((rewardToken) => + rewardToken.tokenAddress != null && + rewardToken.tokenSymbol != null && + rewardToken.apr ? ( +
+
+
+ {getTokenLogoComponent(rewardToken.tokenSymbol)} +
+ + {rewardToken.tokenSymbol} APR: +
- - {rewardToken.tokenSymbol} APR: + + {formatPercentage(rewardToken.apr, { decimals: 2, showSign: true })}
- - {formatPercentage(rewardToken.apr, { decimals: 2, showSign: true })} - -
- )) + ) : null, + ) : // Fallback: show total rewards APR if no breakdown available data.rewardsAPR !== 0 && (
@@ -113,20 +170,22 @@ export function APYBreakdown({ data, compact = false, className }: APYBreakdownP )} {/* Total APY - Separated */} -
-
- Total APY: - - {formatPercentage(data.totalAPY, { decimals: 2, showSign: true })} - + {!hasAnyError && ( +
+
+ Total APY: + + {formatPercentage(data.totalAPY, { decimals: 2, showSign: true })} + +
-
+ )} {/* Averaging Period Disclosure */} {data.metadata && diff --git a/src/components/ApyInfoTooltip.tsx b/src/components/ApyInfoTooltip.tsx index 1ad59ffe..23b4d7c8 100644 --- a/src/components/ApyInfoTooltip.tsx +++ b/src/components/ApyInfoTooltip.tsx @@ -64,7 +64,7 @@ export function ApyInfoTooltip({ { const tokenApyData = apyDataMap?.get(token.address) const tokenApyError = apyError || (!apyLoading && !apyDataMap?.has(token.address)) + const isApyError = hasApyError(tokenApyData) return (
- {tokenApyError ? ( + {tokenApyError || isApyError ? ( N/A diff --git a/src/features/portfolio/hooks/usePositionsAPY.ts b/src/features/portfolio/hooks/usePositionsAPY.ts index e503c8a5..d7e89c87 100644 --- a/src/features/portfolio/hooks/usePositionsAPY.ts +++ b/src/features/portfolio/hooks/usePositionsAPY.ts @@ -80,16 +80,24 @@ export function useTokensAPY({ tokens, enabled = true }: UseTokensAPYOptions) { } // Fetch all required data in parallel - const [leverageRatios, aprData, borrowApyData, rewardsAPRData] = await Promise.all([ - fetchLeverageRatios(tokenAddress, tokenConfig.chainId, config), - fetchAprForToken(tokenAddress, tokenConfig.chainId), - fetchBorrowApyForToken(tokenAddress, tokenConfig.chainId, config), - fetchRewardsAprForToken(tokenAddress, tokenConfig.chainId), - ]) - - const borrowAPY = borrowApyData.borrowAPY - const utilization = borrowApyData.utilization - const targetLeverage = leverageRatios.targetLeverage + const [leverageRatios, aprDataResult, borrowApyData, rewardsAPRData] = + await Promise.allSettled([ + fetchLeverageRatios(tokenAddress, tokenConfig.chainId, config), + fetchAprForToken(tokenAddress, tokenConfig.chainId), + fetchBorrowApyForToken(tokenAddress, tokenConfig.chainId, config), + fetchRewardsAprForToken(tokenAddress, tokenConfig.chainId), + ]) + + const borrowAPY = + borrowApyData.status === 'fulfilled' ? borrowApyData.value.borrowAPY : 0 + const utilization = + borrowApyData.status === 'fulfilled' ? borrowApyData.value.utilization : 0 + const targetLeverage = + leverageRatios.status === 'fulfilled' ? leverageRatios.value.targetLeverage : 0 + const aprData = + aprDataResult.status === 'fulfilled' + ? aprDataResult.value + : { stakingAPR: 0, restakingAPR: 0, averagingPeriod: undefined } // Calculate APY components const stakingYield = @@ -105,8 +113,10 @@ export function useTokensAPY({ tokens, enabled = true }: UseTokensAPYOptions) { const borrowRate = (borrowAPY && targetLeverage ? borrowAPY * -1 * (targetLeverage - 1) : undefined) ?? 0 - const rewardsAPR = rewardsAPRData?.rewardsAPR ?? 0 - const rewardTokens = rewardsAPRData?.rewardTokens + const rewardsAPR = + rewardsAPRData.status === 'fulfilled' ? rewardsAPRData.value.rewardsAPR : 0 + const rewardTokens = + rewardsAPRData.status === 'fulfilled' ? rewardsAPRData.value.rewardTokens : undefined // Points calculation - use pointsMultiplier from config if available, otherwise default to 0 const points = tokenConfig.apyConfig?.pointsMultiplier ?? 0 @@ -121,8 +131,8 @@ export function useTokensAPY({ tokens, enabled = true }: UseTokensAPYOptions) { if (aprData.averagingPeriod) { metadata.yieldAveragingPeriod = aprData.averagingPeriod } - if (borrowApyData.averagingPeriod) { - metadata.borrowAveragingPeriod = borrowApyData.averagingPeriod + if (borrowApyData.status === 'fulfilled' && borrowApyData.value.averagingPeriod) { + metadata.borrowAveragingPeriod = borrowApyData.value.averagingPeriod } // Build base APY breakdown @@ -139,6 +149,13 @@ export function useTokensAPY({ tokens, enabled = true }: UseTokensAPYOptions) { rawStakingYield: aprData.stakingAPR ? aprData.stakingAPR / 100 : 0, rawRestakingYield: aprData.restakingAPR ? aprData.restakingAPR / 100 : 0, }, + errors: { + stakingYield: aprDataResult.status === 'rejected' ? aprDataResult.reason : null, + restakingYield: aprDataResult.status === 'rejected' ? aprDataResult.reason : null, + borrowRate: borrowApyData.status === 'rejected' ? borrowApyData.reason : null, + rewardsAPR: rewardsAPRData.status === 'rejected' ? rewardsAPRData.reason : null, + rewardTokens: rewardsAPRData.status === 'rejected' ? rewardsAPRData.reason : null, + }, ...(Object.keys(metadata).length > 0 ? { metadata } : {}), } @@ -196,3 +213,15 @@ export function useTokensAPY({ tokens, enabled = true }: UseTokensAPYOptions) { // Backward compatibility export export const usePositionsAPY = useTokensAPY + +export const hasApyError = (apyData: APYBreakdownData | undefined) => { + return ( + !!apyData?.errors?.stakingYield || + !!apyData?.errors?.restakingYield || + !!apyData?.errors?.borrowRate || + !!apyData?.errors?.rewardsAPR || + !!apyData?.errors?.rewardTokens || + !!apyData?.errors?.totalAPY || + !!apyData?.errors?.utilization + ) +} diff --git a/src/routes/leverage-tokens.$chainId.$id.tsx b/src/routes/leverage-tokens.$chainId.$id.tsx index e93e53c2..2fb93294 100644 --- a/src/routes/leverage-tokens.$chainId.$id.tsx +++ b/src/routes/leverage-tokens.$chainId.$id.tsx @@ -31,7 +31,7 @@ import { getLeverageTokenConfig, } from '@/features/leverage-tokens/leverageTokens.config' import { generateLeverageTokenFAQ } from '@/features/leverage-tokens/utils/faqGenerator' -import { useTokensAPY } from '@/features/portfolio/hooks/usePositionsAPY' +import { hasApyError, useTokensAPY } from '@/features/portfolio/hooks/usePositionsAPY' import { useGA } from '@/lib/config/ga4.config' import { useExplorer } from '@/lib/hooks/useExplorer' import { useUsdPrices } from '@/lib/prices/useUsdPrices' @@ -166,7 +166,7 @@ export const Route = createFileRoute('/leverage-tokens/$chainId/$id')({ if (!apyData || !apyData.raw || isLoading) return undefined return { borrowRate: apyData.raw.rawBorrowRate, - baseYield: apyData.raw.rawStakingYield + apyData.raw.rawRestakingYield, + baseYield: (apyData.raw.rawStakingYield ?? 0) + (apyData.raw.rawRestakingYield ?? 0), } } @@ -283,6 +283,8 @@ export const Route = createFileRoute('/leverage-tokens/$chainId/$id')({ }, ] + const hasApyErrors = hasApyError(apyData) + return ( {/* Breadcrumb Navigation */} @@ -378,8 +380,10 @@ export const Route = createFileRoute('/leverage-tokens/$chainId/$id')({ } className="text-sm" > - {apyData?.totalAPY ? ( + {apyData?.totalAPY && !hasApyErrors ? ( `${formatAPY(apyData.totalAPY, 2)} APY` + ) : hasApyErrors ? ( + N/A ) : ( )} @@ -466,13 +470,15 @@ export const Route = createFileRoute('/leverage-tokens/$chainId/$id')({
- {apyData?.totalAPY ? ( + {apyData?.totalAPY && !hasApyErrors ? ( `${formatAPY(apyData.totalAPY, 2)} APY` + ) : hasApyErrors ? ( + N/A ) : ( )} From 2ea2851bbf23aa69bbf5152ca5e41fbc69c26b5e Mon Sep 17 00:00:00 2001 From: chad-js Date: Fri, 20 Feb 2026 16:28:55 -0700 Subject: [PATCH 02/10] rm unused errors --- src/components/APYBreakdown.tsx | 9 --------- 1 file changed, 9 deletions(-) diff --git a/src/components/APYBreakdown.tsx b/src/components/APYBreakdown.tsx index 3ef018c2..8d94d7da 100644 --- a/src/components/APYBreakdown.tsx +++ b/src/components/APYBreakdown.tsx @@ -31,15 +31,6 @@ export interface APYBreakdownData { rewardTokens?: Error | null totalAPY?: Error | null utilization?: Error | null - raw?: { - rawBorrowRate?: Error | null - rawStakingYield?: Error | null - rawRestakingYield?: Error | null - } - metadata?: { - yieldAveragingPeriod?: Error | null - borrowAveragingPeriod?: Error | null - } } } From c4a394b42055aa1a12c0d569b0acd069a8fb5de3 Mon Sep 17 00:00:00 2001 From: chad-js Date: Mon, 23 Feb 2026 09:29:07 -0600 Subject: [PATCH 03/10] feat: show apy errors in breakdown --- src/components/APYBreakdown.tsx | 20 +++++++------------ .../LeverageTokenTable.tsx | 8 +++++--- .../hooks/useLeverageTokenAPY.ts | 3 +++ .../portfolio/hooks/usePositionsAPY.ts | 12 ++--------- src/routes/leverage-tokens.$chainId.$id.tsx | 4 ++-- src/routes/leverage-tokens.index.tsx | 12 +++++------ .../components/apy-breakdown.stories.tsx | 3 +++ .../featured-leverage-token.stories.tsx | 2 ++ .../leverage-token-table.stories.tsx | 3 +++ tests/unit/components/APYBreakdown.test.tsx | 1 + .../hooks/useLeverageTokenAPY.test.tsx | 2 ++ 11 files changed, 36 insertions(+), 34 deletions(-) diff --git a/src/components/APYBreakdown.tsx b/src/components/APYBreakdown.tsx index 8d94d7da..f0cb8889 100644 --- a/src/components/APYBreakdown.tsx +++ b/src/components/APYBreakdown.tsx @@ -1,4 +1,5 @@ import type { RewardTokenApr } from '@/features/leverage-tokens/utils/apy-calculations/rewards-providers/types' +import { hasApyError } from '@/features/portfolio/hooks/usePositionsAPY' import { formatPercentage, formatPoints } from '@/lib/utils/formatting' import { getTokenLogoComponent } from '@/lib/utils/token-logos' import { cn } from './ui/utils' @@ -23,7 +24,7 @@ export interface APYBreakdownData { yieldAveragingPeriod?: string borrowAveragingPeriod?: string } - errors?: { + errors: { stakingYield?: Error | null restakingYield?: Error | null borrowRate?: Error | null @@ -46,14 +47,7 @@ export function APYBreakdown({ data, compact = false, className }: APYBreakdownP : 'space-y-4 p-4 min-w-[240px] rounded-lg border border-[var(--divider-line)] bg-[var(--surface-card)]' const titleClass = 'text-sm' const itemClass = 'text-sm' - const hasAnyError = - !!data.errors?.stakingYield || - !!data.errors?.restakingYield || - !!data.errors?.borrowRate || - !!data.errors?.rewardsAPR || - !!data.errors?.rewardTokens || - !!data.errors?.totalAPY || - !!data.errors?.utilization + const hasAnyError = hasApyError(data) return (
@@ -74,7 +68,7 @@ export function APYBreakdown({ data, compact = false, className }: APYBreakdownP
)} {/* Show staking yield: error if present */} - {data.errors?.stakingYield && ( + {data.errors.stakingYield && (
Staking Yield:  @@ -93,7 +87,7 @@ export function APYBreakdown({ data, compact = false, className }: APYBreakdownP
)} {/* Show restaking yield: error if present */} - {data.errors?.restakingYield && ( + {data.errors.restakingYield && (
Restaking Yield:  @@ -112,7 +106,7 @@ export function APYBreakdown({ data, compact = false, className }: APYBreakdownP
)} {/* Show borrow rate: error if present */} - {data.errors?.borrowRate && ( + {data.errors.borrowRate && (
Borrow Rate:  @@ -160,7 +154,7 @@ export function APYBreakdown({ data, compact = false, className }: APYBreakdownP
)} - {/* Total APY - Separated */} + {/* Total APY - Separated. Only show if no errors */} {!hasAnyError && (
diff --git a/src/features/leverage-tokens/components/leverage-token-table/LeverageTokenTable.tsx b/src/features/leverage-tokens/components/leverage-token-table/LeverageTokenTable.tsx index 9631862c..10a1639a 100644 --- a/src/features/leverage-tokens/components/leverage-token-table/LeverageTokenTable.tsx +++ b/src/features/leverage-tokens/components/leverage-token-table/LeverageTokenTable.tsx @@ -365,8 +365,10 @@ export function LeverageTokenTable({ ) : ( currentItems.map((token, index) => { const tokenApyData = apyDataMap?.get(token.address) - const tokenApyError = apyError || (!apyLoading && !apyDataMap?.has(token.address)) - const isApyError = hasApyError(tokenApyData) + const tokenApyError = + apyError || + (!apyLoading && !apyDataMap?.has(token.address)) || + (tokenApyData ? hasApyError(tokenApyData) : false) return (
- {tokenApyError || isApyError ? ( + {tokenApyError ? ( N/A diff --git a/src/features/leverage-tokens/hooks/useLeverageTokenAPY.ts b/src/features/leverage-tokens/hooks/useLeverageTokenAPY.ts index fa654c39..b7a2d41e 100644 --- a/src/features/leverage-tokens/hooks/useLeverageTokenAPY.ts +++ b/src/features/leverage-tokens/hooks/useLeverageTokenAPY.ts @@ -51,6 +51,7 @@ export function useLeverageTokenAPY({ rawStakingYield: 0, rawRestakingYield: 0, }, + errors: {}, } } @@ -73,6 +74,7 @@ export function useLeverageTokenAPY({ rawStakingYield: 0, rawRestakingYield: 0, }, + errors: {}, } } @@ -183,6 +185,7 @@ export function useLeverageTokenAPY({ rawStakingYield, rawRestakingYield, }, + errors: {}, ...(Object.keys(metadata).length > 0 ? { metadata } : {}), } diff --git a/src/features/portfolio/hooks/usePositionsAPY.ts b/src/features/portfolio/hooks/usePositionsAPY.ts index d7e89c87..7bedb52b 100644 --- a/src/features/portfolio/hooks/usePositionsAPY.ts +++ b/src/features/portfolio/hooks/usePositionsAPY.ts @@ -214,14 +214,6 @@ export function useTokensAPY({ tokens, enabled = true }: UseTokensAPYOptions) { // Backward compatibility export export const usePositionsAPY = useTokensAPY -export const hasApyError = (apyData: APYBreakdownData | undefined) => { - return ( - !!apyData?.errors?.stakingYield || - !!apyData?.errors?.restakingYield || - !!apyData?.errors?.borrowRate || - !!apyData?.errors?.rewardsAPR || - !!apyData?.errors?.rewardTokens || - !!apyData?.errors?.totalAPY || - !!apyData?.errors?.utilization - ) +export const hasApyError = (apyData: APYBreakdownData) => { + return Object.values(apyData.errors).some((error) => error !== null) } diff --git a/src/routes/leverage-tokens.$chainId.$id.tsx b/src/routes/leverage-tokens.$chainId.$id.tsx index 2fb93294..6b2ba1b1 100644 --- a/src/routes/leverage-tokens.$chainId.$id.tsx +++ b/src/routes/leverage-tokens.$chainId.$id.tsx @@ -166,7 +166,7 @@ export const Route = createFileRoute('/leverage-tokens/$chainId/$id')({ if (!apyData || !apyData.raw || isLoading) return undefined return { borrowRate: apyData.raw.rawBorrowRate, - baseYield: (apyData.raw.rawStakingYield ?? 0) + (apyData.raw.rawRestakingYield ?? 0), + baseYield: apyData.raw.rawStakingYield + apyData.raw.rawRestakingYield, } } @@ -283,7 +283,7 @@ export const Route = createFileRoute('/leverage-tokens/$chainId/$id')({ }, ] - const hasApyErrors = hasApyError(apyData) + const hasApyErrors = apyData ? hasApyError(apyData) : false return ( diff --git a/src/routes/leverage-tokens.index.tsx b/src/routes/leverage-tokens.index.tsx index 55555a77..9dda07c9 100644 --- a/src/routes/leverage-tokens.index.tsx +++ b/src/routes/leverage-tokens.index.tsx @@ -28,8 +28,8 @@ export const Route = createFileRoute('/leverage-tokens/')({ // Calculate APY data for all leverage tokens const { data: tokensAPYData, - isLoading: isApyLoading, - isError: isApyError, + isLoading: isApyHookLoading, + isError: isApyHookError, } = useTokensAPY({ tokens: leverageTokens, enabled: leverageTokens.length > 0, @@ -63,8 +63,8 @@ export const Route = createFileRoute('/leverage-tokens/')({ tokens={featuredTokens} onTokenClick={handleTokenClick} apyDataMap={tokensAPYData} - isApyLoading={isApyLoading} - isApyError={isApyError} + isApyLoading={isApyHookLoading} + isApyError={isApyHookError} />
)} @@ -126,8 +126,8 @@ export const Route = createFileRoute('/leverage-tokens/')({ tokens={leverageTokens} onTokenClick={handleTokenClick} apyDataMap={tokensAPYData} - isApyLoading={isApyLoading} - isApyError={isApyError} + isApyLoading={isApyHookLoading} + isApyError={isApyHookError} /> )} diff --git a/src/stories/components/apy-breakdown.stories.tsx b/src/stories/components/apy-breakdown.stories.tsx index 4582a878..563b4f88 100644 --- a/src/stories/components/apy-breakdown.stories.tsx +++ b/src/stories/components/apy-breakdown.stories.tsx @@ -28,6 +28,7 @@ const mockAPYData: APYBreakdownData = { rewardsAPR: 2.55, points: 1096, totalAPY: 6.34, + errors: {}, } const highAPYData: APYBreakdownData = { @@ -37,6 +38,7 @@ const highAPYData: APYBreakdownData = { rewardsAPR: 3.89, points: 2340, totalAPY: 11.34, + errors: {}, } const lowAPYData: APYBreakdownData = { @@ -46,6 +48,7 @@ const lowAPYData: APYBreakdownData = { rewardsAPR: 0.95, points: 234, totalAPY: 2.52, + errors: {}, } export const Default: Story = { diff --git a/src/stories/features/leverage-tokens/featured-leverage-token.stories.tsx b/src/stories/features/leverage-tokens/featured-leverage-token.stories.tsx index cbf12898..17415916 100644 --- a/src/stories/features/leverage-tokens/featured-leverage-token.stories.tsx +++ b/src/stories/features/leverage-tokens/featured-leverage-token.stories.tsx @@ -156,6 +156,7 @@ export const Default: Story = { yieldAveragingPeriod: '7-day average' as const, borrowAveragingPeriod: '24-hour average' as const, }, + errors: {}, }, ], [ @@ -171,6 +172,7 @@ export const Default: Story = { yieldAveragingPeriod: '7-day average' as const, borrowAveragingPeriod: '7-day average' as const, }, + errors: {}, }, ], ]), diff --git a/src/stories/features/leverage-tokens/leverage-token-table.stories.tsx b/src/stories/features/leverage-tokens/leverage-token-table.stories.tsx index 4b369dad..cb9f6e85 100644 --- a/src/stories/features/leverage-tokens/leverage-token-table.stories.tsx +++ b/src/stories/features/leverage-tokens/leverage-token-table.stories.tsx @@ -407,6 +407,7 @@ export const Default: Story = { rewardsAPR: 1.5, points: 4, totalAPY: 7.5, + errors: {}, }, ], ]), @@ -428,6 +429,7 @@ export const WithPagination: Story = { rewardsAPR: 1.5, points: 4, totalAPY: 7.5, + errors: {}, }, ], ]), @@ -457,6 +459,7 @@ export const NoPagination: Story = { rewardsAPR: 1.5, points: 4, totalAPY: 7.5, + errors: {}, }, ], ]), diff --git a/tests/unit/components/APYBreakdown.test.tsx b/tests/unit/components/APYBreakdown.test.tsx index c01eb7ea..a08dfe9f 100644 --- a/tests/unit/components/APYBreakdown.test.tsx +++ b/tests/unit/components/APYBreakdown.test.tsx @@ -10,6 +10,7 @@ describe('APYBreakdown', () => { rewardsAPR: 0.8, points: 6.0, totalAPY: 6.6, + errors: {}, } it('should render all yield components correctly', () => { diff --git a/tests/unit/features/leverage-tokens/hooks/useLeverageTokenAPY.test.tsx b/tests/unit/features/leverage-tokens/hooks/useLeverageTokenAPY.test.tsx index 7b0d0834..32d44d38 100644 --- a/tests/unit/features/leverage-tokens/hooks/useLeverageTokenAPY.test.tsx +++ b/tests/unit/features/leverage-tokens/hooks/useLeverageTokenAPY.test.tsx @@ -202,6 +202,7 @@ describe('useLeverageTokenAPY', () => { rawStakingYield: 0, rawRestakingYield: 0, }, + errors: {}, }) // Should not call external APIs @@ -300,6 +301,7 @@ describe('useLeverageTokenAPY', () => { rawStakingYield: 0, rawRestakingYield: 0, }, + errors: {}, }) }) }) From 20f2d4ee3d8cf543bdd1bcf93117b8624277dd93 Mon Sep 17 00:00:00 2001 From: chad-js Date: Mon, 23 Feb 2026 09:38:42 -0600 Subject: [PATCH 04/10] generic error --- src/components/APYBreakdownTooltip.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/components/APYBreakdownTooltip.tsx b/src/components/APYBreakdownTooltip.tsx index 90e1e1fc..39fd7194 100644 --- a/src/components/APYBreakdownTooltip.tsx +++ b/src/components/APYBreakdownTooltip.tsx @@ -1,3 +1,4 @@ +import { hasApyError } from '@/features/portfolio/hooks/usePositionsAPY' import type { APYBreakdownData } from './APYBreakdown' import { APYBreakdown } from './APYBreakdown' import { Skeleton } from './ui/skeleton' @@ -35,7 +36,8 @@ export function APYBreakdownTooltip({ ) } - if (isError || !apyData) { + const showGenericError = !apyData || (isError && !hasApyError(apyData)) + if (showGenericError) { return (
APY Breakdown
From 772c6fe508878416b854d53ac75c00d8d86c358b Mon Sep 17 00:00:00 2001 From: chad-js Date: Mon, 23 Feb 2026 09:52:06 -0600 Subject: [PATCH 05/10] update naming --- src/components/APYBreakdown.tsx | 8 ++++---- src/components/APYBreakdownTooltip.tsx | 4 ++-- .../leverage-token-table/LeverageTokenTable.tsx | 4 ++-- src/features/portfolio/hooks/usePositionsAPY.ts | 2 +- src/routes/leverage-tokens.$chainId.$id.tsx | 15 ++++++++------- 5 files changed, 17 insertions(+), 16 deletions(-) diff --git a/src/components/APYBreakdown.tsx b/src/components/APYBreakdown.tsx index f0cb8889..f2142977 100644 --- a/src/components/APYBreakdown.tsx +++ b/src/components/APYBreakdown.tsx @@ -1,5 +1,5 @@ import type { RewardTokenApr } from '@/features/leverage-tokens/utils/apy-calculations/rewards-providers/types' -import { hasApyError } from '@/features/portfolio/hooks/usePositionsAPY' +import { hasApyBreakdownError } from '@/features/portfolio/hooks/usePositionsAPY' import { formatPercentage, formatPoints } from '@/lib/utils/formatting' import { getTokenLogoComponent } from '@/lib/utils/token-logos' import { cn } from './ui/utils' @@ -47,7 +47,7 @@ export function APYBreakdown({ data, compact = false, className }: APYBreakdownP : 'space-y-4 p-4 min-w-[240px] rounded-lg border border-[var(--divider-line)] bg-[var(--surface-card)]' const titleClass = 'text-sm' const itemClass = 'text-sm' - const hasAnyError = hasApyError(data) + const hasApyBreakdownErrors = hasApyBreakdownError(data) return (
@@ -116,7 +116,7 @@ export function APYBreakdown({ data, compact = false, className }: APYBreakdownP )} {/* Individual Reward Tokens - show breakdown if available */} - {data.rewardTokens && data.rewardTokens.length > 0 + {data.rewardTokens?.length ? data.rewardTokens.map((rewardToken) => rewardToken.tokenAddress != null && rewardToken.tokenSymbol != null && @@ -155,7 +155,7 @@ export function APYBreakdown({ data, compact = false, className }: APYBreakdownP )} {/* Total APY - Separated. Only show if no errors */} - {!hasAnyError && ( + {!hasApyBreakdownErrors && (
Total APY: diff --git a/src/components/APYBreakdownTooltip.tsx b/src/components/APYBreakdownTooltip.tsx index 39fd7194..7f28da13 100644 --- a/src/components/APYBreakdownTooltip.tsx +++ b/src/components/APYBreakdownTooltip.tsx @@ -1,4 +1,4 @@ -import { hasApyError } from '@/features/portfolio/hooks/usePositionsAPY' +import { hasApyBreakdownError } from '@/features/portfolio/hooks/usePositionsAPY' import type { APYBreakdownData } from './APYBreakdown' import { APYBreakdown } from './APYBreakdown' import { Skeleton } from './ui/skeleton' @@ -36,7 +36,7 @@ export function APYBreakdownTooltip({ ) } - const showGenericError = !apyData || (isError && !hasApyError(apyData)) + const showGenericError = !apyData || (isError && !hasApyBreakdownError(apyData)) if (showGenericError) { return (
diff --git a/src/features/leverage-tokens/components/leverage-token-table/LeverageTokenTable.tsx b/src/features/leverage-tokens/components/leverage-token-table/LeverageTokenTable.tsx index 10a1639a..4b3e1785 100644 --- a/src/features/leverage-tokens/components/leverage-token-table/LeverageTokenTable.tsx +++ b/src/features/leverage-tokens/components/leverage-token-table/LeverageTokenTable.tsx @@ -3,7 +3,7 @@ import { Search } from 'lucide-react' import { useMemo, useState } from 'react' import type { APYBreakdownData } from '@/components/APYBreakdown' import { ApyInfoTooltip } from '@/components/ApyInfoTooltip' -import { hasApyError } from '@/features/portfolio/hooks/usePositionsAPY' +import { hasApyBreakdownError } from '@/features/portfolio/hooks/usePositionsAPY' import { getTokenExplorerInfo } from '@/lib/utils/block-explorer' import { cn } from '@/lib/utils/cn' import { formatAPY, formatCurrency } from '@/lib/utils/formatting' @@ -368,7 +368,7 @@ export function LeverageTokenTable({ const tokenApyError = apyError || (!apyLoading && !apyDataMap?.has(token.address)) || - (tokenApyData ? hasApyError(tokenApyData) : false) + (tokenApyData ? hasApyBreakdownError(tokenApyData) : false) return ( { +export const hasApyBreakdownError = (apyData: APYBreakdownData) => { return Object.values(apyData.errors).some((error) => error !== null) } diff --git a/src/routes/leverage-tokens.$chainId.$id.tsx b/src/routes/leverage-tokens.$chainId.$id.tsx index 6b2ba1b1..eec8a8fd 100644 --- a/src/routes/leverage-tokens.$chainId.$id.tsx +++ b/src/routes/leverage-tokens.$chainId.$id.tsx @@ -31,7 +31,7 @@ import { getLeverageTokenConfig, } from '@/features/leverage-tokens/leverageTokens.config' import { generateLeverageTokenFAQ } from '@/features/leverage-tokens/utils/faqGenerator' -import { hasApyError, useTokensAPY } from '@/features/portfolio/hooks/usePositionsAPY' +import { hasApyBreakdownError, useTokensAPY } from '@/features/portfolio/hooks/usePositionsAPY' import { useGA } from '@/lib/config/ga4.config' import { useExplorer } from '@/lib/hooks/useExplorer' import { useUsdPrices } from '@/lib/prices/useUsdPrices' @@ -283,7 +283,7 @@ export const Route = createFileRoute('/leverage-tokens/$chainId/$id')({ }, ] - const hasApyErrors = apyData ? hasApyError(apyData) : false + const hasApyBreakdownErrors = apyData ? hasApyBreakdownError(apyData) : false return ( @@ -380,9 +380,9 @@ export const Route = createFileRoute('/leverage-tokens/$chainId/$id')({ } className="text-sm" > - {apyData?.totalAPY && !hasApyErrors ? ( + {apyData?.totalAPY && !hasApyBreakdownErrors ? ( `${formatAPY(apyData.totalAPY, 2)} APY` - ) : hasApyErrors ? ( + ) : hasApyBreakdownErrors ? ( N/A ) : ( @@ -470,14 +470,15 @@ export const Route = createFileRoute('/leverage-tokens/$chainId/$id')({
- {apyData?.totalAPY && !hasApyErrors ? ( + {apyData?.totalAPY && !hasApyBreakdownErrors ? ( `${formatAPY(apyData.totalAPY, 2)} APY` - ) : hasApyErrors ? ( + ) : hasApyBreakdownErrors ? ( N/A ) : ( From 0483df92114c4f83aa8e83b51c4e55e4fc3769dc Mon Sep 17 00:00:00 2001 From: chad-js Date: Mon, 23 Feb 2026 10:01:22 -0600 Subject: [PATCH 06/10] update max w --- src/components/APYBreakdownTooltip.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/components/APYBreakdownTooltip.tsx b/src/components/APYBreakdownTooltip.tsx index 7f28da13..0b07d16c 100644 --- a/src/components/APYBreakdownTooltip.tsx +++ b/src/components/APYBreakdownTooltip.tsx @@ -50,7 +50,10 @@ export function APYBreakdownTooltip({ ) } From b1efd9e8ff1ca2b12d2f526866173a45755af768 Mon Sep 17 00:00:00 2001 From: chad-js Date: Mon, 23 Feb 2026 10:17:51 -0600 Subject: [PATCH 07/10] update test --- .../hooks/useLeverageTokenAPY.ts | 10 +++++++++- tests/unit/components/APYBreakdown.test.tsx | 20 +++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/src/features/leverage-tokens/hooks/useLeverageTokenAPY.ts b/src/features/leverage-tokens/hooks/useLeverageTokenAPY.ts index b7a2d41e..8cbf5455 100644 --- a/src/features/leverage-tokens/hooks/useLeverageTokenAPY.ts +++ b/src/features/leverage-tokens/hooks/useLeverageTokenAPY.ts @@ -185,7 +185,15 @@ export function useLeverageTokenAPY({ rawStakingYield, rawRestakingYield, }, - errors: {}, + errors: { + stakingYield: aprDataResult.status === 'rejected' ? aprDataResult.reason : null, + restakingYield: aprDataResult.status === 'rejected' ? aprDataResult.reason : null, + borrowRate: borrowApyDataResult.status === 'rejected' ? borrowApyDataResult.reason : null, + rewardsAPR: + rewardsAPRDataResult.status === 'rejected' ? rewardsAPRDataResult.reason : null, + rewardTokens: + rewardsAPRDataResult.status === 'rejected' ? rewardsAPRDataResult.reason : null, + }, ...(Object.keys(metadata).length > 0 ? { metadata } : {}), } diff --git a/tests/unit/components/APYBreakdown.test.tsx b/tests/unit/components/APYBreakdown.test.tsx index a08dfe9f..0cf21ca0 100644 --- a/tests/unit/components/APYBreakdown.test.tsx +++ b/tests/unit/components/APYBreakdown.test.tsx @@ -44,4 +44,24 @@ describe('APYBreakdown', () => { expect(screen.getByText('APY Breakdown')).toBeInTheDocument() expect(screen.getByText('Staking Yield:')).toBeInTheDocument() }) + + it('should render with errors', () => { + render( + , + ) + + expect(screen.getByText('Staking Yield:')).toBeInTheDocument() + expect(screen.getByText('test reason')).toBeInTheDocument() + + // Still show the other values + expect(screen.getByText('Restaking Yield:')).toBeInTheDocument() + expect(screen.getByText('Borrow Rate:')).toBeInTheDocument() + expect(screen.getByText('Rewards APR:')).toBeInTheDocument() + expect(screen.getByText('Points:')).toBeInTheDocument() + + // Total APY should not be shown + expect(screen.queryByText('Total APY:')).not.toBeInTheDocument() + }) }) From 8dfc6db848d9ce3609010091546506083eb8635d Mon Sep 17 00:00:00 2001 From: chad-js Date: Mon, 23 Feb 2026 10:26:09 -0600 Subject: [PATCH 08/10] update mobile card error state --- .../leverage-token-table/LeverageTokenMobileCard.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/features/leverage-tokens/components/leverage-token-table/LeverageTokenMobileCard.tsx b/src/features/leverage-tokens/components/leverage-token-table/LeverageTokenMobileCard.tsx index fa309332..615fb102 100644 --- a/src/features/leverage-tokens/components/leverage-token-table/LeverageTokenMobileCard.tsx +++ b/src/features/leverage-tokens/components/leverage-token-table/LeverageTokenMobileCard.tsx @@ -1,6 +1,7 @@ import { motion } from 'framer-motion' import type { APYBreakdownData } from '@/components/APYBreakdown' import { ApyInfoTooltip } from '@/components/ApyInfoTooltip' +import { hasApyBreakdownError } from '@/features/portfolio/hooks/usePositionsAPY' import { formatAPY, formatCurrency } from '@/lib/utils/formatting' import { AssetDisplay } from '../../../../components/ui/asset-display' import { Card, CardContent } from '../../../../components/ui/card' @@ -73,7 +74,7 @@ export function LeverageTokenMobileCard({
APY
- {isApyError ? ( + {isApyError || (apyData && hasApyBreakdownError(apyData)) ? ( N/A ) : isApyLoading || !apyData ? ( From 1c2af836a8097d76ad3472b745d3fa77b6547cbf Mon Sep 17 00:00:00 2001 From: chad-js Date: Mon, 23 Feb 2026 10:35:23 -0600 Subject: [PATCH 09/10] update test --- tests/unit/components/APYBreakdown.test.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/unit/components/APYBreakdown.test.tsx b/tests/unit/components/APYBreakdown.test.tsx index 0cf21ca0..6ea7c3ce 100644 --- a/tests/unit/components/APYBreakdown.test.tsx +++ b/tests/unit/components/APYBreakdown.test.tsx @@ -60,6 +60,10 @@ describe('APYBreakdown', () => { expect(screen.getByText('Borrow Rate:')).toBeInTheDocument() expect(screen.getByText('Rewards APR:')).toBeInTheDocument() expect(screen.getByText('Points:')).toBeInTheDocument() + expect(screen.getByText('+210.00%')).toBeInTheDocument() // Restaking Yield + expect(screen.getByText('-150.00%')).toBeInTheDocument() // Borrow Rate (negative) + expect(screen.getByText('+80.00%')).toBeInTheDocument() // Rewards APR + expect(screen.getByText('6 x')).toBeInTheDocument() // Points // Total APY should not be shown expect(screen.queryByText('Total APY:')).not.toBeInTheDocument() From e6d0d5f32affda1d062a03ac1db2dbd35f579275 Mon Sep 17 00:00:00 2001 From: chad-js Date: Mon, 23 Feb 2026 10:49:29 -0600 Subject: [PATCH 10/10] add unit tests for usePositionsApy hook --- .../portfolio/hooks/usePositionsAPY.test.ts | 718 ++++++++++++++++++ 1 file changed, 718 insertions(+) create mode 100644 tests/unit/features/portfolio/hooks/usePositionsAPY.test.ts diff --git a/tests/unit/features/portfolio/hooks/usePositionsAPY.test.ts b/tests/unit/features/portfolio/hooks/usePositionsAPY.test.ts new file mode 100644 index 00000000..fa8042e6 --- /dev/null +++ b/tests/unit/features/portfolio/hooks/usePositionsAPY.test.ts @@ -0,0 +1,718 @@ +import { waitFor } from '@testing-library/react' +import type { Address } from 'viem' +import { base } from 'viem/chains' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { APYBreakdownData } from '@/components/APYBreakdown' +import { fetchAprForToken } from '@/features/leverage-tokens/utils/apy-calculations/apr-providers' +import { fetchBorrowApyForToken } from '@/features/leverage-tokens/utils/apy-calculations/borrow-apy-providers' +import { fetchLeverageRatios } from '@/features/leverage-tokens/utils/apy-calculations/leverage-ratios' +import { fetchRewardsAprForToken } from '@/features/leverage-tokens/utils/apy-calculations/rewards-providers' +import { hasApyBreakdownError, useTokensAPY } from '@/features/portfolio/hooks/usePositionsAPY' +import { hookTestUtils } from '../../../../utils' + +const mockFetchAprForToken = vi.mocked(fetchAprForToken) +const mockFetchBorrowApyForToken = vi.mocked(fetchBorrowApyForToken) +const mockFetchLeverageRatios = vi.mocked(fetchLeverageRatios) +const mockFetchRewardsAprForToken = vi.mocked(fetchRewardsAprForToken) + +const TOKEN_ADDRESS = '0x04C0599Ae5A44757c0af6F9eC3b93da8976c150A' as Address + +function setupSuccessMocks(overrides?: { + leverageRatios?: { targetLeverage: number; minLeverage: number; maxLeverage: number } + apr?: { stakingAPR: number; restakingAPR: number; totalAPR: number; averagingPeriod?: string } + borrow?: { borrowAPY: number; utilization?: number; averagingPeriod?: string } + rewards?: { + rewardsAPR: number + rewardTokens?: Array<{ + tokenAddress: Address + tokenSymbol: string + tokenDecimals: number + apr: number + }> + } +}) { + mockFetchLeverageRatios.mockResolvedValue( + overrides?.leverageRatios ?? { targetLeverage: 3, minLeverage: 2, maxLeverage: 5 }, + ) + mockFetchAprForToken.mockResolvedValue( + overrides?.apr ?? { stakingAPR: 4.0, restakingAPR: 2.0, totalAPR: 6.0 }, + ) + mockFetchBorrowApyForToken.mockResolvedValue( + overrides?.borrow ?? { borrowAPY: 0.05, utilization: 0.8 }, + ) + mockFetchRewardsAprForToken.mockResolvedValue(overrides?.rewards ?? { rewardsAPR: 0.01 }) +} + +describe('useTokensAPY', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('query lifecycle', () => { + it('should be disabled when tokens array is empty', () => { + const { result } = hookTestUtils.renderHookWithQuery(() => useTokensAPY({ tokens: [] })) + + expect(result.current.isFetching).toBe(false) + expect(result.current.data).toBeUndefined() + }) + + it('should be disabled when enabled option is false', () => { + const { result } = hookTestUtils.renderHookWithQuery(() => + useTokensAPY({ + tokens: [{ name: 'Test', type: 'leverage-token', address: TOKEN_ADDRESS }], + enabled: false, + }), + ) + + expect(result.current.isFetching).toBe(false) + }) + + it('should start loading when enabled with valid tokens', () => { + setupSuccessMocks() + + const { result } = hookTestUtils.renderHookWithQuery(() => + useTokensAPY({ + tokens: [{ name: 'Test', type: 'leverage-token', address: TOKEN_ADDRESS }], + }), + ) + + expect(result.current.isLoading).toBe(true) + }) + }) + + describe('token filtering', () => { + it('should skip vault-type tokens', async () => { + setupSuccessMocks() + + const { result } = hookTestUtils.renderHookWithQuery(() => + useTokensAPY({ + tokens: [{ name: 'Vault Token', type: 'vault', address: '0xabc' as Address }], + }), + ) + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true) + }) + + expect(result.current.data?.size).toBe(0) + expect(mockFetchAprForToken).not.toHaveBeenCalled() + }) + + it('should skip tokens without an address', async () => { + setupSuccessMocks() + + const { result } = hookTestUtils.renderHookWithQuery(() => + useTokensAPY({ + tokens: [{ name: 'No Address Token', type: 'leverage-token' }], + }), + ) + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true) + }) + + expect(result.current.data?.size).toBe(0) + expect(mockFetchAprForToken).not.toHaveBeenCalled() + }) + + it('should include tokens with type leverage-token and address', async () => { + setupSuccessMocks() + + const { result } = hookTestUtils.renderHookWithQuery(() => + useTokensAPY({ + tokens: [{ name: 'LT', type: 'leverage-token', address: TOKEN_ADDRESS }], + }), + ) + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true) + }) + + expect(result.current.data?.size).toBe(1) + }) + + it('should include tokens with chainId (leverage token configs)', async () => { + setupSuccessMocks() + + const { result } = hookTestUtils.renderHookWithQuery(() => + useTokensAPY({ + tokens: [{ name: 'LT Config', chainId: base.id, address: TOKEN_ADDRESS }], + }), + ) + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true) + }) + + expect(result.current.data?.size).toBe(1) + }) + + it('should use leverageTokenAddress for position-style tokens', async () => { + setupSuccessMocks() + + const { result } = hookTestUtils.renderHookWithQuery(() => + useTokensAPY({ + tokens: [ + { + id: 'pos-1', + name: 'Position', + type: 'leverage-token', + leverageTokenAddress: TOKEN_ADDRESS, + }, + ], + }), + ) + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true) + }) + + expect(result.current.data?.has('pos-1')).toBe(true) + expect(mockFetchLeverageRatios).toHaveBeenCalledWith(TOKEN_ADDRESS, undefined, {}) + }) + }) + + describe('APY calculations', () => { + it('should compute staking/restaking yield, borrow rate, and total APY', async () => { + setupSuccessMocks({ + leverageRatios: { targetLeverage: 3, minLeverage: 2, maxLeverage: 5 }, + apr: { stakingAPR: 4.0, restakingAPR: 2.0, totalAPR: 6.0 }, + borrow: { borrowAPY: 0.05, utilization: 0.8 }, + rewards: { rewardsAPR: 0.01 }, + }) + + const { result } = hookTestUtils.renderHookWithQuery(() => + useTokensAPY({ + tokens: [{ name: 'LT', type: 'leverage-token', address: TOKEN_ADDRESS }], + }), + ) + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true) + }) + + const apyData = result.current.data?.values().next().value as APYBreakdownData + + // borrowRate = borrowAPY * -1 * (leverage - 1) = 0.05 * -1 * 2 = -0.10 + expect(apyData.borrowRate).toBeCloseTo(-0.1, 5) + + // rewardsAPR = 0.01 + expect(apyData.rewardsAPR).toBeCloseTo(0.01, 5) + + // totalAPY = stakingYield(0) + restakingYield(0) + rewardsAPR + borrowRate + // Note: the source sets stakingYield/restakingYield to 0 in the output object + // while calculating them for totalAPY + // totalAPY = (4/100)*3 + (2/100)*3 + 0.01 + (-0.1) = 0.12 + 0.06 + 0.01 - 0.1 = 0.09 + expect(apyData.totalAPY).toBeCloseTo(0.09, 5) + }) + + it('should default all yields to 0 when leverage is 0', async () => { + setupSuccessMocks({ + leverageRatios: { targetLeverage: 0, minLeverage: 0, maxLeverage: 0 }, + apr: { stakingAPR: 4.0, restakingAPR: 2.0, totalAPR: 6.0 }, + borrow: { borrowAPY: 0.05 }, + rewards: { rewardsAPR: 0.01 }, + }) + + const { result } = hookTestUtils.renderHookWithQuery(() => + useTokensAPY({ + tokens: [{ name: 'LT', type: 'leverage-token', address: TOKEN_ADDRESS }], + }), + ) + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true) + }) + + const apyData = result.current.data?.values().next().value as APYBreakdownData + expect(apyData.borrowRate).toBe(0) + // totalAPY with 0 leverage yields = only rewardsAPR + expect(apyData.totalAPY).toBeCloseTo(0.01, 5) + }) + + it('should default all yields to 0 when APR values are 0', async () => { + setupSuccessMocks({ + leverageRatios: { targetLeverage: 3, minLeverage: 2, maxLeverage: 5 }, + apr: { stakingAPR: 0, restakingAPR: 0, totalAPR: 0 }, + borrow: { borrowAPY: 0 }, + rewards: { rewardsAPR: 0 }, + }) + + const { result } = hookTestUtils.renderHookWithQuery(() => + useTokensAPY({ + tokens: [{ name: 'LT', type: 'leverage-token', address: TOKEN_ADDRESS }], + }), + ) + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true) + }) + + const apyData = result.current.data?.values().next().value as APYBreakdownData + expect(apyData.borrowRate).toBe(0) + expect(apyData.totalAPY).toBe(0) + }) + + it('should populate raw rates from provider data', async () => { + setupSuccessMocks({ + apr: { stakingAPR: 5.0, restakingAPR: 3.0, totalAPR: 8.0 }, + borrow: { borrowAPY: 0.04 }, + }) + + const { result } = hookTestUtils.renderHookWithQuery(() => + useTokensAPY({ + tokens: [{ name: 'LT', type: 'leverage-token', address: TOKEN_ADDRESS }], + }), + ) + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true) + }) + + const apyData = result.current.data?.values().next().value as APYBreakdownData + expect(apyData.raw?.rawStakingYield).toBeCloseTo(0.05, 5) + expect(apyData.raw?.rawRestakingYield).toBeCloseTo(0.03, 5) + expect(apyData.raw?.rawBorrowRate).toBeCloseTo(0.04, 5) + }) + + it('should include utilization from borrow data', async () => { + setupSuccessMocks({ + borrow: { borrowAPY: 0.05, utilization: 0.85 }, + }) + + const { result } = hookTestUtils.renderHookWithQuery(() => + useTokensAPY({ + tokens: [{ name: 'LT', type: 'leverage-token', address: TOKEN_ADDRESS }], + }), + ) + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true) + }) + + const apyData = result.current.data?.values().next().value as APYBreakdownData + expect(apyData.utilization).toBe(0.85) + }) + + it('should use pointsMultiplier from token config apyConfig', async () => { + setupSuccessMocks() + + // Use an address not in the mocked leverageTokenConfigs so the code + // falls through to `token as LeverageTokenConfig` + const customAddress = '0x0000000000000000000000000000000000000099' as Address + + const { result } = hookTestUtils.renderHookWithQuery(() => + useTokensAPY({ + tokens: [ + { + name: 'LT with points', + chainId: base.id, + address: customAddress, + apyConfig: { pointsMultiplier: 5 }, + } as any, + ], + }), + ) + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true) + }) + + const apyData = result.current.data?.get(customAddress) + expect(apyData?.points).toBe(5) + }) + }) + + describe('metadata', () => { + it('should include yield averaging period when present', async () => { + setupSuccessMocks({ + apr: { + stakingAPR: 4.0, + restakingAPR: 2.0, + totalAPR: 6.0, + averagingPeriod: '30-day average', + }, + }) + + const { result } = hookTestUtils.renderHookWithQuery(() => + useTokensAPY({ + tokens: [{ name: 'LT', type: 'leverage-token', address: TOKEN_ADDRESS }], + }), + ) + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true) + }) + + const apyData = result.current.data?.values().next().value as APYBreakdownData + expect(apyData.metadata?.yieldAveragingPeriod).toBe('30-day average') + }) + + it('should include borrow averaging period when present', async () => { + setupSuccessMocks({ + borrow: { borrowAPY: 0.05, utilization: 0.8, averagingPeriod: '7-day average' }, + }) + + const { result } = hookTestUtils.renderHookWithQuery(() => + useTokensAPY({ + tokens: [{ name: 'LT', type: 'leverage-token', address: TOKEN_ADDRESS }], + }), + ) + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true) + }) + + const apyData = result.current.data?.values().next().value as APYBreakdownData + expect(apyData.metadata?.borrowAveragingPeriod).toBe('7-day average') + }) + + it('should omit metadata when no averaging periods are present', async () => { + setupSuccessMocks({ + apr: { stakingAPR: 4.0, restakingAPR: 2.0, totalAPR: 6.0 }, + borrow: { borrowAPY: 0.05 }, + }) + + const { result } = hookTestUtils.renderHookWithQuery(() => + useTokensAPY({ + tokens: [{ name: 'LT', type: 'leverage-token', address: TOKEN_ADDRESS }], + }), + ) + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true) + }) + + const apyData = result.current.data?.values().next().value as APYBreakdownData + expect(apyData.metadata).toBeUndefined() + }) + }) + + describe('reward tokens', () => { + it('should include rewardTokens when provider returns them', async () => { + const rewardTokens = [ + { + tokenAddress: '0x1C7a460413dD4e964f96D8dFC56E7223cE88CD85' as Address, + tokenSymbol: 'SEAM', + tokenDecimals: 18, + apr: 0.005, + }, + ] + + setupSuccessMocks({ + rewards: { rewardsAPR: 0.005, rewardTokens }, + }) + + const { result } = hookTestUtils.renderHookWithQuery(() => + useTokensAPY({ + tokens: [{ name: 'LT', type: 'leverage-token', address: TOKEN_ADDRESS }], + }), + ) + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true) + }) + + const apyData = result.current.data?.values().next().value as APYBreakdownData + expect(apyData.rewardTokens).toHaveLength(1) + expect(apyData.rewardTokens?.[0]?.tokenSymbol).toBe('SEAM') + }) + + it('should not include rewardTokens when array is empty', async () => { + setupSuccessMocks({ + rewards: { rewardsAPR: 0, rewardTokens: [] }, + }) + + const { result } = hookTestUtils.renderHookWithQuery(() => + useTokensAPY({ + tokens: [{ name: 'LT', type: 'leverage-token', address: TOKEN_ADDRESS }], + }), + ) + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true) + }) + + const apyData = result.current.data?.values().next().value as APYBreakdownData + expect(apyData.rewardTokens).toBeUndefined() + }) + }) + + describe('error handling', () => { + it('should gracefully handle rejected leverage ratios', async () => { + mockFetchLeverageRatios.mockRejectedValue(new Error('RPC error')) + mockFetchAprForToken.mockResolvedValue({ stakingAPR: 4.0, restakingAPR: 2.0, totalAPR: 6.0 }) + mockFetchBorrowApyForToken.mockResolvedValue({ borrowAPY: 0.05, utilization: 0.8 }) + mockFetchRewardsAprForToken.mockResolvedValue({ rewardsAPR: 0 }) + + const { result } = hookTestUtils.renderHookWithQuery(() => + useTokensAPY({ + tokens: [{ name: 'LT', type: 'leverage-token', address: TOKEN_ADDRESS }], + }), + ) + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true) + }) + + const apyData = result.current.data?.values().next().value as APYBreakdownData + // targetLeverage defaults to 0 when rejected, so yields are 0 + expect(apyData.borrowRate).toBe(0) + expect(apyData.totalAPY).toBe(0) + }) + + it('should gracefully handle rejected APR data', async () => { + mockFetchLeverageRatios.mockResolvedValue({ + targetLeverage: 3, + minLeverage: 2, + maxLeverage: 5, + }) + mockFetchAprForToken.mockRejectedValue(new Error('API down')) + mockFetchBorrowApyForToken.mockResolvedValue({ borrowAPY: 0.05, utilization: 0.8 }) + mockFetchRewardsAprForToken.mockResolvedValue({ rewardsAPR: 0 }) + + const { result } = hookTestUtils.renderHookWithQuery(() => + useTokensAPY({ + tokens: [{ name: 'LT', type: 'leverage-token', address: TOKEN_ADDRESS }], + }), + ) + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true) + }) + + const apyData = result.current.data?.values().next().value as APYBreakdownData + expect(apyData.raw?.rawStakingYield).toBe(0) + expect(apyData.raw?.rawRestakingYield).toBe(0) + }) + + it('should record borrow error when borrow provider rejects', async () => { + mockFetchLeverageRatios.mockResolvedValue({ + targetLeverage: 3, + minLeverage: 2, + maxLeverage: 5, + }) + mockFetchAprForToken.mockResolvedValue({ stakingAPR: 4.0, restakingAPR: 2.0, totalAPR: 6.0 }) + mockFetchBorrowApyForToken.mockRejectedValue(new Error('borrow fetch failed')) + mockFetchRewardsAprForToken.mockResolvedValue({ rewardsAPR: 0 }) + + const { result } = hookTestUtils.renderHookWithQuery(() => + useTokensAPY({ + tokens: [{ name: 'LT', type: 'leverage-token', address: TOKEN_ADDRESS }], + }), + ) + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true) + }) + + const apyData = result.current.data?.values().next().value as APYBreakdownData + expect(apyData.errors.borrowRate).toBeInstanceOf(Error) + expect(apyData.errors.borrowRate?.message).toBe('borrow fetch failed') + expect(apyData.borrowRate).toBe(0) + expect(apyData.utilization).toBe(0) + }) + + it('should record rewards error when rewards provider rejects', async () => { + mockFetchLeverageRatios.mockResolvedValue({ + targetLeverage: 3, + minLeverage: 2, + maxLeverage: 5, + }) + mockFetchAprForToken.mockResolvedValue({ stakingAPR: 4.0, restakingAPR: 2.0, totalAPR: 6.0 }) + mockFetchBorrowApyForToken.mockResolvedValue({ borrowAPY: 0.05, utilization: 0.8 }) + mockFetchRewardsAprForToken.mockRejectedValue(new Error('rewards fetch failed')) + + const { result } = hookTestUtils.renderHookWithQuery(() => + useTokensAPY({ + tokens: [{ name: 'LT', type: 'leverage-token', address: TOKEN_ADDRESS }], + }), + ) + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true) + }) + + const apyData = result.current.data?.values().next().value as APYBreakdownData + expect(apyData.errors.rewardsAPR).toBeInstanceOf(Error) + expect(apyData.errors.rewardsAPR?.message).toBe('rewards fetch failed') + expect(apyData.errors.rewardTokens).toBeInstanceOf(Error) + expect(apyData.errors.rewardTokens?.message).toBe('rewards fetch failed') + expect(apyData.rewardsAPR).toBe(0) + }) + + it('should return null apyData when no config is found for a token', async () => { + setupSuccessMocks() + + const unknownAddress = '0x0000000000000000000000000000000000000001' as Address + + const { result } = hookTestUtils.renderHookWithQuery(() => + useTokensAPY({ + tokens: [{ name: 'Unknown', type: 'leverage-token', address: unknownAddress }], + }), + ) + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true) + }) + + // Token has no matching config, so apyData is null and not added to the map + expect(result.current.data?.size).toBe(0) + }) + }) + + describe('multiple tokens', () => { + it('should process multiple leverage tokens in parallel', async () => { + setupSuccessMocks() + + const { result } = hookTestUtils.renderHookWithQuery(() => + useTokensAPY({ + tokens: [ + { name: 'Token A', type: 'leverage-token', address: TOKEN_ADDRESS }, + { + name: 'Token B', + type: 'leverage-token', + address: '0x10041DFFBE8fB54Ca4Dfa56F2286680EC98A37c3' as Address, + }, + ], + }), + ) + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true) + }) + + // Both tokens should have APY data (both addresses are in the mocked leverageTokenConfigs) + expect(result.current.data?.size).toBe(2) + }) + + it('should only include leverage tokens and skip vaults in mixed arrays', async () => { + setupSuccessMocks() + + const { result } = hookTestUtils.renderHookWithQuery(() => + useTokensAPY({ + tokens: [ + { name: 'Vault', type: 'vault', address: '0xabc' as Address }, + { name: 'LT', type: 'leverage-token', address: TOKEN_ADDRESS }, + ], + }), + ) + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true) + }) + + expect(result.current.data?.size).toBe(1) + }) + }) + + describe('token ID resolution', () => { + it('should use token.id as map key when available', async () => { + setupSuccessMocks() + + const { result } = hookTestUtils.renderHookWithQuery(() => + useTokensAPY({ + tokens: [ + { + id: 'my-position-id', + name: 'Position', + type: 'leverage-token', + leverageTokenAddress: TOKEN_ADDRESS, + }, + ], + }), + ) + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true) + }) + + expect(result.current.data?.has('my-position-id')).toBe(true) + }) + + it('should fall back to address when id is not provided', async () => { + setupSuccessMocks() + + const { result } = hookTestUtils.renderHookWithQuery(() => + useTokensAPY({ + tokens: [{ name: 'LT', type: 'leverage-token', address: TOKEN_ADDRESS }], + }), + ) + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true) + }) + + expect(result.current.data?.has(TOKEN_ADDRESS)).toBe(true) + }) + }) +}) + +describe('hasApyBreakdownError', () => { + it('should return true when any error is non-null', () => { + const data: APYBreakdownData = { + stakingYield: 0, + restakingYield: 0, + borrowRate: 0, + rewardsAPR: 0, + points: 0, + totalAPY: 0, + errors: { + borrowRate: new Error('fetch failed'), + }, + } + + expect(hasApyBreakdownError(data)).toBe(true) + }) + + it('should return false when all errors are null', () => { + const data: APYBreakdownData = { + stakingYield: 0, + restakingYield: 0, + borrowRate: 0, + rewardsAPR: 0, + points: 0, + totalAPY: 0, + errors: { + stakingYield: null, + restakingYield: null, + borrowRate: null, + rewardsAPR: null, + }, + } + + expect(hasApyBreakdownError(data)).toBe(false) + }) + + it('should return false when errors object is empty', () => { + const data: APYBreakdownData = { + stakingYield: 0, + restakingYield: 0, + borrowRate: 0, + rewardsAPR: 0, + points: 0, + totalAPY: 0, + errors: {}, + } + + expect(hasApyBreakdownError(data)).toBe(false) + }) + + it('should return true when multiple errors are present', () => { + const data: APYBreakdownData = { + stakingYield: 0, + restakingYield: 0, + borrowRate: 0, + rewardsAPR: 0, + points: 0, + totalAPY: 0, + errors: { + stakingYield: new Error('staking failed'), + borrowRate: new Error('borrow failed'), + rewardsAPR: null, + }, + } + + expect(hasApyBreakdownError(data)).toBe(true) + }) +})