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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 71 additions & 27 deletions src/components/APYBreakdown.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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 {
Expand All @@ -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 (
<div className={cn(containerClass, className)}>
Expand All @@ -56,6 +67,15 @@ export function APYBreakdown({ data, compact = false, className }: APYBreakdownP
</span>
</div>
)}
{/* Show staking yield: error if present */}
{data.errors.stakingYield && (
<div className="flex items-start gap-2">
<span className="shrink-0 text-[var(--text-secondary)]">Staking Yield:&nbsp;</span>
<span className="min-w-0 flex-1 break-words text-right font-medium text-[var(--state-error-text)]">
{data.errors.stakingYield.message}
</span>
</div>
)}

{/* Restaking Yield - only show if not zero */}
{data.restakingYield !== 0 && (
Expand All @@ -66,6 +86,15 @@ export function APYBreakdown({ data, compact = false, className }: APYBreakdownP
</span>
</div>
)}
{/* Show restaking yield: error if present */}
{data.errors.restakingYield && (
<div className="flex items-start gap-2">
<span className="shrink-0 text-[var(--text-secondary)]">Restaking Yield:&nbsp;</span>
<span className="min-w-0 flex-1 break-words text-right font-medium text-[var(--state-error-text)]">
{data.errors.restakingYield.message}
</span>
</div>
)}

{/* Borrow Rate - only show if not zero */}
{data.borrowRate !== 0 && (
Expand All @@ -76,24 +105,37 @@ export function APYBreakdown({ data, compact = false, className }: APYBreakdownP
</span>
</div>
)}
{/* Show borrow rate: error if present */}
{data.errors.borrowRate && (
<div className="flex items-start gap-2">
<span className="shrink-0 text-[var(--text-secondary)]">Borrow Rate:&nbsp;</span>
<span className="min-w-0 flex-1 break-words text-right font-medium text-[var(--state-error-text)]">
{data.errors.borrowRate.message}
</span>
</div>
)}

{/* Individual Reward Tokens - show breakdown if available */}
{data.rewardTokens && data.rewardTokens.length > 0
? data.rewardTokens.map((rewardToken) => (
<div key={rewardToken.tokenAddress} className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="h-4 w-4 flex-shrink-0">
{getTokenLogoComponent(rewardToken.tokenSymbol)}
{data.rewardTokens?.length
? data.rewardTokens.map((rewardToken) =>
rewardToken.tokenAddress != null &&
rewardToken.tokenSymbol != null &&
rewardToken.apr ? (
<div key={rewardToken.tokenAddress} className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="h-4 w-4 flex-shrink-0">
{getTokenLogoComponent(rewardToken.tokenSymbol)}
</div>
<span className="text-[var(--text-secondary)]">
{rewardToken.tokenSymbol} APR:
</span>
</div>
<span className="text-[var(--text-secondary)]">
{rewardToken.tokenSymbol} APR:
<span className="font-medium text-[var(--accent-1)]">
{formatPercentage(rewardToken.apr, { decimals: 2, showSign: true })}
</span>
</div>
<span className="font-medium text-[var(--accent-1)]">
{formatPercentage(rewardToken.apr, { decimals: 2, showSign: true })}
</span>
</div>
))
) : null,
)
: // Fallback: show total rewards APR if no breakdown available
data.rewardsAPR !== 0 && (
<div className="flex justify-between">
Expand All @@ -112,21 +154,23 @@ export function APYBreakdown({ data, compact = false, className }: APYBreakdownP
</div>
)}

{/* Total APY - Separated */}
<div className="mt-3 border-t border-[var(--divider-line)] pt-3">
<div className="flex justify-between font-semibold">
<span className="text-[var(--text-primary)]">Total APY:</span>
<span
className={
data.totalAPY < 0
? 'text-[var(--state-error-text)]'
: 'text-[var(--state-success-text)]'
}
>
{formatPercentage(data.totalAPY, { decimals: 2, showSign: true })}
</span>
{/* Total APY - Separated. Only show if no errors */}
{!hasApyBreakdownErrors && (
<div className="mt-3 border-t border-[var(--divider-line)] pt-3">
<div className="flex justify-between font-semibold">
<span className="text-[var(--text-primary)]">Total APY:</span>
<span
className={
data.totalAPY < 0
? 'text-[var(--state-error-text)]'
: 'text-[var(--state-success-text)]'
}
>
{formatPercentage(data.totalAPY, { decimals: 2, showSign: true })}
</span>
</div>
</div>
</div>
)}

{/* Averaging Period Disclosure */}
{data.metadata &&
Expand Down
9 changes: 7 additions & 2 deletions src/components/APYBreakdownTooltip.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -35,7 +36,8 @@ export function APYBreakdownTooltip({
)
}

if (isError || !apyData) {
const showGenericError = !apyData || (isError && !hasApyBreakdownError(apyData))
if (showGenericError) {
return (
<div className="min-w-[240px] space-y-2 rounded-lg border border-border bg-card p-4">
<div className="text-sm font-semibold text-foreground">APY Breakdown</div>
Expand All @@ -48,7 +50,10 @@ export function APYBreakdownTooltip({
<APYBreakdown
data={apyData}
compact={compact}
className={cn('min-w-[240px] rounded-lg border border-border bg-card', className)}
className={cn(
'max-w-[min(92vw,420px)] min-w-[240px] rounded-lg border border-border bg-card',
className,
)}
/>
)
}
2 changes: 1 addition & 1 deletion src/components/ApyInfoTooltip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ export function ApyInfoTooltip({
</button>
</TooltipTrigger>
<TooltipContent
className={`p-0 text-sm border border-[var(--divider-line)] bg-[color-mix(in_srgb,var(--surface-card) 92%,transparent)] ${className ?? ''}`}
className={`max-w-[min(92vw,420px)] p-0 text-sm border border-[var(--divider-line)] bg-[color-mix(in_srgb,var(--surface-card) 92%,transparent)] ${className ?? ''}`}
side={side}
align={align}
sideOffset={sideOffset}
Expand Down
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -73,7 +74,7 @@ export function LeverageTokenMobileCard({
<div className="flex justify-between items-center">
<span className="text-sm text-[var(--text-secondary)]">APY</span>
<div className="flex items-center space-x-1">
{isApyError ? (
{isApyError || (apyData && hasApyBreakdownError(apyData)) ? (
<span className="text-sm font-medium text-[var(--text-muted)]">N/A</span>
) : isApyLoading || !apyData ? (
<Skeleton variant="pulse" className="h-4 w-16" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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 (
<motion.tr
Expand Down
11 changes: 11 additions & 0 deletions src/features/leverage-tokens/hooks/useLeverageTokenAPY.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export function useLeverageTokenAPY({
rawStakingYield: 0,
rawRestakingYield: 0,
},
errors: {},
}
}

Expand All @@ -73,6 +74,7 @@ export function useLeverageTokenAPY({
rawStakingYield: 0,
rawRestakingYield: 0,
},
errors: {},
}
}

Expand Down Expand Up @@ -183,6 +185,15 @@ export function useLeverageTokenAPY({
rawStakingYield,
rawRestakingYield,
},
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,
},
Comment on lines +188 to +196
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

fyi we're not actually using this hook useLeverageTokenApy anywhere atm, but I added this here for consistency

...(Object.keys(metadata).length > 0 ? { metadata } : {}),
}

Expand Down
49 changes: 35 additions & 14 deletions src/features/portfolio/hooks/usePositionsAPY.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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 } : {}),
}

Expand Down Expand Up @@ -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)
}
15 changes: 11 additions & 4 deletions src/routes/leverage-tokens.$chainId.$id.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -283,6 +283,8 @@ export const Route = createFileRoute('/leverage-tokens/$chainId/$id')({
},
]

const hasApyBreakdownErrors = apyData ? hasApyBreakdownError(apyData) : false

return (
<PageContainer padded={false}>
{/* Breadcrumb Navigation */}
Expand Down Expand Up @@ -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 ? (
<span className="text-sm font-medium text-[var(--text-muted)]">N/A</span>
) : (
<Skeleton className="h-4 w-20" />
)}
Expand Down Expand Up @@ -466,13 +470,16 @@ export const Route = createFileRoute('/leverage-tokens/$chainId/$id')({
<div className="flex items-center space-x-1">
<Badge
className={
apyData?.totalAPY !== undefined && apyData.totalAPY < 0
(apyData?.totalAPY !== undefined && apyData.totalAPY < 0) ||
hasApyBreakdownErrors
? 'border-[color-mix(in_srgb,var(--state-error-text)_25%,transparent)] bg-[var(--state-error-bg)] text-[var(--state-error-text)]'
: 'border-[color-mix(in_srgb,var(--state-success-text)_25%,transparent)] bg-[var(--state-success-bg)] text-[var(--state-success-text)]'
}
>
{apyData?.totalAPY ? (
{apyData?.totalAPY && !hasApyBreakdownErrors ? (
`${formatAPY(apyData.totalAPY, 2)} APY`
) : hasApyBreakdownErrors ? (
<span className="text-sm font-medium text-[var(--text-muted)]">N/A</span>
) : (
<Skeleton className="h-4 w-20" />
)}
Expand Down
Loading