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) + }) +})