From 0c03a61414d9b2a6936d924285e210829fcc8fc9 Mon Sep 17 00:00:00 2001 From: antoncoding Date: Wed, 4 Feb 2026 16:18:50 +0800 Subject: [PATCH 1/4] chore: remove risk tiers --- .../supplied-morpho-blue-grouped-table.tsx | 75 +--------------- .../components/user-vaults-table.tsx | 16 +--- .../components/vault-risk-indicators.tsx | 85 ------------------- 3 files changed, 4 insertions(+), 172 deletions(-) delete mode 100644 src/features/positions/components/vault-risk-indicators.tsx diff --git a/src/features/positions/components/supplied-morpho-blue-grouped-table.tsx b/src/features/positions/components/supplied-morpho-blue-grouped-table.tsx index 71107ceb..b655c415 100644 --- a/src/features/positions/components/supplied-morpho-blue-grouped-table.tsx +++ b/src/features/positions/components/supplied-morpho-blue-grouped-table.tsx @@ -21,7 +21,6 @@ import { usePositionsPreferences } from '@/stores/usePositionsPreferences'; import { usePositionsFilters } from '@/stores/usePositionsFilters'; import { useAppSettings } from '@/stores/useAppSettings'; import { useModalStore } from '@/stores/useModalStore'; -import { computeMarketWarnings } from '@/hooks/useMarketWarnings'; import { useRateLabel } from '@/hooks/useRateLabel'; import { useStyledToast } from '@/hooks/useStyledToast'; import useUserPositionsSummaryData, { type EarningsPeriod } from '@/hooks/useUserPositionsSummaryData'; @@ -29,72 +28,13 @@ import { formatReadable, formatBalance } from '@/utils/balance'; import { getNetworkImg } from '@/utils/networks'; import { getGroupedEarnings, groupPositionsByLoanAsset, processCollaterals } from '@/utils/positions'; import { convertApyToApr } from '@/utils/rateMath'; -import { type GroupedPosition, type WarningWithDetail, WarningCategory } from '@/utils/types'; -import { RiskIndicator } from '@/features/markets/components/risk-indicator'; +import type { GroupedPosition } from '@/utils/types'; import { useTokenPrices } from '@/hooks/useTokenPrices'; import { getTokenPriceKey } from '@/data-sources/morpho-api/prices'; import { PositionActionsDropdown } from './position-actions-dropdown'; import { SuppliedMarketsDetail } from './supplied-markets-detail'; import { CollateralIconsDisplay } from './collateral-icons-display'; -// Component to compute and display aggregated risk indicators for a group of positions -function AggregatedRiskIndicators({ groupedPosition }: { groupedPosition: GroupedPosition }) { - // Compute warnings for all markets in the group - const allWarnings: WarningWithDetail[] = []; - - for (const position of groupedPosition.markets) { - const marketWarnings = computeMarketWarnings(position.market, true); - allWarnings.push(...marketWarnings); - } - - // Remove duplicates based on warning code - const uniqueWarnings = allWarnings.filter((warning, index, array) => array.findIndex((w) => w.code === warning.code) === index); - - // Helper to get warnings by category and determine risk level - const getWarningIndicator = (category: WarningCategory, greenDesc: string, yellowDesc: string, redDesc: string) => { - const categoryWarnings = uniqueWarnings.filter((w) => w.category === category); - - if (categoryWarnings.length === 0) { - return ( - - ); - } - - if (categoryWarnings.some((w) => w.level === 'alert')) { - const alertWarning = categoryWarnings.find((w) => w.level === 'alert'); - return ( - - ); - } - - return ( - - ); - }; - - return ( - <> - {getWarningIndicator(WarningCategory.asset, 'Recognized asset', 'Asset with warning', 'High-risk asset')} - {getWarningIndicator(WarningCategory.oracle, 'Recognized oracles', 'Oracle warning', 'Oracle warning')} - {getWarningIndicator(WarningCategory.debt, 'No bad debt', 'Bad debt has occurred', 'Bad debt higher than 1% of supply')} - - ); -} - type SuppliedMorphoBlueGroupedTableProps = { account: string; }; @@ -224,7 +164,6 @@ export function SuppliedMorphoBlueGroupedTable({ account }: SuppliedMorphoBlueGr {rateLabel} (now) Interest Accrued ({period}) Collateral - Risk Tiers Actions @@ -339,16 +278,6 @@ export function SuppliedMorphoBlueGroupedTable({ account }: SuppliedMorphoBlueGr /> - {/* Risk indicators */} - -
- -
-
- {/* Actions button */} {rateLabel} (now) Interest Accrued Collateral - Risk Tiers Actions {activeVaults.length === 0 ? ( - +

No active positions in auto vaults.

@@ -196,16 +194,6 @@ export function UserVaultsTable({ vaults, account, refetch, isRefetching = false />
- {/* Risk Tiers */} - -
- -
-
- {/* Actions */}
@@ -223,7 +211,7 @@ export function UserVaultsTable({ vaults, account, refetch, isRefetching = false {isExpanded && ( { - const allWarnings: WarningWithDetail[] = []; - - vault.caps.forEach((cap) => { - const params = parseCapIdParams(cap.idParams); - - // Only process market caps (not collateral caps) - if (params.type === 'market' && params.marketId) { - const market = allMarkets.find((m) => m.uniqueKey.toLowerCase() === params.marketId?.toLowerCase()); - - if (market) { - const marketWarnings = computeMarketWarnings(market, true); - allWarnings.push(...marketWarnings); - } - } - }); - - // Remove duplicates based on warning code - return allWarnings.filter((warning, index, array) => array.findIndex((w) => w.code === warning.code) === index); - }, [vault.caps, allMarkets]); - - // Helper to get warnings by category and determine risk level - const getWarningIndicator = (category: WarningCategory, greenDesc: string, yellowDesc: string, redDesc: string) => { - const categoryWarnings = uniqueWarnings.filter((w) => w.category === category); - - if (categoryWarnings.length === 0) { - return ( - - ); - } - - if (categoryWarnings.some((w) => w.level === 'alert')) { - const alertWarning = categoryWarnings.find((w) => w.level === 'alert'); - return ( - - ); - } - - return ( - - ); - }; - - return ( - <> - {getWarningIndicator(WarningCategory.asset, 'Recognized asset', 'Asset with warning', 'High-risk asset')} - {getWarningIndicator(WarningCategory.oracle, 'Recognized oracles', 'Oracle warning', 'Oracle warning')} - {getWarningIndicator(WarningCategory.debt, 'No bad debt', 'Bad debt has occurred', 'Bad debt higher than 1% of supply')} - - ); -} From 259f00fdf99e21d36eb643d44a38708a8eff3083 Mon Sep 17 00:00:00 2001 From: antoncoding Date: Wed, 4 Feb 2026 17:00:12 +0800 Subject: [PATCH 2/4] feat: APY on table view --- .../supplied-morpho-blue-grouped-table.tsx | 37 +++++++++++++++++-- src/hooks/usePositionsWithEarnings.ts | 11 +++++- src/utils/positions.ts | 28 ++++++++++++++ src/utils/types.ts | 4 ++ 4 files changed, 76 insertions(+), 4 deletions(-) diff --git a/src/features/positions/components/supplied-morpho-blue-grouped-table.tsx b/src/features/positions/components/supplied-morpho-blue-grouped-table.tsx index b655c415..dfc18537 100644 --- a/src/features/positions/components/supplied-morpho-blue-grouped-table.tsx +++ b/src/features/positions/components/supplied-morpho-blue-grouped-table.tsx @@ -28,7 +28,6 @@ import { formatReadable, formatBalance } from '@/utils/balance'; import { getNetworkImg } from '@/utils/networks'; import { getGroupedEarnings, groupPositionsByLoanAsset, processCollaterals } from '@/utils/positions'; import { convertApyToApr } from '@/utils/rateMath'; -import type { GroupedPosition } from '@/utils/types'; import { useTokenPrices } from '@/hooks/useTokenPrices'; import { getTokenPriceKey } from '@/data-sources/morpho-api/prices'; import { PositionActionsDropdown } from './position-actions-dropdown'; @@ -162,7 +161,10 @@ export function SuppliedMorphoBlueGroupedTable({ account }: SuppliedMorphoBlueGr Network Size {rateLabel} (now) - Interest Accrued ({period}) + + {rateLabel} ({periodLabels[period]}) + + Interest Accrued ({periodLabels[period]}) Collateral Actions @@ -214,6 +216,35 @@ export function SuppliedMorphoBlueGroupedTable({ account }: SuppliedMorphoBlueGr
+ {/* Actual APY for period */} + +
+ {isEarningsLoading ? ( + + ) : ( + + } + > + + {formatReadable( + (isAprDisplay ? convertApyToApr(groupedPosition.actualApy) : groupedPosition.actualApy) * 100, + )} + % + + + )} +
+
+ {/* Accrued interest */}
@@ -308,7 +339,7 @@ export function SuppliedMorphoBlueGroupedTable({ account }: SuppliedMorphoBlueGr {expandedRows.has(rowKey) && ( { return useMemo(() => { if (!transactions || transactions.length === 0) { - return positions.map((p) => ({ ...p, earned: '0' })); + return positions.map((p) => ({ + ...p, + earned: '0', + actualApy: 0, + avgCapital: '0', + effectiveTime: 0, + })); } return positions.map((position) => { @@ -55,6 +61,9 @@ export const usePositionsWithEarnings = ( return { ...position, earned: earnings.earned.toString(), + actualApy: earnings.apy, + avgCapital: earnings.avgCapital.toString(), + effectiveTime: earnings.effectiveTime, }; }); }, [positions, transactions, snapshotsByChain, chainBlockData, endTimestamp]); diff --git a/src/utils/positions.ts b/src/utils/positions.ts index 2b18b05b..262d2145 100644 --- a/src/utils/positions.ts +++ b/src/utils/positions.ts @@ -276,6 +276,28 @@ export function getGroupedEarnings(groupedPosition: GroupedPosition): bigint { return total; } +/** + * Get weighted actual APY for a group of positions + * Weighted by time-weighted average capital (avgCapital) from each position + * + * @param groupedPosition - The grouped position + * @returns The weighted actual APY as a number + */ +export function getGroupedActualApy(groupedPosition: GroupedPosition): number { + let totalWeightedApy = 0; + let totalAvgCapital = 0n; + + for (const position of groupedPosition.markets) { + const avgCapital = BigInt(position.avgCapital ?? '0'); + if (avgCapital > 0n) { + totalWeightedApy += Number(avgCapital) * position.actualApy; + totalAvgCapital += avgCapital; + } + } + + return totalAvgCapital > 0n ? totalWeightedApy / Number(totalAvgCapital) : 0; +} + /** * Group positions by loan asset * @@ -301,6 +323,7 @@ export function groupPositionsByLoanAsset(positions: MarketPositionWithEarnings[ chainId, totalSupply: 0, totalWeightedApy: 0, + actualApy: 0, collaterals: [], markets: [], processedCollaterals: [], @@ -347,6 +370,8 @@ export function groupPositionsByLoanAsset(positions: MarketPositionWithEarnings[ } else { groupedPosition.totalWeightedApy = 0; // Avoid division by zero } + // Calculate weighted actual APY across markets + groupedPosition.actualApy = getGroupedActualApy(groupedPosition); return groupedPosition; }) .sort((a, b) => b.totalSupply - a.totalSupply); @@ -398,5 +423,8 @@ export function initializePositionsWithEmptyEarnings(positions: MarketPosition[] return positions.map((position) => ({ ...position, earned: '0', + actualApy: 0, + avgCapital: '0', + effectiveTime: 0, })); } diff --git a/src/utils/types.ts b/src/utils/types.ts index de641581..bfab8a8c 100644 --- a/src/utils/types.ts +++ b/src/utils/types.ts @@ -22,6 +22,9 @@ export type MarketPosition = { export type MarketPositionWithEarnings = MarketPosition & { earned: string; + actualApy: number; // Historical APY for the period + avgCapital: string; // Time-weighted average capital + effectiveTime: number; // Seconds held in period }; export enum UserTxTypes { @@ -227,6 +230,7 @@ export type GroupedPosition = { loanAssetSymbol: string; totalSupply: number; totalWeightedApy: number; + actualApy: number; // Weighted historical APY across all markets earned?: PositionEarnings; From 437f4146665765a23d288d72821b75d181f6c5ce Mon Sep 17 00:00:00 2001 From: antoncoding Date: Wed, 4 Feb 2026 17:29:51 +0800 Subject: [PATCH 3/4] feat: vault shares --- src/data-sources/subgraph/v2-vaults.ts | 1 + .../components/user-vaults-table.tsx | 64 ++++++- src/features/positions/positions-view.tsx | 21 ++- src/hooks/useVaultAllocations.ts | 14 +- src/hooks/useVaultHistoricalApy.ts | 173 ++++++++++++++++++ 5 files changed, 260 insertions(+), 13 deletions(-) create mode 100644 src/hooks/useVaultHistoricalApy.ts diff --git a/src/data-sources/subgraph/v2-vaults.ts b/src/data-sources/subgraph/v2-vaults.ts index 937e2339..e1270e27 100644 --- a/src/data-sources/subgraph/v2-vaults.ts +++ b/src/data-sources/subgraph/v2-vaults.ts @@ -29,6 +29,7 @@ export type UserVaultV2 = VaultV2Details & { networkId: SupportedNetworks; balance?: bigint; // User's redeemable assets (from previewRedeem) adapter?: Address; // MorphoMarketV1Adapter address + actualApy?: number; // Historical APY for the selected period }; /** diff --git a/src/features/positions/components/user-vaults-table.tsx b/src/features/positions/components/user-vaults-table.tsx index fcb2df03..c84c6841 100644 --- a/src/features/positions/components/user-vaults-table.tsx +++ b/src/features/positions/components/user-vaults-table.tsx @@ -1,6 +1,7 @@ import { Fragment, useState } from 'react'; import { AnimatePresence, motion } from 'framer-motion'; import Image from 'next/image'; +import { PulseLoader } from 'react-spinners'; import { RefetchIcon } from '@/components/ui/refetch-icon'; import { formatUnits } from 'viem'; import { Tooltip } from '@/components/ui/tooltip'; @@ -12,6 +13,7 @@ import { TableContainerWithHeader } from '@/components/common/table-container-wi import type { UserVaultV2 } from '@/data-sources/subgraph/v2-vaults'; import { useTokensQuery } from '@/hooks/queries/useTokensQuery'; import { useAppSettings } from '@/stores/useAppSettings'; +import type { EarningsPeriod } from '@/stores/usePositionsFilters'; import { useRateLabel } from '@/hooks/useRateLabel'; import { formatReadable } from '@/utils/balance'; import { getNetworkImg } from '@/utils/networks'; @@ -21,14 +23,29 @@ import { VaultAllocationDetail } from './vault-allocation-detail'; import { CollateralIconsDisplay } from './collateral-icons-display'; import { VaultActionsDropdown } from './vault-actions-dropdown'; +const periodLabels = { + day: '1D', + week: '7D', + month: '30D', +} as const; + type UserVaultsTableProps = { vaults: UserVaultV2[]; account: string; + period: EarningsPeriod; + isEarningsLoading?: boolean; refetch?: () => void; isRefetching?: boolean; }; -export function UserVaultsTable({ vaults, account, refetch, isRefetching = false }: UserVaultsTableProps) { +export function UserVaultsTable({ + vaults, + account, + period, + isEarningsLoading = false, + refetch, + isRefetching = false, +}: UserVaultsTableProps) { const [expandedRows, setExpandedRows] = useState>(new Set()); const { findToken } = useTokensQuery(); const { isAprDisplay } = useAppSettings(); @@ -89,7 +106,10 @@ export function UserVaultsTable({ vaults, account, refetch, isRefetching = false Network Size {rateLabel} (now) - Interest Accrued + + {rateLabel} ({periodLabels[period]}) + + Interest Accrued ({periodLabels[period]}) Collateral Actions @@ -97,7 +117,7 @@ export function UserVaultsTable({ vaults, account, refetch, isRefetching = false {activeVaults.length === 0 ? ( - +

No active positions in auto vaults.

@@ -132,6 +152,10 @@ export function UserVaultsTable({ vaults, account, refetch, isRefetching = false const avgApy = vault.avgApy; const displayRate = avgApy !== null && avgApy !== undefined && isAprDisplay ? convertApyToApr(avgApy) : avgApy; + // Historical APY display + const historicalApy = vault.actualApy; + const historicalDisplayRate = historicalApy !== undefined && isAprDisplay ? convertApyToApr(historicalApy) : historicalApy; + return (
- {/* APY/APR */} + {/* APY/APR (now) */}
@@ -177,8 +201,34 @@ export function UserVaultsTable({ vaults, account, refetch, isRefetching = false
- {/* Interest Accrued - TODO: implement vault earnings calculation */} - + {/* Historical APY/APR */} + +
+ {isEarningsLoading ? ( + + ) : ( + + } + > + + {historicalDisplayRate !== undefined ? `${formatReadable((historicalDisplayRate * 100).toString())}%` : '-'} + + + )} +
+
+ + {/* Interest Accrued */} +
-
@@ -211,7 +261,7 @@ export function UserVaultsTable({ vaults, account, refetch, isRefetching = false {isExpanded && ( (); const { open } = useModal(); const { chainId } = useConnection(); + const period = usePositionsFilters((s) => s.period); const { loading: isMarketsLoading } = useProcessedMarkets(); @@ -38,6 +41,18 @@ export default function Positions() { refetch: refetchVaults, } = useUserVaultsV2Query({ userAddress: account as Address }); + // Fetch historical APY for vaults + const { data: vaultApyData, isLoading: isVaultApyLoading } = useVaultHistoricalApy(vaults, period); + + // Merge APY data into vaults + const vaultsWithApy = useMemo(() => { + if (!vaultApyData) return vaults; + return vaults.map((vault) => ({ + ...vault, + actualApy: vaultApyData.get(vault.address.toLowerCase())?.actualApy, + })); + }, [vaults, vaultApyData]); + const router = useRouter(); // Calculate portfolio value from positions and vaults @@ -122,8 +137,10 @@ export default function Positions() { {!isVaultsLoading && hasVaults && ( void refetchVaults()} isRefetching={isVaultsRefetching} /> diff --git a/src/hooks/useVaultAllocations.ts b/src/hooks/useVaultAllocations.ts index 05659752..79851c24 100644 --- a/src/hooks/useVaultAllocations.ts +++ b/src/hooks/useVaultAllocations.ts @@ -35,10 +35,10 @@ type UseVaultAllocationsReturn = { * 5. Returns typed, ready-to-use allocation structures */ export function useVaultAllocations({ vaultAddress, chainId, enabled = true }: UseVaultAllocationsArgs): UseVaultAllocationsReturn { - const { allMarkets } = useProcessedMarkets(); + const { allMarkets, loading: marketsLoading } = useProcessedMarkets(); // Pull vault data directly - TanStack Query handles deduplication - const { data: vaultData } = useVaultV2Data({ vaultAddress, chainId }); + const { data: vaultData, isLoading: vaultDataLoading } = useVaultV2Data({ vaultAddress, chainId }); const collateralCaps = vaultData?.capsData?.collateralCaps ?? []; const marketCaps = vaultData?.capsData?.marketCaps ?? []; @@ -108,14 +108,20 @@ export function useVaultAllocations({ vaultAddress, chainId, enabled = true }: U const allValidCaps = useMemo(() => [...validCollateralCaps, ...validMarketCaps], [validCollateralCaps, validMarketCaps]); // Fetch allocations only for valid, recognized caps - const { allocations, isLoading, error, refetch } = useAllocationsQuery({ + const { + allocations, + isLoading: allocationsLoading, + error, + refetch, + } = useAllocationsQuery({ vaultAddress, chainId, caps: allValidCaps, enabled: enabled && allValidCaps.length > 0, }); - const loading = isLoading; + // Loading if any dependency is loading + const loading = marketsLoading || vaultDataLoading || allocationsLoading; // Create allocation map for efficient lookup const allocationMap = useMemo(() => { diff --git a/src/hooks/useVaultHistoricalApy.ts b/src/hooks/useVaultHistoricalApy.ts new file mode 100644 index 00000000..5bb2f278 --- /dev/null +++ b/src/hooks/useVaultHistoricalApy.ts @@ -0,0 +1,173 @@ +import { useMemo } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import type { Address } from 'viem'; +import { vaultv2Abi } from '@/abis/vaultv2'; +import { useCustomRpcContext } from '@/components/providers/CustomRpcProvider'; +import type { UserVaultV2 } from '@/data-sources/subgraph/v2-vaults'; +import type { EarningsPeriod } from '@/stores/usePositionsFilters'; +import { estimateBlockAtTimestamp } from '@/utils/blockEstimation'; +import type { SupportedNetworks } from '@/utils/networks'; +import { getClient } from '@/utils/rpc'; +import { useCurrentBlocks } from './queries/useCurrentBlocks'; +import { useBlockTimestamps } from './queries/useBlockTimestamps'; +import { getPeriodTimestamp } from './usePositionsWithEarnings'; + +const ONE_SHARE = 10n ** 18n; + +type VaultApyData = { + actualApy: number; +}; + +/** + * Fetches historical APY for vaults by comparing share prices at current and past blocks. + * APY = (currentSharePrice / pastSharePrice) ^ (365 * 86400 / periodSeconds) - 1 + */ +export const useVaultHistoricalApy = (vaults: UserVaultV2[], period: EarningsPeriod) => { + const { customRpcUrls } = useCustomRpcContext(); + + // Get unique chain IDs from vaults + const uniqueChainIds = useMemo(() => [...new Set(vaults.map((v) => v.networkId))], [vaults]); + + // Get current blocks for each chain + const { data: currentBlocks } = useCurrentBlocks(uniqueChainIds); + + // Estimate past blocks based on period + const snapshotBlocks = useMemo(() => { + if (!currentBlocks) return {}; + + const timestamp = getPeriodTimestamp(period); + const blocks: Record = {}; + + for (const chainId of uniqueChainIds) { + const currentBlock = currentBlocks[chainId]; + if (currentBlock) { + blocks[chainId] = estimateBlockAtTimestamp(chainId, timestamp, currentBlock); + } + } + + return blocks; + }, [period, uniqueChainIds, currentBlocks]); + + // Get actual timestamps for the snapshot blocks + const { data: actualBlockData } = useBlockTimestamps(snapshotBlocks); + + // Create a stable key for the query + const vaultAddresses = useMemo( + () => + vaults + .map((v) => v.address.toLowerCase()) + .sort() + .join(','), + [vaults], + ); + + return useQuery({ + queryKey: ['vault-historical-apy', vaultAddresses, period, actualBlockData], + queryFn: async () => { + if (!currentBlocks || !actualBlockData) { + return new Map(); + } + + const results = new Map(); + const endTimestamp = Math.floor(Date.now() / 1000); + + // Group vaults by network for efficient batching + const vaultsByNetwork = vaults.reduce( + (acc, vault) => { + if (!acc[vault.networkId]) { + acc[vault.networkId] = []; + } + acc[vault.networkId].push(vault); + return acc; + }, + {} as Record, + ); + + // Process each network in parallel + await Promise.all( + Object.entries(vaultsByNetwork).map(async ([networkIdStr, networkVaults]) => { + const networkId = Number(networkIdStr) as SupportedNetworks; + const client = getClient(networkId, customRpcUrls[networkId]); + const pastBlock = snapshotBlocks[networkId]; + const blockData = actualBlockData[networkId]; + + if (!pastBlock || !blockData) { + return; + } + + const startTimestamp = blockData.timestamp; + + // Create multicall contracts for current share prices + const currentContracts = networkVaults.map((vault) => ({ + address: vault.address as Address, + abi: vaultv2Abi, + functionName: 'convertToAssets' as const, + args: [ONE_SHARE], + })); + + // Create multicall contracts for past share prices + const pastContracts = networkVaults.map((vault) => ({ + address: vault.address as Address, + abi: vaultv2Abi, + functionName: 'convertToAssets' as const, + args: [ONE_SHARE], + })); + + try { + // Fetch current and past share prices in parallel + const [currentResults, pastResults] = await Promise.all([ + client.multicall({ + contracts: currentContracts, + allowFailure: true, + }), + client.multicall({ + contracts: pastContracts, + allowFailure: true, + blockNumber: BigInt(pastBlock), + }), + ]); + + // Calculate APY for each vault + for (const [index, vault] of networkVaults.entries()) { + const currentResult = currentResults[index]; + const pastResult = pastResults[index]; + + if (currentResult.status === 'success' && pastResult.status === 'success' && currentResult.result && pastResult.result) { + const currentSharePrice = currentResult.result as bigint; + const pastSharePrice = pastResult.result as bigint; + + // Skip if past share price is 0 or current is less than past (shouldn't happen) + if (pastSharePrice === 0n) { + continue; + } + + // Calculate APY + const periodSeconds = endTimestamp - startTimestamp; + if (periodSeconds <= 0) { + continue; + } + + const periodsPerYear = (365 * 86400) / periodSeconds; + const priceRatio = Number(currentSharePrice) / Number(pastSharePrice); + const apy = priceRatio ** periodsPerYear - 1; + + // Only include valid, non-negative APY + if (Number.isFinite(apy) && apy >= 0) { + results.set(vault.address.toLowerCase(), { actualApy: apy }); + } + } + } + } catch (error) { + console.error(`Failed to fetch vault APY for network ${networkId}:`, error); + } + }), + ); + + return results; + }, + enabled: vaults.length > 0 && !!currentBlocks && !!actualBlockData, + staleTime: 2 * 60 * 1000, // 2 minutes + gcTime: 5 * 60 * 1000, // 5 minutes + refetchOnWindowFocus: false, + }); +}; From a26c8238d818b9ef521a25622a226b1372eba93a Mon Sep 17 00:00:00 2001 From: antoncoding Date: Wed, 4 Feb 2026 17:32:09 +0800 Subject: [PATCH 4/4] chore: cleanup --- .../components/user-vaults-table.tsx | 48 ++++++++----------- src/features/positions/positions-view.tsx | 2 - src/hooks/useVaultHistoricalApy.ts | 23 ++------- 3 files changed, 23 insertions(+), 50 deletions(-) diff --git a/src/features/positions/components/user-vaults-table.tsx b/src/features/positions/components/user-vaults-table.tsx index c84c6841..63621c6c 100644 --- a/src/features/positions/components/user-vaults-table.tsx +++ b/src/features/positions/components/user-vaults-table.tsx @@ -29,6 +29,12 @@ const periodLabels = { month: '30D', } as const; +const formatRate = (rate: number | null | undefined, isApr: boolean): string => { + if (rate === null || rate === undefined) return '-'; + const displayRate = isApr ? convertApyToApr(rate) : rate; + return `${formatReadable((displayRate * 100).toString())}%`; +}; + type UserVaultsTableProps = { vaults: UserVaultV2[]; account: string; @@ -130,31 +136,19 @@ export function UserVaultsTable({ const token = findToken(vault.asset, vault.networkId); const networkImg = getNetworkImg(vault.networkId); - // Extract unique collateral addresses from caps - const collateralAddresses = vault.caps - .map((cap) => parseCapIdParams(cap.idParams).collateralToken) - .filter((collat) => collat !== undefined); - - const uniqueCollateralAddresses = Array.from(new Set(collateralAddresses)); + // Extract unique collateral addresses from caps and transform for display + const uniqueCollateralAddresses = [ + ...new Set(vault.caps.map((cap) => parseCapIdParams(cap.idParams).collateralToken).filter((addr) => addr !== undefined)), + ]; - // Transform to format expected by CollateralIconsDisplay - const collaterals = uniqueCollateralAddresses - .map((address) => { - const collateralToken = findToken(address, vault.networkId); - return { - address, - symbol: collateralToken?.symbol ?? 'Unknown', - amount: 1, // Use 1 as placeholder since we're just showing presence - }; - }) - .filter((c) => c !== null); + const collaterals = uniqueCollateralAddresses.map((address) => ({ + address, + symbol: findToken(address, vault.networkId)?.symbol ?? 'Unknown', + amount: 1, // Placeholder - we're just showing presence + })); - const avgApy = vault.avgApy; - const displayRate = avgApy !== null && avgApy !== undefined && isAprDisplay ? convertApyToApr(avgApy) : avgApy; - - // Historical APY display - const historicalApy = vault.actualApy; - const historicalDisplayRate = historicalApy !== undefined && isAprDisplay ? convertApyToApr(historicalApy) : historicalApy; + const currentRateDisplay = formatRate(vault.avgApy, isAprDisplay); + const historicalRateDisplay = formatRate(vault.actualApy, isAprDisplay); return ( @@ -195,9 +189,7 @@ export function UserVaultsTable({ {/* APY/APR (now) */}
- - {displayRate !== null && displayRate !== undefined ? `${(displayRate * 100).toFixed(2)}%` : '-'} - + {currentRateDisplay}
@@ -219,9 +211,7 @@ export function UserVaultsTable({ /> } > - - {historicalDisplayRate !== undefined ? `${formatReadable((historicalDisplayRate * 100).toString())}%` : '-'} - + {historicalRateDisplay} )}
diff --git a/src/features/positions/positions-view.tsx b/src/features/positions/positions-view.tsx index b36f8789..8e78d613 100644 --- a/src/features/positions/positions-view.tsx +++ b/src/features/positions/positions-view.tsx @@ -78,8 +78,6 @@ export default function Positions() {

Portfolio

- {' '} - {/* aligned with portfolio */}