diff --git a/src/components/APYBreakdown.tsx b/src/components/APYBreakdown.tsx
index ead0a9cb..f2142977 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 { 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'
@@ -23,6 +24,15 @@ 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
+ }
}
interface APYBreakdownProps {
@@ -37,6 +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 hasApyBreakdownErrors = hasApyBreakdownError(data)
return (
@@ -56,6 +67,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 +86,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 +105,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?.length
+ ? 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 && (
@@ -112,21 +154,23 @@ export function APYBreakdown({ data, compact = false, className }: APYBreakdownP
)}
- {/* Total APY - Separated */}
-
-
-
Total APY:
-
- {formatPercentage(data.totalAPY, { decimals: 2, showSign: true })}
-
+ {/* Total APY - Separated. Only show if no errors */}
+ {!hasApyBreakdownErrors && (
+
+
+ Total APY:
+
+ {formatPercentage(data.totalAPY, { decimals: 2, showSign: true })}
+
+
-
+ )}
{/* Averaging Period Disclosure */}
{data.metadata &&
diff --git a/src/components/APYBreakdownTooltip.tsx b/src/components/APYBreakdownTooltip.tsx
index 90e1e1fc..0b07d16c 100644
--- a/src/components/APYBreakdownTooltip.tsx
+++ b/src/components/APYBreakdownTooltip.tsx
@@ -1,3 +1,4 @@
+import { hasApyBreakdownError } 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 && !hasApyBreakdownError(apyData))
+ if (showGenericError) {
return (
APY Breakdown
@@ -48,7 +50,10 @@ export function APYBreakdownTooltip({
)
}
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({
APY
- {isApyError ? (
+ {isApyError || (apyData && hasApyBreakdownError(apyData)) ? (
N/A
) : isApyLoading || !apyData ? (
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 294b3d97..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,6 +3,7 @@ import { Search } from 'lucide-react'
import { useMemo, useState } from 'react'
import type { APYBreakdownData } from '@/components/APYBreakdown'
import { ApyInfoTooltip } from '@/components/ApyInfoTooltip'
+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'
@@ -364,7 +365,10 @@ export function LeverageTokenTable({
) : (
currentItems.map((token, index) => {
const tokenApyData = apyDataMap?.get(token.address)
- const tokenApyError = apyError || (!apyLoading && !apyDataMap?.has(token.address))
+ const tokenApyError =
+ apyError ||
+ (!apyLoading && !apyDataMap?.has(token.address)) ||
+ (tokenApyData ? hasApyBreakdownError(tokenApyData) : false)
return (
0 ? { metadata } : {}),
}
diff --git a/src/features/portfolio/hooks/usePositionsAPY.ts b/src/features/portfolio/hooks/usePositionsAPY.ts
index e503c8a5..da5a7f28 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,7 @@ export function useTokensAPY({ tokens, enabled = true }: UseTokensAPYOptions) {
// Backward compatibility export
export const usePositionsAPY = useTokensAPY
+
+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 e93e53c2..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 { 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,6 +283,8 @@ export const Route = createFileRoute('/leverage-tokens/$chainId/$id')({
},
]
+ const hasApyBreakdownErrors = apyData ? hasApyBreakdownError(apyData) : false
+
return (
{/* Breadcrumb Navigation */}
@@ -378,8 +380,10 @@ export const Route = createFileRoute('/leverage-tokens/$chainId/$id')({
}
className="text-sm"
>
- {apyData?.totalAPY ? (
+ {apyData?.totalAPY && !hasApyBreakdownErrors ? (
`${formatAPY(apyData.totalAPY, 2)} APY`
+ ) : hasApyBreakdownErrors ? (
+ N/A
) : (
)}
@@ -466,13 +470,16 @@ export const Route = createFileRoute('/leverage-tokens/$chainId/$id')({
- {apyData?.totalAPY ? (
+ {apyData?.totalAPY && !hasApyBreakdownErrors ? (
`${formatAPY(apyData.totalAPY, 2)} APY`
+ ) : hasApyBreakdownErrors ? (
+ N/A
) : (
)}
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..6ea7c3ce 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', () => {
@@ -43,4 +44,28 @@ 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()
+ 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()
+ })
})
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: {},
})
})
})
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)
+ })
+})