diff --git a/.gitignore b/.gitignore index 0c7ba9eb..de4ec81b 100644 --- a/.gitignore +++ b/.gitignore @@ -56,3 +56,4 @@ FULLAUTO_CONTEXT.md .claude/settings.local.json .omx/ +.agents/skills diff --git a/app/analysis/page.tsx b/app/analysis/page.tsx new file mode 100644 index 00000000..7ced1667 --- /dev/null +++ b/app/analysis/page.tsx @@ -0,0 +1,13 @@ +import { generateMetadata } from '@/utils/generateMetadata'; +import AnalysisView from '@/features/analysis/analysis-view'; + +export const metadata = generateMetadata({ + title: 'Analysis | Monarch', + description: 'Global Morpho market risk, oracle, peg, and asset exposure analysis', + images: 'themes.png', + pathname: '/analysis', +}); + +export default function AnalysisPage() { + return ; +} diff --git a/next.config.js b/next.config.js index bf68f83c..b518e30c 100644 --- a/next.config.js +++ b/next.config.js @@ -13,15 +13,7 @@ const nextConfig = { remotePatterns: [ { protocol: 'https', - hostname: 'ipfs.io', - }, - { - protocol: 'https', - hostname: 'effigy.im', - }, - { - protocol: 'https', - hostname: 'api.dicebear.com', + hostname: '**', }, ], }, diff --git a/skills-lock.json b/skills-lock.json new file mode 100644 index 00000000..097a2ccb --- /dev/null +++ b/skills-lock.json @@ -0,0 +1,90 @@ +{ + "version": 1, + "skills": { + "adapt": { + "source": "pbakaus/impeccable", + "sourceType": "github", + "computedHash": "65085998d643c3b64ff323ca74d76496b47484046b030730c00c03a0208bf410" + }, + "animate": { + "source": "pbakaus/impeccable", + "sourceType": "github", + "computedHash": "354414a934957f9d54a71468abe81888304d35b47a4fe8e27fff08f60b457d53" + }, + "audit": { + "source": "pbakaus/impeccable", + "sourceType": "github", + "computedHash": "115a00d21d91d52123115af789948cb22bd604de0084837b5533f5e4af3c4055" + }, + "bolder": { + "source": "pbakaus/impeccable", + "sourceType": "github", + "computedHash": "1787478962577c9d5bde26e89e47c1654aa9c8f3c47c316a026731db0000a92e" + }, + "clarify": { + "source": "pbakaus/impeccable", + "sourceType": "github", + "computedHash": "42877142fcc51dcc6be696349aa7df8887eb29510e2a5868beac913f5f84de31" + }, + "colorize": { + "source": "pbakaus/impeccable", + "sourceType": "github", + "computedHash": "4793ac377b2bc1a5831d9634ce882373350cc5d097fd631d192155266204ceb8" + }, + "critique": { + "source": "pbakaus/impeccable", + "sourceType": "github", + "computedHash": "977f6fc3aa1002ec095f649e1b7c4fa52ee08a447f19229062810a9323c5c342" + }, + "delight": { + "source": "pbakaus/impeccable", + "sourceType": "github", + "computedHash": "1f6f4adee0b964e8344c0baef46b4e303ab3ba20932907bcbb1444ba7b3273b2" + }, + "distill": { + "source": "pbakaus/impeccable", + "sourceType": "github", + "computedHash": "669a671f855be8773e4c2936e54d4243fbd1d273f87633158544efe3dbc4d825" + }, + "impeccable": { + "source": "pbakaus/impeccable", + "sourceType": "github", + "computedHash": "a26b3dd377c4572b1c34c68f82ab9a1cb806f21c7157060327fe60e9b6d2e277" + }, + "layout": { + "source": "pbakaus/impeccable", + "sourceType": "github", + "computedHash": "6b5fd49587616ca1e594e9c6b11bc1cef5eb0b2d137c307de54a8a650770f4ce" + }, + "optimize": { + "source": "pbakaus/impeccable", + "sourceType": "github", + "computedHash": "979ffc76a4345c081fdf89078b7107c216d70696e027b23e8e24972dd4e457b2" + }, + "overdrive": { + "source": "pbakaus/impeccable", + "sourceType": "github", + "computedHash": "30e2909c14b9e860b580e63f9bbe10cc1dee8de0be9b03013218c3ef8d9ede78" + }, + "polish": { + "source": "pbakaus/impeccable", + "sourceType": "github", + "computedHash": "c294f2570dcc5b8865d17f0dc5ef339a700c868f1b20fa9994297f64ace2d4de" + }, + "quieter": { + "source": "pbakaus/impeccable", + "sourceType": "github", + "computedHash": "2c20b12bb4ece445d45fc63bda020aecf78b3cdf6d593beb59c273f658293130" + }, + "shape": { + "source": "pbakaus/impeccable", + "sourceType": "github", + "computedHash": "8edad33758f2461f51afdb51741bfcc4cc57d84ea42e2751a84be2c6a2d48f69" + }, + "typeset": { + "source": "pbakaus/impeccable", + "sourceType": "github", + "computedHash": "1cd76e560d1ba25b0bd5f7adfa2957a9f653a3ad0b031d467221154fcd1e0423" + } + } +} diff --git a/src/components/DataPrefetcher.tsx b/src/components/DataPrefetcher.tsx index ae47bdd0..37bdd0b8 100644 --- a/src/components/DataPrefetcher.tsx +++ b/src/components/DataPrefetcher.tsx @@ -23,7 +23,7 @@ function DataPrefetcherContent() { export function DataPrefetcher() { const pathname = usePathname(); - if (pathname?.startsWith('/ui-lab')) { + if (pathname?.startsWith('/ui-lab') || pathname?.startsWith('/analysis')) { return null; } diff --git a/src/components/layout/header/Navbar.tsx b/src/components/layout/header/Navbar.tsx index cf2e9970..c2780ab0 100644 --- a/src/components/layout/header/Navbar.tsx +++ b/src/components/layout/header/Navbar.tsx @@ -5,12 +5,12 @@ import { ChevronDownIcon } from '@radix-ui/react-icons'; import { clsx } from 'clsx'; import Image from 'next/image'; import Link from 'next/link'; -import { usePathname } from 'next/navigation'; +import { usePathname, useRouter } from 'next/navigation'; import { useTheme } from 'next-themes'; import { FaRegMoon } from 'react-icons/fa'; import { GearIcon } from '@radix-ui/react-icons'; import { LuSunMedium } from 'react-icons/lu'; -import { RiBookLine, RiDiscordFill, RiGithubFill } from 'react-icons/ri'; +import { RiBookLine, RiDiscordFill, RiGithubFill, RiPieChart2Line } from 'react-icons/ri'; import { useConnection } from 'wagmi'; import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem } from '@/components/ui/dropdown-menu'; import { useModal } from '@/hooks/useModal'; @@ -77,6 +77,7 @@ export function NavbarTitle() { export function Navbar() { const { theme, setTheme } = useTheme(); const { address } = useConnection(); + const router = useRouter(); const { open: openModal } = useModal(); const [mounted, setMounted] = useState(false); const [isMoreOpen, setIsMoreOpen] = useState(false); @@ -167,6 +168,12 @@ export function Navbar() { align="end" className="min-w-[180px]" > + } + onClick={() => router.push('/analysis')} + > + Analysis + } onClick={() => window.open(EXTERNAL_LINKS.docs, '_blank')} diff --git a/src/components/layout/header/NavbarMobile.tsx b/src/components/layout/header/NavbarMobile.tsx index 35d83359..6c96eaa1 100644 --- a/src/components/layout/header/NavbarMobile.tsx +++ b/src/components/layout/header/NavbarMobile.tsx @@ -9,7 +9,7 @@ import { useRouter } from 'next/navigation'; import { useTheme } from 'next-themes'; import { FaRegMoon } from 'react-icons/fa'; import { LuSunMedium } from 'react-icons/lu'; -import { RiBookLine, RiDiscordFill, RiGithubFill, RiLineChartLine, RiBriefcaseLine, RiGiftLine } from 'react-icons/ri'; +import { RiBookLine, RiDiscordFill, RiGithubFill, RiLineChartLine, RiBriefcaseLine, RiGiftLine, RiPieChart2Line } from 'react-icons/ri'; import { useConnection } from 'wagmi'; import { DropdownMenu, @@ -99,6 +99,13 @@ export default function NavbarMobile() { > Markets + } + onClick={() => handleNavigation('/analysis')} + className="py-3" + > + Analysis + } onClick={() => handleNavigation(mounted && address ? `/positions/${address}` : '/positions')} diff --git a/src/components/ui/button-group.tsx b/src/components/ui/button-group.tsx index 5997b112..92f6a1c3 100644 --- a/src/components/ui/button-group.tsx +++ b/src/components/ui/button-group.tsx @@ -14,7 +14,7 @@ type ButtonGroupProps = { value: string; onChange: (value: ButtonOption['value']) => void; size?: 'sm' | 'md' | 'lg'; - variant?: 'default' | 'primary'; + variant?: 'default' | 'primary' | 'compact'; equalWidth?: boolean; }; @@ -40,12 +40,21 @@ const variantStyles = { ? 'before:bg-gradient-to-b before:from-white/10 before:to-transparent' : 'before:bg-gradient-to-b before:from-transparent before:to-black/5', ], + compact: (isSelected: boolean) => [ + isSelected ? 'bg-hovered text-primary' : 'bg-transparent text-primary hover:bg-hovered/70', + 'border-0 shadow-none', + ], }; export default function ButtonGroup({ options, value, onChange, size = 'md', variant = 'default', equalWidth = false }: ButtonGroupProps) { + const isCompact = variant === 'compact'; + return (
@@ -66,11 +75,11 @@ export default function ButtonGroup({ options, value, onChange, size = 'md', var equalWidth && 'min-w-[3rem] text-center', // Position-based styles - isFirst ? 'rounded-l' : '-ml-px rounded-none', - isLast ? 'rounded-r' : 'rounded-none', + isCompact ? 'rounded-none' : isFirst ? 'rounded-l' : '-ml-px rounded-none', + !isCompact && (isLast ? 'rounded-r' : 'rounded-none'), // Variant & State styles - variant === 'default' ? variantStyles.default(isSelected) : variantStyles.primary(isSelected), + variantStyles[variant](isSelected), // Hover & Focus styles 'hover:relative hover:z-20', diff --git a/src/features/analysis/analysis-view.tsx b/src/features/analysis/analysis-view.tsx new file mode 100644 index 00000000..bcb47f28 --- /dev/null +++ b/src/features/analysis/analysis-view.tsx @@ -0,0 +1,1275 @@ +'use client'; + +import { useMemo, useState } from 'react'; +import { formatUnits } from 'viem'; +import Image from 'next/image'; +import Link from 'next/link'; +import { Cell, Legend as RechartsLegend, Pie, PieChart, ResponsiveContainer, Tooltip as RechartsTooltip } from 'recharts'; +import { RiDatabase2Line, RiErrorWarningLine, RiNodeTree, RiPieChart2Line, RiScales3Line } from 'react-icons/ri'; +import Header from '@/components/layout/header/Header'; +import { AddressIdentity } from '@/components/shared/address-identity'; +import { NetworkIcon } from '@/components/shared/network-icon'; +import { TokenIcon } from '@/components/shared/token-icon'; +import ButtonGroup, { type ButtonOption } from '@/components/ui/button-group'; +import { Card } from '@/components/ui/card'; +import { Spinner } from '@/components/ui/spinner'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { Tooltip } from '@/components/ui/tooltip'; +import { useAllOracleMetadata } from '@/hooks/useOracleMetadata'; +import { useProcessedMarkets } from '@/hooks/useProcessedMarkets'; +import { useChartColors } from '@/constants/chartColors'; +import NetworkFilter from '@/features/markets/components/filters/network-filter'; +import { MarketIdentity, MarketIdentityFocus, MarketIdentityMode } from '@/features/markets/components/market-identity'; +import OracleVendorBadge from '@/features/markets/components/oracle-vendor-badge'; +import { FeedEntry } from '@/features/markets/components/oracle'; +import { getOracleFromMetadata, type OracleOutputData } from '@/hooks/useOracleMetadata'; +import { formatReadableTokenAmount } from '@/utils/balance'; +import { getNetworkName, type SupportedNetworks } from '@/utils/networks'; +import { OracleVendorIcons, PriceFeedVendors } from '@/utils/oracle'; +import { formatUsdValue } from '@/utils/portfolio'; +import { cn } from '@/utils'; +import { + buildRiskAnalysis, + type AnalysisAssetBucket, + type AnalysisBucket, + type AnalysisBucketMarket, + type AnalysisExposureMetric, + type AnalysisMarketRow, +} from './utils/oracle-risk-analysis'; + +const EXPOSURE_OPTIONS: ButtonOption[] = [ + { key: 'supply', label: 'Supply', value: 'supply' }, + { key: 'borrow', label: 'Borrow', value: 'borrow' }, +]; + +const DETAIL_OPTIONS: ButtonOption[] = [ + { key: 'assets', label: 'Asset Split', value: 'assets' }, + { key: 'chains', label: 'Chain Split', value: 'chains' }, +]; + +type AssumptionMode = 'peg' | 'vault'; +type ProviderDetailMode = 'assets' | 'chains'; + +const ASSUMPTION_OPTIONS: ButtonOption[] = [ + { key: 'peg', label: 'Peg Assumption', value: 'peg' }, + { key: 'vault', label: 'Vault Conversion', value: 'vault' }, +]; + +const OTHER_BUCKET_KEY = '__other'; +const MAX_TABLE_ROWS = 8; + +type DonutDataPoint = { + key: string; + label: string; + valueUsd: number; + marketCount: number; +}; + +const formatPercent = (value: number, total: number): string => { + if (total <= 0 || value <= 0) return '0.00%'; + const percent = (value / total) * 100; + if (percent > 0 && percent < 0.01) return '<0.01%'; + return `${percent >= 10 ? percent.toFixed(1) : percent.toFixed(2)}%`; +}; + +const formatAssumptionLabel = (label: string): string => { + return label + .replace(/\s+peg$/i, '') + .replace(/\s+vault conversion$/i, '') + .trim(); +}; + +function UsdWithPercent({ valueUsd, totalUsd }: { valueUsd: number; totalUsd: number }) { + return ( + + {formatUsdValue(valueUsd)} + ({formatPercent(valueUsd, totalUsd)}) + + ); +} + +const getMarketHref = (row: AnalysisMarketRow): string => `/market/${row.chainId}/${row.market.uniqueKey}`; + +const getDonutData = (buckets: AnalysisBucket[]): DonutDataPoint[] => { + return buckets.map((bucket) => ({ + key: bucket.key, + label: bucket.label, + valueUsd: bucket.valueUsd, + marketCount: bucket.marketCount, + })); +}; + +const isKnownVendor = (label: string): label is PriceFeedVendors => { + return Object.values(PriceFeedVendors).includes(label as PriceFeedVendors); +}; + +const PROVIDER_LINKS: Record = { + [PriceFeedVendors.Chainlink]: 'https://chain.link/data-feeds', + [PriceFeedVendors.Redstone]: 'https://redstone.finance/', + [PriceFeedVendors.PythNetwork]: 'https://pyth.network/', + [PriceFeedVendors.Chronicle]: 'https://chroniclelabs.org/', + [PriceFeedVendors.API3]: 'https://api3.org/', + [PriceFeedVendors.Pendle]: 'https://www.pendle.finance/', + [PriceFeedVendors.Lido]: 'https://lido.fi/', + [PriceFeedVendors.Compound]: 'https://compound.finance/', + [PriceFeedVendors.Oval]: 'https://oval.xyz/', + [PriceFeedVendors.Midas]: 'https://midas.app/', + [PriceFeedVendors.Unknown]: undefined, +}; + +const UNKNOWN_VENDOR_COLOR = '#94A3B8'; + +const getProviderLink = (label: string) => PROVIDER_LINKS[label]; + +function VendorIcon({ label, size = 20 }: { label: string; size?: number }) { + const icon = isKnownVendor(label) ? OracleVendorIcons[label] : ''; + + if (!icon) { + return ( + + ? + + ); + } + + return ( + {label} + ); +} + +function MetricCard({ label, value, detail, icon }: { label: string; value: string; detail: string; icon: React.ReactNode }) { + return ( + +
+
+ {label} + {value} + {detail} +
+ {icon} +
+
+ ); +} + +function Controls({ + exposureMetric, + onExposureMetricChange, + selectedNetwork, + setSelectedNetwork, +}: { + exposureMetric: AnalysisExposureMetric; + onExposureMetricChange: (value: AnalysisExposureMetric) => void; + selectedNetwork: SupportedNetworks | null; + setSelectedNetwork: (network: SupportedNetworks | null) => void; +}) { + return ( +
+ + onExposureMetricChange(value as AnalysisExposureMetric)} + size="sm" + variant="compact" + /> +
+ ); +} + +function DonutTooltip({ active, payload, totalUsd }: { active?: boolean; payload?: { payload: DonutDataPoint }[]; totalUsd: number }) { + if (!active || !payload?.[0]) return null; + const data = payload[0].payload; + + return ( +
+
+ {data.key !== OTHER_BUCKET_KEY && } + {data.label} +
+
+
+ Exposure + {formatUsdValue(data.valueUsd)} +
+
+ Share + {formatPercent(data.valueUsd, totalUsd)} +
+
+ Markets + {data.marketCount} +
+
+
+ ); +} + +function OracleDonut({ buckets, activeKey, onSelect }: { buckets: AnalysisBucket[]; activeKey?: string; onSelect: (key: string) => void }) { + const chartColors = useChartColors(); + const data = useMemo(() => getDonutData(buckets), [buckets]); + const [hiddenKeys, setHiddenKeys] = useState>(new Set()); + + const colorByKey = useMemo(() => { + return new Map( + data.map((entry, index) => [ + entry.key, + entry.key === OTHER_BUCKET_KEY || entry.label === PriceFeedVendors.Unknown + ? UNKNOWN_VENDOR_COLOR + : chartColors.pie[index % chartColors.pie.length], + ]), + ); + }, [chartColors.pie, data]); + + const visibleData = useMemo(() => { + const nextVisible = data.filter((entry) => !hiddenKeys.has(entry.key)); + return nextVisible.length > 0 ? nextVisible : data; + }, [data, hiddenKeys]); + + const visibleTotalUsd = visibleData.reduce((total, entry) => total + entry.valueUsd, 0); + + const toggleHiddenKey = (key: string) => { + const isCurrentlyHidden = hiddenKeys.has(key); + if (!isCurrentlyHidden && data.filter((entry) => !hiddenKeys.has(entry.key)).length <= 1) { + return; + } + + if (!isCurrentlyHidden && key === activeKey) { + const replacement = data.find((entry) => entry.key !== key && !hiddenKeys.has(entry.key)); + if (replacement && replacement.key !== OTHER_BUCKET_KEY) { + onSelect(replacement.key); + } + } + + setHiddenKeys((current) => { + const isHidden = current.has(key); + const next = new Set(current); + if (isHidden) { + next.delete(key); + } else { + next.add(key); + } + return next; + }); + }; + + if (data.length === 0) { + return ; + } + + return ( +
+ + + { + const key = visibleData[index]?.key; + if (key && key !== OTHER_BUCKET_KEY) { + onSelect(key); + } + }} + className="[&_sector]:outline-none" + rootTabIndex={-1} + style={{ + cursor: 'pointer', + outline: 'none', + userSelect: 'none', + WebkitTapHighlightColor: 'transparent', + }} + > + {visibleData.map((entry) => ( + + ))} + + } /> + ( +
+ {data.map((entry) => { + const isHidden = hiddenKeys.has(entry.key); + return ( + + ); + })} +
+ )} + /> +
+
+
+ ); +} + +type ProviderAssetSplitItem = { + key: string; + label: string; + address: string; + chainId: number; + symbol: string; + valueUsd: number; + marketCount: number; +}; + +const getProviderAssetSplit = (bucket: AnalysisBucket | undefined): ProviderAssetSplitItem[] => { + if (!bucket) return []; + + const items = new Map(); + for (const { row, attributedUsd } of bucket.markets) { + const { loanAsset } = row.market; + const key = `${row.chainId}:${loanAsset.address.toLowerCase()}`; + const chainName = getNetworkName(row.chainId) ?? `Chain ${row.chainId}`; + const current = + items.get(key) ?? + ({ + key, + label: `${loanAsset.symbol} · ${chainName}`, + address: loanAsset.address, + chainId: row.chainId, + symbol: loanAsset.symbol, + valueUsd: 0, + marketCount: 0, + } satisfies ProviderAssetSplitItem); + + current.valueUsd += attributedUsd; + current.marketCount += 1; + items.set(key, current); + } + + return Array.from(items.values()).sort((left, right) => right.valueUsd - left.valueUsd); +}; + +const getOracleTypeSummary = (bucket: AnalysisBucket | undefined): string => { + if (!bucket) return 'No oracle data'; + const counts = new Map(); + for (const { row } of bucket.markets) { + const label = row.oracleType === 'missing' ? 'Unknown' : row.oracleType[0].toUpperCase() + row.oracleType.slice(1); + counts.set(label, (counts.get(label) ?? 0) + 1); + } + + return Array.from(counts.entries()) + .sort((left, right) => right[1] - left[1]) + .map(([label, count]) => `${label} ${count}`) + .join(' · '); +}; + +function SplitRow({ + label, + valueUsd, + totalUsd, + maxValueUsd, + leading, +}: { + label: string; + valueUsd: number; + totalUsd: number; + maxValueUsd: number; + leading: React.ReactNode; +}) { + return ( +
+
+
+ {leading} + {label} +
+ +
+
+
+
+
+ ); +} + +function ProviderDetailPanel({ + bucket, + totalUsd, + exposureMetric, +}: { + bucket?: AnalysisBucket; + totalUsd: number; + exposureMetric: AnalysisExposureMetric; +}) { + const [detailMode, setDetailMode] = useState('assets'); + + if (!bucket) { + return ; + } + + const providerLink = getProviderLink(bucket.label); + const exposureLabel = exposureMetric === 'borrow' ? 'Borrow' : 'Supply'; + const assetSplit = getProviderAssetSplit(bucket); + const maxAssetUsd = Math.max(...assetSplit.map((item) => item.valueUsd), 1); + const maxChainUsd = Math.max(...bucket.chainBreakdown.map((chain) => chain.valueUsd), 1); + + return ( + +
+
+
+ +
+

{bucket.label}

+

{getOracleTypeSummary(bucket)}

+
+
+ {providerLink && ( + + Source + + )} +
+ +
+
+ {exposureLabel} + {formatUsdValue(bucket.valueUsd)} +
+
+ Share + {formatPercent(bucket.valueUsd, totalUsd)} +
+
+ Markets + {bucket.marketCount} +
+
+ +
+ setDetailMode(value as ProviderDetailMode)} + size="sm" + /> +
+ +
+ {detailMode === 'assets' + ? assetSplit.slice(0, 6).map((item) => ( + + } + /> + )) + : bucket.chainBreakdown.slice(0, 6).map((chain) => ( + + } + /> + ))} +
+
+
+ ); +} + +function EmptyPanel({ label }: { label: string }) { + return
{label}
; +} + +function LoadingPanel() { + return ( + +
+ + Loading analysis data +
+
+ ); +} + +function ErrorPanel({ message }: { message: string }) { + return ( + +
+ Analysis unavailable + {message} +
+
+ ); +} + +function OracleExposureSection({ + buckets, + activeBucket, + activeKey, + setActiveKey, + totalUsd, + exposureMetric, + oracleMetadataMap, +}: { + buckets: AnalysisBucket[]; + activeBucket?: AnalysisBucket; + activeKey?: string; + setActiveKey: (key: string) => void; + totalUsd: number; + exposureMetric: AnalysisExposureMetric; + oracleMetadataMap?: ReturnType['data']; +}) { + return ( +
+
+ +
+
+

Oracle Vendor Exposure

+ Click legend items to hide slices +
+
+
+ +
+
+ +
+ + total + (exposureMetric === 'borrow' ? row.borrowUsd : row.supplyUsd), 0) ?? + totalUsd + } + exposureMetric={exposureMetric} + showAssumptions={false} + showFeeds + oracleMetadataMap={oracleMetadataMap} + /> +
+ ); +} + +function AssumptionBars({ + title, + buckets, + activeKey, + onSelect, + totalUsd, + totalLabel, +}: { + title: string; + buckets: AnalysisBucket[]; + activeKey?: string; + onSelect: (key: string) => void; + totalUsd: number; + totalLabel: string; +}) { + return ( + +
+

{title}

+ + % of {totalLabel}: {formatUsdValue(totalUsd)} + +
+ {buckets.length === 0 ? ( + + ) : ( +
+ {buckets.slice(0, 8).map((bucket) => { + const isActive = bucket.key === activeKey; + return ( + + ); + })} +
+ )} +
+ ); +} + +function AssumptionsSection({ + mode, + onModeChange, + buckets, + activeBucket, + activeKey, + setActiveKey, + totalUsd, + exposureMetric, +}: { + mode: AssumptionMode; + onModeChange: (mode: AssumptionMode) => void; + buckets: AnalysisBucket[]; + activeBucket?: AnalysisBucket; + activeKey?: string; + setActiveKey: (key: string) => void; + totalUsd: number; + exposureMetric: AnalysisExposureMetric; +}) { + const title = mode === 'peg' ? 'Peg Assumptions' : 'Vault Conversions'; + return ( +
+
+ onModeChange(value as AssumptionMode)} + size="sm" + /> + + {buckets.length} {mode === 'peg' ? 'peg' : 'vault'} buckets + +
+ + + + +
+ ); +} + +function AssetDominanceBars({ title, buckets, totalUsd }: { title: string; buckets: AnalysisAssetBucket[]; totalUsd: number }) { + const maxValue = Math.max(...buckets.map((bucket) => bucket.valueUsd), 1); + + return ( + +
+

{title}

+ Top assets +
+ {buckets.length === 0 ? ( + + ) : ( +
+ {buckets.slice(0, 8).map((bucket) => ( +
+
+
+ + {bucket.label} +
+ +
+
+
+
+
+ {bucket.marketCount} markets +
+
+ ))} +
+ )} + + ); +} + +function AssetDominanceSection({ + loanAssetBuckets, + collateralAssetBuckets, + totalExposureUsd, + totalCollateralUsd, +}: { + loanAssetBuckets: AnalysisAssetBucket[]; + collateralAssetBuckets: AnalysisAssetBucket[]; + totalExposureUsd: number; + totalCollateralUsd: number; +}) { + return ( +
+ + +
+ ); +} + +function AnalysisTableContainer({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+
+

{title}

+
+
{children}
+
+ ); +} + +function AssumptionBadges({ assumptions }: { assumptions: string[] }) { + if (assumptions.length === 0) { + return -; + } + + return ( +
+ {assumptions.slice(0, 2).map((assumption) => ( + {assumption}} + > + {formatAssumptionLabel(assumption)} + + ))} + {assumptions.length > 2 && +{assumptions.length - 2}} +
+ ); +} + +function VaultDependencyBadges({ row }: { row: AnalysisMarketRow }) { + if (row.vaultDependencies.length === 0) { + return -; + } + + return ( +
+ {row.vaultDependencies.slice(0, 2).map((dependency) => ( + + ))} + {row.vaultDependencies.length > 2 && ( + +{row.vaultDependencies.length - 2} + )} +
+ ); +} + +const formatLoanAssetAmount = (row: AnalysisMarketRow, exposureMetric: AnalysisExposureMetric, forceSupply: boolean): string => { + const rawAmount = forceSupply || exposureMetric === 'supply' ? row.market.state.supplyAssets : row.market.state.borrowAssets; + try { + const amount = formatUnits(BigInt(rawAmount), row.market.loanAsset.decimals); + return `${formatReadableTokenAmount(amount)} ${row.market.loanAsset.symbol}`; + } catch { + return `0 ${row.market.loanAsset.symbol}`; + } +}; + +const getOracleDataForFeeds = (row: AnalysisMarketRow, oracleMetadataMap?: ReturnType['data']) => { + const oracle = getOracleFromMetadata(oracleMetadataMap, row.market.oracleAddress, row.chainId); + if (!oracle) return null; + if (oracle.type === 'standard') return oracle.data; + + if (oracle.type === 'meta') { + const currentOracle = oracle.data.currentOracle?.toLowerCase(); + const primaryOracle = oracle.data.primaryOracle?.toLowerCase(); + const backupOracle = oracle.data.backupOracle?.toLowerCase(); + + if (currentOracle && currentOracle === primaryOracle) { + return oracle.data.oracleSources.primary; + } + + if (currentOracle && currentOracle === backupOracle) { + return oracle.data.oracleSources.backup; + } + + return oracle.data.oracleSources.primary ?? oracle.data.oracleSources.backup; + } + + return null; +}; + +type OracleFeedPath = NonNullable; + +const getOracleFeedPaths = (oracleData: OracleOutputData | null): OracleFeedPath[] => { + if (!oracleData) return []; + return [oracleData.baseFeedOne, oracleData.baseFeedTwo, oracleData.quoteFeedOne, oracleData.quoteFeedTwo].filter( + (path): path is OracleFeedPath => path != null, + ); +}; + +function OracleFeedsCell({ + row, + oracleMetadataMap, +}: { + row: AnalysisMarketRow; + oracleMetadataMap?: ReturnType['data']; +}) { + const oracleData = getOracleDataForFeeds(row, oracleMetadataMap); + const paths = getOracleFeedPaths(oracleData); + + if (paths.length === 0) { + return -; + } + + return ( +
+ {paths.map((path) => ( + + ))} +
+ ); +} + +function OracleVendorCell({ row }: { row: AnalysisMarketRow }) { + if (!row.market.oracleAddress) { + return -; + } + + return ( + + ); +} + +function MarketExposureTable({ + title, + entries, + totalUsd, + assumptionMode, + exposureMetric, + showAssumptions = true, + showFeeds = false, + forceSupplyAmounts = false, + centerRiskCell = false, + oracleMetadataMap, +}: { + title: string; + entries: AnalysisBucketMarket[]; + totalUsd: number; + assumptionMode?: AssumptionMode; + exposureMetric: AnalysisExposureMetric; + showAssumptions?: boolean; + showFeeds?: boolean; + forceSupplyAmounts?: boolean; + centerRiskCell?: boolean; + oracleMetadataMap?: ReturnType['data']; +}) { + const usdHeader = forceSupplyAmounts ? 'Supply (USD)' : exposureMetric === 'borrow' ? 'Borrow (USD)' : 'Supply (USD)'; + const amountHeader = forceSupplyAmounts ? 'Supply Asset' : exposureMetric === 'borrow' ? 'Borrow Asset' : 'Supply Asset'; + const riskColumnHeader = assumptionMode === 'vault' ? 'Dependencies' : 'Assumptions'; + + return ( + + {entries.length === 0 ? ( +
+ +
+ ) : ( + + + + Chain + Market + {usdHeader} + {amountHeader} + {showFeeds ? ( + Feeds + ) : ( + Oracle + )} + {showAssumptions && {riskColumnHeader}} + + + + {entries.slice(0, MAX_TABLE_ROWS).map(({ row }) => { + const assumptions = + assumptionMode === 'peg' ? row.pegAssumptions : assumptionMode === 'vault' ? row.vaultAssumptions : row.allAssumptions; + + return ( + + + {getNetworkName(row.chainId) ?? row.chainId}}> + + + + + + + + + + + + + + + {formatLoanAssetAmount(row, exposureMetric, forceSupplyAmounts)} + + + {showFeeds ? ( + + ) : ( +
+ +
+ )} +
+ {showAssumptions && ( + + {assumptionMode === 'vault' ? : } + + )} +
+ ); + })} +
+
+ )} +
+ ); +} + +export default function AnalysisView() { + const [selectedNetwork, setSelectedNetwork] = useState(null); + const [exposureMetric, setExposureMetric] = useState('supply'); + const [assumptionMode, setAssumptionMode] = useState('peg'); + const [selectedOracleKey, setSelectedOracleKey] = useState(); + const [selectedPegKey, setSelectedPegKey] = useState(); + const [selectedVaultKey, setSelectedVaultKey] = useState(); + + const { whitelistedMarkets, loading, error } = useProcessedMarkets({ + marketsRefetchInterval: false, + marketsRefetchOnWindowFocus: false, + }); + const { data: oracleMetadataMap, isLoading: oracleMetadataLoading, isError: oracleMetadataError } = useAllOracleMetadata(); + + const scopedMarkets = useMemo(() => { + if (!selectedNetwork) return whitelistedMarkets; + return whitelistedMarkets.filter((market) => market.morphoBlue.chain.id === selectedNetwork); + }, [selectedNetwork, whitelistedMarkets]); + + const analysis = useMemo( + () => + buildRiskAnalysis({ + markets: scopedMarkets, + oracleMetadataMap, + exposureMetric, + }), + [exposureMetric, oracleMetadataMap, scopedMarkets], + ); + + const activeOracleBucket = analysis.oracleBuckets.find((bucket) => bucket.key === selectedOracleKey) ?? analysis.oracleBuckets[0]; + const activeVaultBucket = + analysis.vaultAssumptionBuckets.find((bucket) => bucket.key === selectedVaultKey) ?? analysis.vaultAssumptionBuckets[0]; + const pegBuckets = analysis.noPegAssumptionBucket + ? [analysis.noPegAssumptionBucket, ...analysis.pegAssumptionBuckets] + : analysis.pegAssumptionBuckets; + const pegBucketsWithUnknown = analysis.unknownPegRouteBucket ? [...pegBuckets, analysis.unknownPegRouteBucket] : pegBuckets; + const assumptionBuckets = assumptionMode === 'peg' ? pegBucketsWithUnknown : analysis.vaultAssumptionBuckets; + const activeAssumptionBucket = + assumptionMode === 'peg' + ? (assumptionBuckets.find((bucket) => bucket.key === selectedPegKey) ?? assumptionBuckets[0]) + : activeVaultBucket; + const activeAssumptionKey = activeAssumptionBucket?.key; + const setActiveAssumptionKey = assumptionMode === 'peg' ? setSelectedPegKey : setSelectedVaultKey; + + const isInitialLoading = loading || oracleMetadataLoading; + const metricLabel = exposureMetric === 'borrow' ? 'Borrow Exposure' : 'Supply TVL'; + const pegRiskMetricLabel = exposureMetric === 'borrow' ? 'Borrow Behind Peg Assumptions' : 'Supply Behind Peg Assumptions'; + + return ( + <> +
+
+
+ +
+
+
+

Morpho Global Dashboard

+

Risk Breakdown

+

+ Oracle vendors, peg assumptions, and asset dominance across the Morpho universe. +

+
+ +
+ + {error ? ( + + ) : isInitialLoading ? ( + + ) : ( + <> + {oracleMetadataError && } + +
+ } + /> + } + /> + } + /> + } + /> +
+ + + + + + + Oracles + + + + + + Pegs + + + + + + Assets + + + + + + + + + + + + + +
+ + ({ row, attributedUsd: row.exposureUsd }))} + totalUsd={analysis.totalExposureUsd} + exposureMetric={exposureMetric} + /> +
+
+
+ + )} +
+ + ); +} diff --git a/src/features/analysis/utils/oracle-risk-analysis.ts b/src/features/analysis/utils/oracle-risk-analysis.ts new file mode 100644 index 00000000..1e44d72b --- /dev/null +++ b/src/features/analysis/utils/oracle-risk-analysis.ts @@ -0,0 +1,371 @@ +import { + getOracleFromMetadata, + type EnrichedFeed, + type EnrichedVault, + type OracleMetadataRecord, + type OracleOutput, + type OracleOutputData, +} from '@/hooks/useOracleMetadata'; +import { checkFeedsPath, getOracleVendorInfo, PriceFeedVendors } from '@/utils/oracle'; +import { getMarketIdentityKey } from '@/utils/market-identity'; +import { getNetworkName } from '@/utils/networks'; +import type { Market } from '@/utils/types'; + +export type AnalysisExposureMetric = 'supply' | 'borrow'; + +export type AnalysisMarketRow = { + id: string; + market: Market; + chainId: number; + exposureUsd: number; + supplyUsd: number; + borrowUsd: number; + collateralUsd: number; + vendorLabels: string[]; + oracleType: OracleOutput['type'] | 'missing'; + actualPath: string; + expectedPath: string; + isValidPath: boolean; + pegAssumptions: string[]; + vaultAssumptions: string[]; + vaultDependencies: AnalysisVaultDependency[]; + allAssumptions: string[]; + unknownLegCount: number; +}; + +export type AnalysisVaultDependency = { + label: string; + address: string; + symbol: string; +}; + +export type AnalysisBucketMarket = { + row: AnalysisMarketRow; + attributedUsd: number; +}; + +export type AnalysisChainBreakdown = { + chainId: number; + chainName: string; + valueUsd: number; +}; + +export type AnalysisBucket = { + key: string; + label: string; + valueUsd: number; + marketCount: number; + markets: AnalysisBucketMarket[]; + chainBreakdown: AnalysisChainBreakdown[]; +}; + +export type AnalysisAssetBucket = { + key: string; + label: string; + symbol: string; + address: string; + chainId: number; + valueUsd: number; + marketCount: number; + markets: AnalysisBucketMarket[]; +}; + +export type RiskAnalysisResult = { + marketCount: number; + totalSupplyUsd: number; + totalBorrowUsd: number; + totalCollateralUsd: number; + totalExposureUsd: number; + pegAssumptionMarketCount: number; + pegAssumptionExposureUsd: number; + unknownOracleCount: number; + rows: AnalysisMarketRow[]; + oracleBuckets: AnalysisBucket[]; + pegAssumptionBuckets: AnalysisBucket[]; + noPegAssumptionBucket: AnalysisBucket | null; + unknownPegRouteBucket: AnalysisBucket | null; + vaultAssumptionBuckets: AnalysisBucket[]; + loanAssetBuckets: AnalysisAssetBucket[]; + collateralAssetBuckets: AnalysisAssetBucket[]; +}; + +type BuildRiskAnalysisInput = { + markets: Market[]; + oracleMetadataMap?: OracleMetadataRecord; + exposureMetric: AnalysisExposureMetric; +}; + +type MutableBucket = Omit & { + chainBreakdownMap: Map; +}; + +type MutableAssetBucket = AnalysisAssetBucket; + +const UNKNOWN_VENDOR = PriceFeedVendors.Unknown; +const EMPTY_PATH = 'EMPTY/EMPTY'; + +const safeUsd = (value: number | null | undefined): number => { + if (!Number.isFinite(value)) return 0; + return Math.max(value ?? 0, 0); +}; + +const unique = (values: string[]): string[] => Array.from(new Set(values.filter(Boolean))); + +const getMarketExposureUsd = (market: Market, exposureMetric: AnalysisExposureMetric): number => { + return exposureMetric === 'borrow' ? safeUsd(market.state.borrowAssetsUsd) : safeUsd(market.state.supplyAssetsUsd); +}; + +const getCurrentOracleData = (oracle: OracleOutput | undefined): OracleOutputData | null => { + if (!oracle) return null; + if (oracle.type === 'standard') return oracle.data; + if (oracle.type !== 'meta') return null; + + const currentOracle = oracle.data.currentOracle?.toLowerCase(); + const primaryOracle = oracle.data.primaryOracle?.toLowerCase(); + const backupOracle = oracle.data.backupOracle?.toLowerCase(); + + if (currentOracle && currentOracle === primaryOracle) { + return oracle.data.oracleSources.primary; + } + + if (currentOracle && currentOracle === backupOracle) { + return oracle.data.oracleSources.backup; + } + + return oracle.data.oracleSources.primary ?? oracle.data.oracleSources.backup; +}; + +const feedHasUnknownPair = (feed: EnrichedFeed | null): boolean => { + if (!feed?.address) return false; + return feed.pair.length !== 2 || feed.pair.some((asset) => asset === 'Unknown' || asset.trim() === ''); +}; + +const countUnknownLegs = (oracleData: OracleOutputData | null): number => { + if (!oracleData) return 0; + return [oracleData.baseFeedOne, oracleData.baseFeedTwo, oracleData.quoteFeedOne, oracleData.quoteFeedTwo].filter(feedHasUnknownPair) + .length; +}; + +const getVaultDependency = (vault: EnrichedVault | null): AnalysisVaultDependency | null => { + if (!vault?.pair || vault.pair.length !== 2) return null; + const [base, quote] = vault.pair; + if (!base || !quote || base === quote) return null; + + return { + label: `${base} <> ${quote} vault conversion`, + address: vault.address, + symbol: vault.symbol, + }; +}; + +const getVaultDependencies = (oracleData: OracleOutputData | null): AnalysisVaultDependency[] => { + if (!oracleData) return []; + const dependencies = [getVaultDependency(oracleData.baseVault), getVaultDependency(oracleData.quoteVault)].filter( + (dependency): dependency is AnalysisVaultDependency => dependency != null, + ); + const seen = new Set(); + + return dependencies.filter((dependency) => { + const key = `${dependency.address.toLowerCase()}:${dependency.label}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); +}; + +const resolveVendorLabels = ( + oracleAddress: string | undefined, + chainId: number, + oracleMetadataMap: OracleMetadataRecord | undefined, +): string[] => { + const vendorInfo = getOracleVendorInfo(oracleAddress, chainId, oracleMetadataMap); + const labels = unique([...vendorInfo.coreVendors, ...vendorInfo.taggedVendors.map((vendor) => vendor.trim())]); + + if (labels.length === 0 || vendorInfo.hasCompletelyUnknown) { + labels.push(UNKNOWN_VENDOR); + } + + return unique(labels); +}; + +const buildMarketRow = ( + market: Market, + oracleMetadataMap: OracleMetadataRecord | undefined, + exposureMetric: AnalysisExposureMetric, +): AnalysisMarketRow => { + const chainId = market.morphoBlue.chain.id; + const oracle = getOracleFromMetadata(oracleMetadataMap, market.oracleAddress, chainId); + const oracleData = getCurrentOracleData(oracle); + const pathResult = oracleData ? checkFeedsPath(oracleData, market.collateralAsset.symbol, market.loanAsset.symbol) : null; + const expectedPath = `${market.collateralAsset.symbol}/${market.loanAsset.symbol}`; + const actualPath = pathResult?.actualPath ?? (pathResult?.isValid ? expectedPath : EMPTY_PATH); + const pegAssumptions = unique(pathResult?.inferredAssumptions ?? []); + const vaultDependencies = getVaultDependencies(oracleData); + const vaultAssumptions = vaultDependencies.map((dependency) => dependency.label); + + return { + id: getMarketIdentityKey(chainId, market.uniqueKey), + market, + chainId, + exposureUsd: getMarketExposureUsd(market, exposureMetric), + supplyUsd: safeUsd(market.state.supplyAssetsUsd), + borrowUsd: safeUsd(market.state.borrowAssetsUsd), + collateralUsd: safeUsd(market.state.collateralAssetsUsd), + vendorLabels: resolveVendorLabels(market.oracleAddress, chainId, oracleMetadataMap), + oracleType: oracle?.type ?? 'missing', + actualPath, + expectedPath, + isValidPath: pathResult?.isValid ?? false, + pegAssumptions, + vaultAssumptions, + vaultDependencies, + allAssumptions: unique([...pegAssumptions, ...vaultAssumptions]), + unknownLegCount: countUnknownLegs(oracleData), + }; +}; + +const createBucket = (key: string, label: string): MutableBucket => ({ + key, + label, + valueUsd: 0, + marketCount: 0, + markets: [], + chainBreakdownMap: new Map(), +}); + +const addToBucket = (bucket: MutableBucket, row: AnalysisMarketRow, attributedUsd: number) => { + const valueUsd = safeUsd(attributedUsd); + bucket.valueUsd += valueUsd; + bucket.marketCount += 1; + bucket.markets.push({ row, attributedUsd: valueUsd }); + + const chainName = getNetworkName(row.chainId) ?? `Chain ${row.chainId}`; + const chainBucket = bucket.chainBreakdownMap.get(row.chainId) ?? { + chainId: row.chainId, + chainName, + valueUsd: 0, + }; + chainBucket.valueUsd += valueUsd; + bucket.chainBreakdownMap.set(row.chainId, chainBucket); +}; + +const finalizeBuckets = (buckets: Map): AnalysisBucket[] => { + return Array.from(buckets.values()) + .map((bucket) => ({ + key: bucket.key, + label: bucket.label, + valueUsd: bucket.valueUsd, + marketCount: bucket.marketCount, + markets: bucket.markets.sort((left, right) => right.attributedUsd - left.attributedUsd), + chainBreakdown: Array.from(bucket.chainBreakdownMap.values()).sort((left, right) => right.valueUsd - left.valueUsd), + })) + .sort((left, right) => right.valueUsd - left.valueUsd); +}; + +const addToAssetBucket = ( + buckets: Map, + row: AnalysisMarketRow, + token: Market['loanAsset'] | Market['collateralAsset'], + valueUsd: number, +) => { + const chainName = getNetworkName(row.chainId) ?? `Chain ${row.chainId}`; + const key = `${row.chainId}:${token.address.toLowerCase()}`; + const bucket = + buckets.get(key) ?? + ({ + key, + label: `${token.symbol} · ${chainName}`, + symbol: token.symbol, + address: token.address, + chainId: row.chainId, + valueUsd: 0, + marketCount: 0, + markets: [], + } satisfies MutableAssetBucket); + + const safeValue = safeUsd(valueUsd); + bucket.valueUsd += safeValue; + bucket.marketCount += 1; + bucket.markets.push({ row, attributedUsd: safeValue }); + buckets.set(key, bucket); +}; + +const finalizeAssetBuckets = (buckets: Map): AnalysisAssetBucket[] => { + return Array.from(buckets.values()) + .map((bucket) => ({ + ...bucket, + markets: bucket.markets.sort((left, right) => right.attributedUsd - left.attributedUsd), + })) + .sort((left, right) => right.valueUsd - left.valueUsd); +}; + +const finalizeOptionalBucket = (bucket: MutableBucket): AnalysisBucket | null => { + return bucket.marketCount > 0 ? (finalizeBuckets(new Map([[bucket.key, bucket]]))[0] ?? null) : null; +}; + +export function buildRiskAnalysis({ markets, oracleMetadataMap, exposureMetric }: BuildRiskAnalysisInput): RiskAnalysisResult { + const rows = markets + .map((market) => buildMarketRow(market, oracleMetadataMap, exposureMetric)) + .sort((left, right) => right.exposureUsd - left.exposureUsd); + + const oracleBuckets = new Map(); + const pegAssumptionBuckets = new Map(); + const noPegAssumptionBucket = createBucket('no-peg-assumption', 'No Assumption'); + const unknownPegRouteBucket = createBucket('unknown-peg-route', 'Unknown'); + const vaultAssumptionBuckets = new Map(); + const loanAssetBuckets = new Map(); + const collateralAssetBuckets = new Map(); + + for (const row of rows) { + const vendorLabels = row.vendorLabels.length > 0 ? row.vendorLabels : [UNKNOWN_VENDOR]; + const vendorAttribution = row.exposureUsd / vendorLabels.length; + + for (const vendorLabel of vendorLabels) { + const bucket = oracleBuckets.get(vendorLabel) ?? createBucket(vendorLabel, vendorLabel); + addToBucket(bucket, row, vendorAttribution); + oracleBuckets.set(vendorLabel, bucket); + } + + for (const assumption of row.pegAssumptions) { + const bucket = pegAssumptionBuckets.get(assumption) ?? createBucket(assumption, assumption); + addToBucket(bucket, row, row.exposureUsd); + pegAssumptionBuckets.set(assumption, bucket); + } + + const hasUnknownPegRoute = row.oracleType === 'missing' || row.unknownLegCount > 0 || row.actualPath === EMPTY_PATH || !row.isValidPath; + + if (row.pegAssumptions.length === 0 && hasUnknownPegRoute) { + addToBucket(unknownPegRouteBucket, row, row.exposureUsd); + } else if (row.pegAssumptions.length === 0) { + addToBucket(noPegAssumptionBucket, row, row.exposureUsd); + } + + for (const assumption of row.vaultAssumptions) { + const bucket = vaultAssumptionBuckets.get(assumption) ?? createBucket(assumption, assumption); + addToBucket(bucket, row, row.exposureUsd); + vaultAssumptionBuckets.set(assumption, bucket); + } + + addToAssetBucket(loanAssetBuckets, row, row.market.loanAsset, row.exposureUsd); + addToAssetBucket(collateralAssetBuckets, row, row.market.collateralAsset, row.collateralUsd); + } + + return { + marketCount: rows.length, + totalSupplyUsd: rows.reduce((total, row) => total + row.supplyUsd, 0), + totalBorrowUsd: rows.reduce((total, row) => total + row.borrowUsd, 0), + totalCollateralUsd: rows.reduce((total, row) => total + row.collateralUsd, 0), + totalExposureUsd: rows.reduce((total, row) => total + row.exposureUsd, 0), + pegAssumptionMarketCount: rows.filter((row) => row.pegAssumptions.length > 0).length, + pegAssumptionExposureUsd: rows.filter((row) => row.pegAssumptions.length > 0).reduce((total, row) => total + row.exposureUsd, 0), + unknownOracleCount: rows.filter((row) => row.vendorLabels.includes(UNKNOWN_VENDOR) || row.oracleType === 'missing').length, + rows, + oracleBuckets: finalizeBuckets(oracleBuckets), + pegAssumptionBuckets: finalizeBuckets(pegAssumptionBuckets), + noPegAssumptionBucket: finalizeOptionalBucket(noPegAssumptionBucket), + unknownPegRouteBucket: finalizeOptionalBucket(unknownPegRouteBucket), + vaultAssumptionBuckets: finalizeBuckets(vaultAssumptionBuckets), + loanAssetBuckets: finalizeAssetBuckets(loanAssetBuckets), + collateralAssetBuckets: finalizeAssetBuckets(collateralAssetBuckets), + }; +} diff --git a/src/features/markets/components/filters/asset-filter.tsx b/src/features/markets/components/filters/asset-filter.tsx index 41a3244e..ec819782 100644 --- a/src/features/markets/components/filters/asset-filter.tsx +++ b/src/features/markets/components/filters/asset-filter.tsx @@ -79,6 +79,7 @@ export default function AssetFilter({ alt={token.symbol} width={size} height={size} + unoptimized /> ) : (
{ return new Error(String(error)); }; +type UseMarketsQueryOptions = { + refetchInterval?: number | false; + refetchOnWindowFocus?: boolean; +}; + /** * Fetches markets from all supported networks using React Query. * @@ -33,7 +38,7 @@ const toError = (error: unknown): Error => { * const { data: markets, isLoading, isRefetching, refetch } = useMarketsQuery(); * ``` */ -export const useMarketsQuery = () => { +export const useMarketsQuery = (options?: UseMarketsQueryOptions) => { const { customRpcUrls } = useCustomRpcContext(); const rpcIdentity = Object.entries(customRpcUrls).sort(([left], [right]) => Number(left) - Number(right)); @@ -146,7 +151,7 @@ export const useMarketsQuery = () => { return filtered; }, staleTime: 5 * 60 * 1000, // Data is fresh for 5 minutes - refetchInterval: 5 * 60 * 1000, // Auto-refetch every 5 minutes in background - refetchOnWindowFocus: true, // Refetch when user returns to tab + refetchInterval: options?.refetchInterval ?? 5 * 60 * 1000, // Auto-refetch every 5 minutes in background by default + refetchOnWindowFocus: options?.refetchOnWindowFocus ?? true, // Refetch when user returns to tab by default }); }; diff --git a/src/hooks/useProcessedMarkets.ts b/src/hooks/useProcessedMarkets.ts index 7fb8ea92..539adbac 100644 --- a/src/hooks/useProcessedMarkets.ts +++ b/src/hooks/useProcessedMarkets.ts @@ -13,6 +13,11 @@ import { formatBalance } from '@/utils/balance'; import type { TokenPriceInput } from '@/data-sources/morpho-api/prices'; import type { Market } from '@/utils/types'; +type UseProcessedMarketsOptions = { + marketsRefetchInterval?: number | false; + marketsRefetchOnWindowFocus?: boolean; +}; + const EMPTY_RATE_ENRICHMENTS: MarketRateEnrichmentMap = new Map(); const hasSameSupplyingVaults = (current: Market['supplyingVaults'], next: Market['supplyingVaults']): boolean => { @@ -84,8 +89,17 @@ const computeUsdValue = (assets: string, decimals: number, price: number): numbe * const { rawMarketsUnfiltered } = useProcessedMarkets(); // For blacklist modal * ``` */ -export const useProcessedMarkets = () => { - const { data: rawMarketsFromQuery, isLoading, isRefetching, error, refetch } = useMarketsQuery(); +export const useProcessedMarkets = (options?: UseProcessedMarketsOptions) => { + const { + data: rawMarketsFromQuery, + isLoading, + isRefetching, + error, + refetch, + } = useMarketsQuery({ + refetchInterval: options?.marketsRefetchInterval, + refetchOnWindowFocus: options?.marketsRefetchOnWindowFocus, + }); const { whitelistLookup, supplyingVaultsLookup } = useMorphoWhitelistStatusQuery(); const { getAllBlacklistedKeys, customBlacklistedMarkets } = useBlacklistedMarkets(); const { showUnwhitelistedMarkets } = useAppSettings(); diff --git a/src/utils/oracle.ts b/src/utils/oracle.ts index 2b695986..e2a3dc3f 100644 --- a/src/utils/oracle.ts +++ b/src/utils/oracle.ts @@ -77,6 +77,7 @@ export function mapProviderToVendor(provider: OracleFeedProvider): PriceFeedVend if (normalizedProvider.includes('chronicle')) return PriceFeedVendors.Chronicle; if (normalizedProvider.includes('pendle')) return PriceFeedVendors.Pendle; if (normalizedProvider.includes('midas')) return PriceFeedVendors.Midas; + if (normalizedProvider.includes('pyth')) return PriceFeedVendors.PythNetwork; const mapping: Record = { chainlink: PriceFeedVendors.Chainlink,