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 (
+
+ );
+}
+
+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,