;
}) {
const { market, isSelected } = marketWithSelection;
+ const trustedVaults = useMemo(() => {
+ if (!columnVisibility.trustedBy) {
+ return [];
+ }
+ return getTrustedVaultsForMarket(market, trustedVaultMap);
+ }, [columnVisibility.trustedBy, market, trustedVaultMap]);
return (
+ {columnVisibility.trustedBy && (
+
+
+
+ )}
{columnVisibility.totalSupply && (
@@ -489,11 +540,12 @@ export function MarketsTableWithSameLoanAsset({
showSettings = true,
}: MarketsTableWithSameLoanAssetProps): JSX.Element {
// Get global market settings
- const { showUnwhitelistedMarkets } = useMarkets();
+ const { showUnwhitelistedMarkets, setShowUnwhitelistedMarkets } = useMarkets();
const { findToken } = useTokens();
// Settings modal state
const [showSettingsModal, setShowSettingsModal] = useState(false);
+ const [showTrustedVaultsModal, setShowTrustedVaultsModal] = useState(false);
// Table state
const [currentPage, setCurrentPage] = useState(1);
@@ -507,6 +559,7 @@ export function MarketsTableWithSameLoanAsset({
const [entriesPerPage, setEntriesPerPage] = useLocalStorage(keys.MarketEntriesPerPageKey, 8);
const [includeUnknownTokens, setIncludeUnknownTokens] = useLocalStorage(keys.MarketsShowUnknownTokens, false);
const [showUnknownOracle, setShowUnknownOracle] = useLocalStorage(keys.MarketsShowUnknownOracle, false);
+ const [userTrustedVaults, setUserTrustedVaults] = useLocalStorage('userTrustedVaults', defaultTrustedVaults);
// Store USD filters as separate localStorage items to match markets.tsx pattern
const [usdMinSupply, setUsdMinSupply] = useLocalStorage(
@@ -519,6 +572,27 @@ export function MarketsTableWithSameLoanAsset({
DEFAULT_MIN_LIQUIDITY_USD.toString(),
);
+ const [trustedVaultsOnly, setTrustedVaultsOnly] = useLocalStorage(
+ keys.MarketsTrustedVaultsOnlyKey,
+ false,
+ );
+
+ const trustedVaultMap = useMemo(() => {
+ return buildTrustedVaultMap(userTrustedVaults);
+ }, [userTrustedVaults]);
+
+ const hasTrustedVault = useCallback(
+ (market: Market) => {
+ if (!market.supplyingVaults?.length) return false;
+ const chainId = market.morphoBlue.chain.id;
+ return market.supplyingVaults.some((vault) => {
+ if (!vault.address) return false;
+ return trustedVaultMap.has(getVaultKey(vault.address as string, chainId));
+ });
+ },
+ [trustedVaultMap],
+ );
+
// USD Filter enabled states
const [minSupplyEnabled, setMinSupplyEnabled] = useLocalStorage(
keys.MarketsMinSupplyEnabledKey,
@@ -559,6 +633,8 @@ export function MarketsTableWithSameLoanAsset({
);
const effectiveMinSupply = parseNumericThreshold(usdFilters.minSupply);
+ const effectiveMinBorrow = parseNumericThreshold(usdFilters.minBorrow);
+ const effectiveMinLiquidity = parseNumericThreshold(usdFilters.minLiquidity);
const handleSort = (column: SortColumn) => {
if (sortColumn === column) {
@@ -660,6 +736,10 @@ export function MarketsTableWithSameLoanAsset({
filtered = filtered.filter((market) => market.whitelisted ?? false);
}
+ if (trustedVaultsOnly) {
+ filtered = filtered.filter(hasTrustedVault);
+ }
+
// Sort using the shared utility
const sortPropertyMap: Record = {
[SortColumn.COLLATSYMBOL]: 'collateralAsset.symbol',
@@ -670,10 +750,17 @@ export function MarketsTableWithSameLoanAsset({
[SortColumn.BorrowAPY]: 'state.borrowApy',
[SortColumn.RateAtTarget]: 'state.apyAtTarget',
[SortColumn.Risk]: '', // No sorting for risk
+ [SortColumn.TrustedBy]: '',
};
const propertyPath = sortPropertyMap[sortColumn];
- if (propertyPath && sortColumn !== SortColumn.Risk) {
+ if (sortColumn === SortColumn.TrustedBy) {
+ filtered = sortMarkets(
+ filtered,
+ (a, b) => Number(hasTrustedVault(a)) - Number(hasTrustedVault(b)),
+ sortDirection,
+ );
+ } else if (propertyPath && sortColumn !== SortColumn.Risk) {
filtered = sortMarkets(filtered, createPropertySort(propertyPath), sortDirection);
}
@@ -697,6 +784,8 @@ export function MarketsTableWithSameLoanAsset({
minLiquidityEnabled,
usdFilters,
findToken,
+ hasTrustedVault,
+ trustedVaultsOnly,
]);
// Get selected markets
@@ -710,6 +799,7 @@ export function MarketsTableWithSameLoanAsset({
const safePage = Math.min(Math.max(1, currentPage), totalPages);
const startIndex = (safePage - 1) * safePerPage;
const paginatedMarkets = processedMarkets.slice(startIndex, startIndex + safePerPage);
+ const emptyStateColumns = (showSelectColumn ? 7 : 6) + (columnVisibility.trustedBy ? 1 : 0);
React.useEffect(() => {
setCurrentPage(1);
@@ -778,9 +868,26 @@ export function MarketsTableWithSameLoanAsset({
setShowSettingsModal(true)}
/>
{showSettings && (
+ {columnVisibility.trustedBy && (
+
+ )}
{columnVisibility.totalSupply && (
{paginatedMarkets.length === 0 ? (
-
+
No markets found
@@ -896,6 +1012,7 @@ export function MarketsTableWithSameLoanAsset({
disabled={disabled}
showSelectColumn={showSelectColumn}
columnVisibility={columnVisibility}
+ trustedVaultMap={trustedVaultMap}
/>
))
)}
@@ -918,22 +1035,24 @@ export function MarketsTableWithSameLoanAsset({
setShowSettingsModal(false)}
- includeUnknownTokens={includeUnknownTokens}
- setIncludeUnknownTokens={setIncludeUnknownTokens}
- showUnknownOracle={showUnknownOracle}
- setShowUnknownOracle={setShowUnknownOracle}
usdFilters={usdFilters}
setUsdFilters={setUsdFilters}
- minSupplyEnabled={minSupplyEnabled}
- setMinSupplyEnabled={setMinSupplyEnabled}
- minBorrowEnabled={minBorrowEnabled}
- setMinBorrowEnabled={setMinBorrowEnabled}
- minLiquidityEnabled={minLiquidityEnabled}
- setMinLiquidityEnabled={setMinLiquidityEnabled}
entriesPerPage={entriesPerPage}
onEntriesPerPageChange={setEntriesPerPage}
columnVisibility={columnVisibility}
setColumnVisibility={setColumnVisibility}
+ trustedVaults={userTrustedVaults}
+ onOpenTrustedVaultsModal={() => setShowTrustedVaultsModal(true)}
+ />
+ )}
+
+ {/* Trusted Vaults Modal */}
+ {showTrustedVaultsModal && (
+ setShowTrustedVaultsModal(false)}
+ userTrustedVaults={userTrustedVaults}
+ setUserTrustedVaults={setUserTrustedVaults}
/>
)}
diff --git a/src/components/common/SuppliedAssetFilterCompactSwitch.tsx b/src/components/common/SuppliedAssetFilterCompactSwitch.tsx
index 0972eeb7..4efc4e2d 100644
--- a/src/components/common/SuppliedAssetFilterCompactSwitch.tsx
+++ b/src/components/common/SuppliedAssetFilterCompactSwitch.tsx
@@ -1,44 +1,80 @@
'use client';
-import { VisuallyHidden, Tooltip, useSwitch } from '@heroui/react';
-import { TbDropletQuestion } from 'react-icons/tb';
-
+import { useMemo } from 'react';
+import { Modal, ModalBody, ModalContent, ModalFooter, ModalHeader, Divider, Tooltip, useDisclosure } from '@heroui/react';
+import { FiFilter } from 'react-icons/fi';
+import { Button } from '@/components/common/Button';
+import { FilterRow, FilterSection } from '@/components/common/FilterComponents';
+import { IconSwitch } from '@/components/common/IconSwitch';
import { TooltipContent } from '@/components/TooltipContent';
import { MONARCH_PRIMARY } from '@/constants/chartColors';
import { formatReadable } from '@/utils/balance';
type SuppliedAssetFilterCompactSwitchProps = {
- isEnabled: boolean;
- onToggle: (selected: boolean) => void;
- effectiveMinSupply: number;
+ includeUnknownTokens: boolean;
+ setIncludeUnknownTokens: (value: boolean) => void;
+ showUnknownOracle: boolean;
+ setShowUnknownOracle: (value: boolean) => void;
+ showUnwhitelistedMarkets: boolean;
+ setShowUnwhitelistedMarkets: (value: boolean) => void;
+ trustedVaultsOnly: boolean;
+ setTrustedVaultsOnly: (value: boolean) => void;
+ minSupplyEnabled: boolean;
+ setMinSupplyEnabled: (value: boolean) => void;
+ minBorrowEnabled: boolean;
+ setMinBorrowEnabled: (value: boolean) => void;
+ minLiquidityEnabled: boolean;
+ setMinLiquidityEnabled: (value: boolean) => void;
+ thresholds: {
+ minSupply: number;
+ minBorrow: number;
+ minLiquidity: number;
+ };
+ onOpenSettings: () => void;
className?: string;
- ariaLabel?: string;
};
export function SuppliedAssetFilterCompactSwitch({
- isEnabled,
- onToggle,
- effectiveMinSupply,
+ includeUnknownTokens,
+ setIncludeUnknownTokens,
+ showUnknownOracle,
+ setShowUnknownOracle,
+ showUnwhitelistedMarkets,
+ setShowUnwhitelistedMarkets,
+ trustedVaultsOnly,
+ setTrustedVaultsOnly,
+ minSupplyEnabled,
+ setMinSupplyEnabled,
+ minBorrowEnabled,
+ setMinBorrowEnabled,
+ minLiquidityEnabled,
+ setMinLiquidityEnabled,
+ thresholds,
+ onOpenSettings,
className,
- ariaLabel = 'Toggle liquidity filter',
}: SuppliedAssetFilterCompactSwitchProps) {
- const formattedThreshold = formatReadable(effectiveMinSupply);
- const containerClassName = ['flex items-center gap-2', className].filter(Boolean).join(' ');
+ const { isOpen, onOpen, onOpenChange, onClose } = useDisclosure();
- const tooltipDetail = isEnabled
- ? `Hiding markets below $${formattedThreshold} total supply`
- : `Showing all markets, toggle to hide markets below $${formattedThreshold} total supply.`;
+ const thresholdCopy = useMemo(
+ () => ({
+ minSupply: formatReadable(thresholds.minSupply),
+ minBorrow: formatReadable(thresholds.minBorrow),
+ minLiquidity: formatReadable(thresholds.minLiquidity),
+ }),
+ [thresholds],
+ );
- const { Component, slots, getInputProps, getWrapperProps } = useSwitch({
- isSelected: isEnabled,
- onValueChange: onToggle,
- 'aria-label': ariaLabel,
- });
+ const handleCustomize = () => {
+ onClose();
+ onOpenSettings();
+ };
- const iconClassName = isEnabled ? 'text-primary' : 'text-secondary';
+ const basicFilterActive = includeUnknownTokens || showUnknownOracle || showUnwhitelistedMarkets;
+ const advancedFilterActive = trustedVaultsOnly || minSupplyEnabled || minBorrowEnabled || minLiquidityEnabled;
+ const hasActiveFilters = basicFilterActive || advancedFilterActive;
return (
-
+
}
- title="Small Market Filter"
- detail={tooltipDetail}
- secondaryDetail="Configure threshold in settings modal"
+ title="Filters"
+ detail="Toggle market filters and risk guards"
+ icon={
}
/>
}
>
-
+
+
+
+
+
+
+ {(close) => (
+ <>
+
+ Filters
+
+ Quickly toggle the visibility filters that power the markets table.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Customize Filters
+
+
+ Done
+
+
+ >
+ )}
+
+
);
}
diff --git a/src/components/settings/TrustedVaultsModal.tsx b/src/components/settings/TrustedVaultsModal.tsx
new file mode 100644
index 00000000..d3240add
--- /dev/null
+++ b/src/components/settings/TrustedVaultsModal.tsx
@@ -0,0 +1,328 @@
+'use client';
+
+import React, { useMemo, useState } from 'react';
+import {
+ Modal,
+ ModalContent,
+ ModalHeader,
+ ModalBody,
+ ModalFooter,
+ Divider,
+ Input,
+ Spinner,
+} from '@heroui/react';
+import { FiChevronDown, FiChevronUp } from 'react-icons/fi';
+import { GoShield, GoShieldCheck } from 'react-icons/go';
+import { IoWarningOutline } from 'react-icons/io5';
+import { Button } from '@/components/common';
+import { IconSwitch } from '@/components/common/IconSwitch';
+import { NetworkIcon } from '@/components/common/NetworkIcon';
+import { VaultIdentity } from '@/components/vaults/VaultIdentity';
+import {
+ known_vaults,
+ type KnownVault,
+ type TrustedVault,
+} from '@/constants/vaults/known_vaults';
+import { useAllMorphoVaults } from '@/hooks/useAllMorphoVaults';
+
+type TrustedVaultsModalProps = {
+ isOpen: boolean;
+ onOpenChange: () => void;
+ userTrustedVaults: TrustedVault[];
+ setUserTrustedVaults: React.Dispatch
>;
+};
+
+export default function TrustedVaultsModal({
+ isOpen,
+ onOpenChange,
+ userTrustedVaults,
+ setUserTrustedVaults,
+}: TrustedVaultsModalProps) {
+ const [searchQuery, setSearchQuery] = React.useState('');
+ const [morphoSectionOpen, setMorphoSectionOpen] = useState(false);
+
+ // Fetch all Morpho vaults from API
+ const { vaults: morphoVaults, loading: morphoLoading } = useAllMorphoVaults();
+
+ // Transform Morpho API vaults to TrustedVault format
+ const morphoWhitelistedVaults = useMemo(() => {
+ return morphoVaults.map((vault) => ({
+ address: vault.address as `0x${string}`,
+ chainId: vault.chainId,
+ name: vault.name,
+ curator: 'unknown',
+ asset: vault.assetAddress as `0x${string}`,
+ }));
+ }, [morphoVaults]);
+
+ // Combine both known vaults (Monarch) and Morpho API vaults
+ const allAvailableVaults = useMemo(() => {
+ // Create a Set of Monarch vault keys to avoid duplicates
+ const monarchVaultKeys = new Set(
+ known_vaults.map((v) => `${v.address.toLowerCase()}-${v.chainId}`)
+ );
+
+ // Filter out Morpho vaults that are already in Monarch's list
+ const uniqueMorphoVaults = morphoWhitelistedVaults.filter(
+ (v) => !monarchVaultKeys.has(`${v.address.toLowerCase()}-${v.chainId}`)
+ );
+
+ return [...known_vaults, ...uniqueMorphoVaults];
+ }, [morphoWhitelistedVaults]);
+
+ // Filter and sort vaults based on search query
+ const filterAndSortVaults = (vaults: KnownVault[]) => {
+ let filtered = vaults;
+
+ // Filter by search query if present
+ if (searchQuery.trim()) {
+ const query = searchQuery.toLowerCase();
+ filtered = filtered.filter((vault) => {
+ return (
+ vault.name.toLowerCase().includes(query) ||
+ vault.curator.toLowerCase().includes(query) ||
+ vault.address.toLowerCase().includes(query)
+ );
+ });
+ }
+
+ // Sort by vendor name, then by vault name
+ return [...filtered].sort((a, b) => {
+ const defaultScore = Number(Boolean(b.defaultTrusted)) - Number(Boolean(a.defaultTrusted));
+ if (defaultScore !== 0) return defaultScore;
+
+ const curatorCompare = a.curator.localeCompare(b.curator);
+ if (curatorCompare !== 0) return curatorCompare;
+ return a.name.localeCompare(b.name);
+ });
+ };
+
+ // Separate lists for Monarch and Morpho vaults
+ const sortedMonarchVaults = useMemo(() => {
+ return filterAndSortVaults(known_vaults);
+ }, [searchQuery]);
+
+ const sortedMorphoVaults = useMemo(() => {
+ // Filter out duplicates that are already in Monarch list
+ const monarchVaultKeys = new Set(
+ known_vaults.map((v) => `${v.address.toLowerCase()}-${v.chainId}`)
+ );
+ const uniqueMorphoVaults = morphoWhitelistedVaults.filter(
+ (v) => !monarchVaultKeys.has(`${v.address.toLowerCase()}-${v.chainId}`)
+ );
+ return filterAndSortVaults(uniqueMorphoVaults);
+ }, [morphoWhitelistedVaults, searchQuery]);
+
+ const isVaultTrusted = (vault: TrustedVault | KnownVault) => {
+ return userTrustedVaults.some(
+ (v) => v.address.toLowerCase() === vault.address.toLowerCase() && v.chainId === vault.chainId
+ );
+ };
+
+ const formatVaultForStorage = (vault: KnownVault): TrustedVault => ({
+ address: vault.address,
+ chainId: vault.chainId,
+ curator: vault.curator,
+ name: vault.name,
+ asset: vault.asset,
+ });
+
+ const toggleVault = (vault: KnownVault) => {
+ setUserTrustedVaults((prev) => {
+ const targetAddress = vault.address.toLowerCase();
+ const exists = prev.some(
+ (v) => v.chainId === vault.chainId && v.address.toLowerCase() === targetAddress
+ );
+
+ if (exists) {
+ return prev.filter(
+ (v) => !(v.chainId === vault.chainId && v.address.toLowerCase() === targetAddress)
+ );
+ }
+
+ return [...prev, formatVaultForStorage(vault)];
+ });
+ };
+
+ const handleSelectAll = () => {
+ setUserTrustedVaults(allAvailableVaults.map((vault) => formatVaultForStorage(vault)));
+ };
+
+ const handleDeselectAll = () => {
+ setUserTrustedVaults([]);
+ };
+
+ return (
+
+
+ {(onClose) => (
+ <>
+
+ Manage Trusted Vaults
+
+
+ {/* Info Section */}
+
+
+ Select which vaults you trust. Trusted vaults can be used to filter markets based on
+ vault participation.
+
+
+
+
+ Vaults are managed by third-party curators. Markets trusted by those vaults are not
+ guaranteed to be risk-free. Always do your own research before trusting any
+ vault.
+
+
+
+
+ {/* Search and Actions */}
+
+
setSearchQuery(e.target.value)}
+ size="sm"
+ className="w-full font-zen"
+ />
+
+
+
+ Select All
+
+
+ Deselect All
+
+
+ {userTrustedVaults.length} / {allAvailableVaults.length} selected
+
+
+
+
+
+
+
+
+ Known Vaults ({sortedMonarchVaults.length})
+
+ {sortedMonarchVaults.length === 0 ? (
+
+ No known vaults found matching your search.
+
+ ) : (
+
+ {sortedMonarchVaults.map((vault) => {
+ const trusted = isVaultTrusted(vault);
+
+ return (
+
+
+
+
+
+
toggleVault(vault)}
+ size="xs"
+ color="primary"
+ thumbIcon={trusted ? GoShieldCheck : GoShield}
+ aria-label={`Toggle trust for ${vault.name}`}
+ />
+
+ );
+ })}
+
+ )}
+
+
+
+
setMorphoSectionOpen((prev) => !prev)}
+ >
+ All Morpho Vaults ({sortedMorphoVaults.length})
+ {morphoSectionOpen ? : }
+
+ {morphoSectionOpen && (
+ morphoLoading ? (
+
+
+
+ ) : sortedMorphoVaults.length === 0 ? (
+
+ {searchQuery.trim()
+ ? 'No Morpho vaults found matching your search.'
+ : 'All Morpho vaults are already in the known list.'}
+
+ ) : (
+
+ {sortedMorphoVaults.map((vault) => {
+ const trusted = isVaultTrusted(vault);
+
+ return (
+
+
+
+
+
+
toggleVault(vault)}
+ size="xs"
+ color="primary"
+ thumbIcon={trusted ? GoShieldCheck : GoShield}
+ aria-label={`Toggle trust for ${vault.name}`}
+ />
+
+ );
+ })}
+
+ )
+ )}
+
+
+
+
+ Close
+
+
+ >
+ )}
+
+
+ );
+}
diff --git a/src/components/vaults/TrustedVaultBadges.tsx b/src/components/vaults/TrustedVaultBadges.tsx
new file mode 100644
index 00000000..960574d7
--- /dev/null
+++ b/src/components/vaults/TrustedVaultBadges.tsx
@@ -0,0 +1,94 @@
+'use client';
+
+import { Tooltip } from '@heroui/react';
+import { TooltipContent } from '@/components/TooltipContent';
+import { VaultIdentity } from '@/components/vaults/VaultIdentity';
+import { type TrustedVault } from '@/constants/vaults/known_vaults';
+
+type MoreVaultsBadgeProps = {
+ vaults: TrustedVault[];
+ badgeSize?: number;
+};
+
+export function MoreVaultsBadge({ vaults, badgeSize = 22 }: MoreVaultsBadgeProps) {
+ if (vaults.length === 0) return null;
+
+ return (
+ More trusted vaults}
+ detail={
+
+ {vaults.map((vault) => (
+
+ ))}
+
+ }
+ />
+ }
+ >
+
+ +{vaults.length}
+
+
+ );
+}
+
+type TrustedByCellProps = {
+ vaults: TrustedVault[];
+ badgeSize?: number;
+};
+
+export function TrustedByCell({ vaults, badgeSize = 22 }: TrustedByCellProps) {
+ if (!vaults.length) {
+ return - ;
+ }
+
+ const preview = vaults.slice(0, 3);
+
+ return (
+
+ {preview.map((vault, index) => (
+
+
+
+ ))}
+ {vaults.length > preview.length && (
+
+ )}
+
+ );
+}
diff --git a/src/components/vaults/VaultIcon.tsx b/src/components/vaults/VaultIcon.tsx
new file mode 100644
index 00000000..4dc23ab1
--- /dev/null
+++ b/src/components/vaults/VaultIcon.tsx
@@ -0,0 +1,39 @@
+'use client';
+
+import { useMemo } from 'react';
+import Image from 'next/image';
+import { VaultCurator, getVaultLogo } from '@/constants/vaults/known_vaults';
+
+type VaultIconProps = {
+ curator: VaultCurator | string;
+ width?: number;
+ height?: number;
+ className?: string;
+};
+
+export function VaultIcon({
+ curator,
+ width = 24,
+ height = 24,
+ className = '',
+}: VaultIconProps) {
+ const logoSrc = useMemo(() => getVaultLogo(curator), [curator]);
+
+ return (
+
+
+
+ );
+}
diff --git a/src/components/vaults/VaultIdentity.tsx b/src/components/vaults/VaultIdentity.tsx
new file mode 100644
index 00000000..22f0f11b
--- /dev/null
+++ b/src/components/vaults/VaultIdentity.tsx
@@ -0,0 +1,150 @@
+'use client';
+
+import { useMemo, type ReactNode } from 'react';
+import { Tooltip } from '@heroui/react';
+import Link from 'next/link';
+import { FiExternalLink } from 'react-icons/fi';
+import { TokenIcon } from '@/components/TokenIcon';
+import { TooltipContent } from '@/components/TooltipContent';
+import { VaultCurator } from '@/constants/vaults/known_vaults';
+import { getVaultURL } from '@/utils/external';
+import { VaultIcon } from './VaultIcon';
+
+type VaultIdentityVariant = 'chip' | 'inline' | 'icon';
+
+type VaultIdentityProps = {
+ address: `0x${string}`;
+ asset?: `0x${string}`;
+ chainId: number;
+ curator: VaultCurator | string;
+ vaultName?: string;
+ showLink?: boolean;
+ variant?: VaultIdentityVariant;
+ showTooltip?: boolean;
+ iconSize?: number;
+ className?: string;
+ tooltipDetail?: ReactNode;
+ tooltipSecondaryDetail?: ReactNode;
+ showAddressInTooltip?: boolean;
+};
+
+export function VaultIdentity({
+ address,
+ asset,
+ chainId,
+ curator,
+ vaultName,
+ showLink = true,
+ variant = 'chip',
+ showTooltip = true,
+ iconSize = 20,
+ className = '',
+ tooltipDetail,
+ tooltipSecondaryDetail,
+ showAddressInTooltip = true,
+}: VaultIdentityProps) {
+ const vaultHref = useMemo(() => getVaultURL(address, chainId), [address, chainId]);
+ const formattedAddress = `${address.slice(0, 6)}...${address.slice(-4)}`;
+ const displayName = vaultName ?? formattedAddress;
+ const curatorLabel = curator === 'unknown' ? 'Curator unknown' : `Curated by ${curator}`;
+
+ const baseContent = (() => {
+ if (variant === 'icon') {
+ return (
+
+
+
+ );
+ }
+
+ if (variant === 'inline') {
+ return (
+
+
+
+ {displayName}
+ {curatorLabel}
+
+
+ );
+ }
+
+ return (
+
+
+
+ {displayName}
+ {formattedAddress}
+
+
+ );
+ })();
+
+ const interactiveContent = showLink ? (
+ e.stopPropagation()}
+ >
+ {baseContent}
+
+ ) : (
+ baseContent
+ );
+
+ if (!showTooltip) {
+ return interactiveContent;
+ }
+
+ const resolvedDetail = tooltipDetail ?? (
+
+ {showAddressInTooltip && (
+
+ {address}
+
+ )}
+ {curatorLabel}
+
+ );
+
+ const tooltipTitle = (
+
+ {displayName}
+ {asset && (
+
+ )}
+
+ );
+
+ return (
+ }
+ title={tooltipTitle}
+ detail={resolvedDetail}
+ secondaryDetail={tooltipSecondaryDetail}
+ actionIcon={ }
+ actionHref={vaultHref}
+ onActionClick={(e) => e.stopPropagation()}
+ />
+ }
+ >
+ {interactiveContent}
+
+ );
+}
diff --git a/src/constants/vaults/known_vaults.ts b/src/constants/vaults/known_vaults.ts
new file mode 100644
index 00000000..3330bd9b
--- /dev/null
+++ b/src/constants/vaults/known_vaults.ts
@@ -0,0 +1,379 @@
+// Default fallback logo for unknown curators
+export const DEFAULT_VAULT_LOGO = '/imgs/curators/unknown.svg';
+
+export enum VaultCurator {
+ Avantgarde = 'Avantgarde',
+ Clearstar = 'Clearstar',
+ Gauntlet = 'Gauntlet',
+ Yearn = 'Yearn',
+ MEVCapital = 'MEVCapital',
+ BlockAnalitica = 'Block Analitica',
+ Re7 = 'Re7',
+ Relend = 'Relend',
+ Spark = 'Spark',
+ Steakhouse = 'Steakhouse',
+ Felix = 'Felix',
+}
+
+// Logo path mapping for each vault curator
+export const VAULT_CURATOR_LOGOS: Record = {
+ [VaultCurator.Avantgarde]: '/imgs/curators/avantgarde.svg',
+ [VaultCurator.Clearstar]: '/imgs/curators/clearstar.svg',
+ [VaultCurator.Gauntlet]: '/imgs/curators/gauntlet.svg',
+ [VaultCurator.Yearn]: '/imgs/curators/yearn.svg',
+ [VaultCurator.MEVCapital]: '/imgs/curators/mevcapital.png',
+ [VaultCurator.BlockAnalitica]: '/imgs/curators/block-analitica.png',
+ [VaultCurator.Re7]: '/imgs/curators/re7.png',
+ [VaultCurator.Relend]: '/imgs/curators/relend.png',
+ [VaultCurator.Spark]: '/imgs/curators/spark.svg',
+ [VaultCurator.Steakhouse]: '/imgs/curators/steakhouse.svg',
+ [VaultCurator.Felix]: '/imgs/curators/felix.svg',
+};
+
+export type TrustedVault = {
+ address: `0x${string}`;
+ curator: VaultCurator | string;
+ chainId: number;
+ name: string;
+ asset: `0x${string}`;
+};
+
+export type KnownVault = TrustedVault & {
+ defaultTrusted?: boolean;
+};
+
+// Helper function to safely get vault curator logo
+export function getVaultLogo(curator: VaultCurator | string): string {
+ if (!curator || curator === 'unknown') {
+ return DEFAULT_VAULT_LOGO;
+ }
+
+ const logo = VAULT_CURATOR_LOGOS[curator as VaultCurator];
+
+ if (!logo) {
+ console.warn(`[getVaultLogo] No logo found for curator "${curator}", using default logo`);
+ return DEFAULT_VAULT_LOGO;
+ }
+
+ return logo;
+}
+
+// eslint-disable-next-line @typescript-eslint/naming-convention
+export const known_vaults: KnownVault[] = [
+ {
+ address: '0x7BfA7C4f149E7415b73bdeDfe609237e29CBF34A',
+ curator: VaultCurator.Spark,
+ chainId: 8453,
+ name: 'Spark USDC Vault',
+ asset: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913',
+ },
+ {
+ address: '0xe41a0583334f0dc4E023Acd0bFef3667F6FE0597',
+ curator: VaultCurator.Spark,
+ chainId: 1,
+ name: 'Spark USDS Vault',
+ asset: '0xdC035D45d973E3EC169d2276DDab16f1e407384F',
+ },
+ {
+ address: '0xd63070114470f685b75B74D60EEc7c1113d33a3D',
+ curator: VaultCurator.MEVCapital,
+ chainId: 1,
+ name: 'MEV Capital USDC',
+ asset: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48',
+ },
+ {
+ address: '0x64A651D825FC70Ebba88f2E1BAD90be9A496C4b9',
+ curator: VaultCurator.Avantgarde,
+ chainId: 42161,
+ name: 'Avantgarde USDC Core Arbitrum',
+ asset: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831',
+ },
+ {
+ address: '0x5b56F90340dBAa6a8693DADb141D620f0e154fE6',
+ curator: VaultCurator.Avantgarde,
+ chainId: 1,
+ name: 'Avantgarde USDC Core',
+ asset: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48',
+ },
+ {
+ address: '0x62fE596d59fB077c2Df736dF212E0AFfb522dC78',
+ curator: VaultCurator.Clearstar,
+ chainId: 1,
+ name: 'Clearstar USDC Reactor',
+ asset: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48',
+ },
+ {
+ address: '0xdd0f28e19C1780eb6396170735D45153D261490d',
+ curator: VaultCurator.Gauntlet,
+ chainId: 1,
+ name: 'Gauntlet USDC Prime',
+ asset: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48',
+ },
+ {
+ address: '0x8eB67A509616cd6A7c1B3c8C21D48FF57df3d458',
+ curator: VaultCurator.Gauntlet,
+ chainId: 1,
+ name: 'Gauntlet USDC Core',
+ asset: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48',
+ },
+ {
+ address: '0x7e97fa6893871A2751B5fE961978DCCb2c201E65',
+ curator: VaultCurator.Gauntlet,
+ chainId: 42161,
+ name: 'Gauntlet USDC Core',
+ asset: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831',
+ },
+ {
+ address: '0x616a4E1db48e22028f6bbf20444Cd3b8e3273738',
+ curator: VaultCurator.Gauntlet,
+ chainId: 8453,
+ name: 'Seamless USDC Vault',
+ asset: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913',
+ },
+ {
+ address: '0xC0c5689e6f4D256E861F65465b691aeEcC0dEb12',
+ curator: VaultCurator.Gauntlet,
+ chainId: 8453,
+ name: 'Gauntlet USDC Core',
+ asset: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913',
+ },
+ {
+ address: '0x236919F11ff9eA9550A4287696C2FC9e18E6e890',
+ curator: VaultCurator.Gauntlet,
+ chainId: 8453,
+ name: 'Gauntlet USDC Frontier',
+ asset: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913',
+ },
+ {
+ address: '0x8CB3649114051cA5119141a34C200D65dc0Faa73',
+ curator: VaultCurator.Gauntlet,
+ chainId: 1,
+ name: 'Gauntlet USDT Prime',
+ asset: '0xdAC17F958D2ee523a2206206994597C13D831ec7',
+ },
+ {
+ address: '0x2371e134e3455e0593363cBF89d3b6cf53740618',
+ curator: VaultCurator.Gauntlet,
+ chainId: 1,
+ name: 'Gauntlet WETH Prime',
+ asset: '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2',
+ },
+ {
+ address: '0x4Ff4186188f8406917293A9e01A1ca16d3cf9E59',
+ curator: VaultCurator.Gauntlet,
+ chainId: 1,
+ name: 'SwissBorg Morpho USDC',
+ asset: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48',
+ },
+ {
+ address: '0x132E6C9C33A62D7727cd359b1f51e5B566E485Eb',
+ curator: VaultCurator.Gauntlet,
+ chainId: 1,
+ name: 'Resolv USDC',
+ asset: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48',
+ },
+ {
+ address: '0x781FB7F6d845E3bE129289833b04d43Aa8558c42',
+ curator: VaultCurator.Gauntlet,
+ chainId: 137,
+ name: 'Compound USDC',
+ asset: '0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359',
+ },
+ {
+ address: '0xfD06859A671C21497a2EB8C5E3fEA48De924D6c8',
+ curator: VaultCurator.Gauntlet,
+ chainId: 137,
+ name: 'Compound USDT',
+ asset: '0xc2132D05D31c914a87C6611C10748AEb04B58e8F',
+ },
+ {
+ address: '0xF5C81d25ee174d83f1FD202cA94AE6070d073cCF',
+ curator: VaultCurator.Gauntlet,
+ chainId: 137,
+ name: 'Compound WETH',
+ asset: '0x7ceB23fD6bC0adD59E62ac25578270cFf1b9f619',
+ },
+ {
+ address: '0xbeeF010f9cb27031ad51e3333f9aF9C6B1228183',
+ curator: VaultCurator.Steakhouse,
+ chainId: 8453,
+ name: 'Steakhouse USDC',
+ asset: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913',
+ defaultTrusted: true,
+ },
+ {
+ address: '0xBEEF01735c132Ada46AA9aA4c54623cAA92A64CB',
+ curator: VaultCurator.Steakhouse,
+ chainId: 1,
+ name: 'Steakhouse USDC',
+ asset: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48',
+ defaultTrusted: true,
+ },
+ {
+ address: '0xBEefb9f61CC44895d8AEc381373555a64191A9c4',
+ curator: VaultCurator.Steakhouse,
+ chainId: 1,
+ name: 'Vault Bridge USDC',
+ asset: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48',
+ },
+ {
+ address: '0xBEeFFF209270748ddd194831b3fa287a5386f5bC',
+ curator: VaultCurator.Steakhouse,
+ chainId: 1,
+ name: 'Smokehouse USDC',
+ asset: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48',
+ },
+ {
+ address: '0x5c0C306Aaa9F877de636f4d5822cA9F2E81563BA',
+ curator: VaultCurator.Steakhouse,
+ chainId: 42161,
+ name: 'Steakhouse High Yield USDC',
+ asset: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831',
+ },
+ {
+ address: '0xCBeeF01994E24a60f7DCB8De98e75AD8BD4Ad60d',
+ curator: VaultCurator.Steakhouse,
+ chainId: 8453,
+ name: 'Steakhouse High Yield USDC',
+ asset: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913',
+ },
+ {
+ address: '0xBEEFA7B88064FeEF0cEe02AAeBBd95D30df3878F',
+ curator: VaultCurator.Steakhouse,
+ chainId: 8453,
+ name: 'Steakhouse High Yield USDC v1.1',
+ asset: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913',
+ },
+ {
+ address: '0x6D4e530B8431a52FFDA4516BA4Aadc0951897F8C',
+ curator: VaultCurator.Steakhouse,
+ chainId: 1,
+ name: 'Steakhouse USDC RWA',
+ asset: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48',
+ },
+ {
+ address: '0xBEEf1f5Bd88285E5B239B6AAcb991d38ccA23Ac9',
+ curator: VaultCurator.Steakhouse,
+ chainId: 1,
+ name: 'Steakhouse infiniFi USDC',
+ asset: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48',
+ },
+ {
+ address: '0xBEEFE94c8aD530842bfE7d8B397938fFc1cb83b2',
+ curator: VaultCurator.Steakhouse,
+ chainId: 8453,
+ name: 'Steakhouse Prime USDC',
+ asset: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913',
+ },
+ {
+ address: '0x60d715515d4411f7F43e4206dc5d4a3677f0eC78',
+ curator: VaultCurator.Re7,
+ chainId: 1,
+ name: 'Re7 USDC',
+ asset: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48',
+ },
+ {
+ address: '0x64964E162Aa18d32f91eA5B24a09529f811AEB8e',
+ curator: VaultCurator.Re7,
+ chainId: 1,
+ name: 'Re7 USDC Prime',
+ asset: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48',
+ },
+ {
+ address: '0x12AFDeFb2237a5963e7BAb3e2D46ad0eee70406e',
+ curator: VaultCurator.Re7,
+ chainId: 8453,
+ name: 'Re7 USDC',
+ asset: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913',
+ },
+ {
+ address: '0x87DEAE530841A9671326C9D5B9f91bdB11F3162c',
+ curator: VaultCurator.Yearn,
+ chainId: 42161,
+ name: 'Yearn OG USDC',
+ asset: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831',
+ },
+ {
+ address: '0x36b69949d60d06ECcC14DE0Ae63f4E00cc2cd8B9',
+ curator: VaultCurator.Yearn,
+ chainId: 42161,
+ name: 'Yearn Degen USDC',
+ asset: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831',
+ },
+ {
+ address: '0xef417a2512C5a41f69AE4e021648b69a7CdE5D03',
+ curator: VaultCurator.Yearn,
+ chainId: 8453,
+ name: 'Yearn OG USDC',
+ asset: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913',
+ },
+ {
+ address: '0x1D3b1Cd0a0f242d598834b3F2d126dC6bd774657',
+ curator: VaultCurator.Clearstar,
+ chainId: 8453,
+ name: 'Clearstar USDC Reactor',
+ asset: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913',
+ },
+ {
+ address: '0xCd347c1e7d600a9A3e403497562eDd0A7Bc3Ef21',
+ curator: VaultCurator.Clearstar,
+ chainId: 8453,
+ name: 'High Yield Clearstar USDC',
+ asset: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913',
+ },
+ {
+ address: '0x43e623Ff7D14d5b105F7bE9c488F36dbF11D1F46',
+ curator: VaultCurator.Clearstar,
+ chainId: 8453,
+ name: 'Clearstar Boring USDC',
+ asset: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913',
+ },
+ {
+ address: '0xc1256Ae5FF1cf2719D4937adb3bbCCab2E00A2Ca',
+ curator: VaultCurator.BlockAnalitica,
+ chainId: 8453,
+ name: 'Moonwell Flagship USDC',
+ asset: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913',
+ },
+ {
+ address: '0xa0E430870c4604CcfC7B38Ca7845B1FF653D0ff1',
+ curator: VaultCurator.BlockAnalitica,
+ chainId: 8453,
+ name: 'Moonwell Flagship WETH',
+ asset: '0x4200000000000000000000000000000000000006',
+ },
+ {
+ address: '0x0F359FD18BDa75e9c49bC027E7da59a4b01BF32a',
+ curator: VaultCurator.Relend,
+ chainId: 1,
+ name: 'Relend USDC',
+ asset: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48',
+ },
+ {
+ address: '0x8A862fD6c12f9ad34C9c2ff45AB2b6712e8CEa27',
+ curator: VaultCurator.Felix,
+ chainId: 999,
+ name: 'Felix USDC',
+ asset: '0xb88339CB7199b77E23DB6E890353E22632Ba630f',
+ },
+ {
+ address: '0xFc5126377F0efc0041C0969Ef9BA903Ce67d151e',
+ curator: VaultCurator.Felix,
+ chainId: 999,
+ name: 'Felix USDT0',
+ asset: '0xB8CE59FC3717ada4C02eaDF9682A9e934F625ebb',
+ },
+ {
+ address: '0x2900ABd73631b2f60747e687095537B673c06A76',
+ curator: VaultCurator.Felix,
+ chainId: 999,
+ name: 'Felix WHYPE',
+ asset: '0x5555555555555555555555555555555555555555',
+ },
+];
+
+export const defaultTrustedVaults: TrustedVault[] = known_vaults
+ .filter((vault) => vault.defaultTrusted)
+ .map(({ defaultTrusted, ...rest }) => rest);
+
+export const getVaultKey = (address: string, chainId: number) => `${chainId}:${address.toLowerCase()}`;
diff --git a/src/data-sources/morpho-api/market.ts b/src/data-sources/morpho-api/market.ts
index 4c7fe8c3..3dacdfe0 100644
--- a/src/data-sources/morpho-api/market.ts
+++ b/src/data-sources/morpho-api/market.ts
@@ -57,7 +57,7 @@ export const fetchMorphoMarket = async (
export const fetchMorphoMarkets = async (network: SupportedNetworks): Promise => {
const allMarkets: Market[] = [];
let skip = 0;
- const pageSize = 1000;
+ const pageSize = 500;
let totalCount = 0;
let queryCount = 0;
diff --git a/src/data-sources/morpho-api/vaults.ts b/src/data-sources/morpho-api/vaults.ts
new file mode 100644
index 00000000..1f15bb13
--- /dev/null
+++ b/src/data-sources/morpho-api/vaults.ts
@@ -0,0 +1,93 @@
+import { allVaultsQuery } from '@/graphql/vault-queries';
+import { morphoGraphqlFetcher } from './fetchers';
+
+// Constants for Morpho vault fetching
+const MORPHO_SUPPORTED_CHAIN_IDS = [1, 8453, 999, 137, 42161, 130];
+const MAX_VAULTS_LIMIT = 500;
+
+// Type for vault from Morpho API
+export type MorphoVault = {
+ address: string;
+ chainId: number;
+ name: string;
+ totalAssets: string;
+ assetAddress: string;
+ assetSymbol: string;
+};
+
+// API response types
+type ApiVault = {
+ address: string;
+ chain: {
+ id: number;
+ };
+ name: string;
+ state: {
+ totalAssets: string;
+ };
+ asset: {
+ address: string;
+ symbol: string;
+ };
+};
+
+type AllVaultsApiResponse = {
+ data: {
+ vaults: {
+ items: ApiVault[];
+ };
+ };
+ errors?: { message: string }[];
+};
+
+/**
+ * Transforms API vault response to internal MorphoVault format
+ */
+function transformVault(apiVault: ApiVault): MorphoVault {
+ return {
+ address: apiVault.address,
+ chainId: apiVault.chain.id,
+ name: apiVault.name,
+ totalAssets: apiVault.state.totalAssets,
+ assetAddress: apiVault.asset.address,
+ assetSymbol: apiVault.asset.symbol,
+ };
+}
+
+/**
+ * Fetches all whitelisted vaults from Morpho API across supported chains
+ *
+ * @returns Array of MorphoVault
+ */
+export const fetchAllMorphoVaults = async (): Promise => {
+ try {
+ const variables = {
+ first: MAX_VAULTS_LIMIT,
+ where: {
+ whitelisted: true,
+ chainId_in: MORPHO_SUPPORTED_CHAIN_IDS,
+ },
+ };
+
+ const response = await morphoGraphqlFetcher(
+ allVaultsQuery,
+ variables,
+ );
+
+ if (response.errors && response.errors.length > 0) {
+ console.error('GraphQL errors:', response.errors);
+ return [];
+ }
+
+ const vaults = response.data?.vaults?.items;
+ if (!vaults || vaults.length === 0) {
+ console.log('No whitelisted vaults found');
+ return [];
+ }
+
+ return vaults.map(transformVault);
+ } catch (error) {
+ console.error('Error fetching all Morpho vaults:', error);
+ return [];
+ }
+};
diff --git a/src/data-sources/subgraph/market.ts b/src/data-sources/subgraph/market.ts
index 1b03a590..2860f3c4 100644
--- a/src/data-sources/subgraph/market.ts
+++ b/src/data-sources/subgraph/market.ts
@@ -271,10 +271,14 @@ const transformSubgraphMarketToMarket = (
isProtectedByLiquidationBots: false, // Not available from subgraph
isMonarchWhitelisted: false,
+
// todo: not able to parse bad debt now
realizedBadDebt: {
underlying: '0'
- }
+ },
+
+ // todo: no way to parse supplying vaults now
+ supplyingVaults: [],
};
return marketDetail;
diff --git a/src/graphql/morpho-api-queries.ts b/src/graphql/morpho-api-queries.ts
index 8daad291..5d8d86d6 100644
--- a/src/graphql/morpho-api-queries.ts
+++ b/src/graphql/morpho-api-queries.ts
@@ -77,6 +77,10 @@ badDebt {
usd
}
+supplyingVaults {
+ address
+}
+
oracle {
data {
... on MorphoChainlinkOracleData {
@@ -189,6 +193,9 @@ export const marketsQuery = `
underlying
usd
}
+ supplyingVaults {
+ address
+ }
state {
borrowAssets
supplyAssets
diff --git a/src/graphql/vault-queries.ts b/src/graphql/vault-queries.ts
new file mode 100644
index 00000000..b9ee688e
--- /dev/null
+++ b/src/graphql/vault-queries.ts
@@ -0,0 +1,24 @@
+// Queries for Morpho Vault API
+// Reference: https://blue-api.morpho.org/graphql
+
+// Query for fetching all whitelisted Morpho vaults across supported chains
+export const allVaultsQuery = `
+ query AllVaults($first: Int, $where: VaultFilters) {
+ vaults(first: $first, where: $where) {
+ items {
+ address
+ chain {
+ id
+ }
+ name
+ state {
+ totalAssets
+ }
+ asset {
+ address
+ symbol
+ }
+ }
+ }
+ }
+`;
diff --git a/src/hooks/useAllMorphoVaults.ts b/src/hooks/useAllMorphoVaults.ts
new file mode 100644
index 00000000..c9f2c17a
--- /dev/null
+++ b/src/hooks/useAllMorphoVaults.ts
@@ -0,0 +1,53 @@
+import { useCallback, useEffect, useMemo, useState } from 'react';
+import { fetchAllMorphoVaults, MorphoVault } from '@/data-sources/morpho-api/vaults';
+
+type UseAllMorphoVaultsReturn = {
+ vaults: MorphoVault[];
+ loading: boolean;
+ error: Error | null;
+ refetch: () => Promise;
+};
+
+/**
+ * Hook to fetch all whitelisted Morpho vaults from the API
+ * Returns vaults with vendor as 'unknown' since API doesn't provide vendor info
+ */
+export function useAllMorphoVaults(): UseAllMorphoVaultsReturn {
+ const [vaults, setVaults] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ const load = useCallback(async () => {
+ setLoading(true);
+ setError(null);
+
+ try {
+ const result = await fetchAllMorphoVaults();
+ setVaults(result);
+ } catch (err) {
+ setError(err instanceof Error ? err : new Error('Failed to fetch Morpho vaults'));
+ setVaults([]);
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ void load();
+ }, [load]);
+
+ // Memoize the refetch function to prevent unnecessary re-renders in parent components
+ const refetch = useCallback(async () => {
+ await load();
+ }, [load]);
+
+ return useMemo(
+ () => ({
+ vaults,
+ loading,
+ error,
+ refetch,
+ }),
+ [vaults, error, loading, refetch],
+ );
+}
diff --git a/src/hooks/useLocalStorage.ts b/src/hooks/useLocalStorage.ts
index 7fb4b47b..251b4bf4 100644
--- a/src/hooks/useLocalStorage.ts
+++ b/src/hooks/useLocalStorage.ts
@@ -6,24 +6,10 @@ export function useLocalStorage(
initialValue: T,
): readonly [T, (value: T | ((val: T) => T)) => void] {
// State to store our value
- // Pass initial state function to useState so logic is only executed once
- const [storedValue, setStoredValue] = useState(() => {
- // Return initial value during SSR
- if (typeof window === 'undefined') {
- return initialValue;
- }
-
- try {
- const item = storage.getItem(key);
- // Parse stored json or if none return initialValue
- return item ? (JSON.parse(item) as T) : initialValue;
- } catch (error) {
- console.warn(`Error reading localStorage key "${key}":`, error);
- return initialValue;
- }
- });
+ // Always use initialValue during SSR and before hydration
+ const [storedValue, setStoredValue] = useState(initialValue);
- // Hydrate from localStorage after mount
+ // Hydrate from localStorage after mount to avoid hydration mismatch
useEffect(() => {
try {
const item = storage.getItem(key);
@@ -45,8 +31,10 @@ export function useLocalStorage(
// Save state
setStoredValue(valueToStore);
- // Save to localStorage
- storage.setItem(key, JSON.stringify(valueToStore));
+ // Save to localStorage only on client
+ if (typeof window !== 'undefined') {
+ storage.setItem(key, JSON.stringify(valueToStore));
+ }
} catch (error) {
console.warn(`Error setting localStorage key "${key}":`, error);
}
diff --git a/src/utils/external.ts b/src/utils/external.ts
index 9d0a62e4..9bedd33e 100644
--- a/src/utils/external.ts
+++ b/src/utils/external.ts
@@ -1,17 +1,25 @@
import { getNetworkName, SupportedNetworks, getExplorerUrl } from './networks';
-export const getMarketURL = (id: string, chainId: number): string => {
-
+const getMorphoNetworkSlug = (chainId: number): string | undefined => {
let network = getNetworkName(chainId)?.toLowerCase();
- // morpho urls
if (chainId === SupportedNetworks.HyperEVM) {
- network = 'hyperliquid';
+ return 'hyperliquid';
} else if (chainId === SupportedNetworks.Mainnet) {
- network = 'ethereum';
+ return 'ethereum';
}
+ return network;
+};
+
+export const getMarketURL = (id: string, chainId: number): string => {
+ const network = getMorphoNetworkSlug(chainId);
return `https://app.morpho.org/${network}/market/${id}`;
};
+export const getVaultURL = (address: string, chainId: number): string => {
+ const network = getMorphoNetworkSlug(chainId);
+ return `https://app.morpho.org/${network}/vault/${address}`;
+};
+
export const getAssetURL = (address: string, chain: SupportedNetworks): string => {
return `${getExplorerUrl(chain)}/token/${address}`;
};
diff --git a/src/utils/storageKeys.ts b/src/utils/storageKeys.ts
index 6a0e8a77..a258f54c 100644
--- a/src/utils/storageKeys.ts
+++ b/src/utils/storageKeys.ts
@@ -29,4 +29,6 @@ export const MarketsShowUnknownOracle = 'showUnknownOracle';
export const MarketsColumnVisibilityKey = 'monarch_marketsColumnVisibility';
// Table view mode
-export const MarketsTableViewModeKey = 'monarch_marketsTableViewMode';
\ No newline at end of file
+export const MarketsTableViewModeKey = 'monarch_marketsTableViewMode';
+
+export const MarketsTrustedVaultsOnlyKey = 'monarch_marketsTrustedVaultsOnly';
diff --git a/src/utils/types.ts b/src/utils/types.ts
index dc0d3cfa..c5d0f051 100644
--- a/src/utils/types.ts
+++ b/src/utils/types.ts
@@ -308,6 +308,9 @@ export type Market = {
realizedBadDebt: {
underlying: string
}
+ supplyingVaults?: {
+ address: string;
+ }[];
// whether we have USD price such has supplyUSD, borrowUSD, collateralUSD, etc. If not, use estimationP
hasUSDPrice: boolean;
warnings: MarketWarning[];
diff --git a/src/utils/vaults.ts b/src/utils/vaults.ts
new file mode 100644
index 00000000..f5652c58
--- /dev/null
+++ b/src/utils/vaults.ts
@@ -0,0 +1,16 @@
+import { type TrustedVault, getVaultKey } from '@/constants/vaults/known_vaults';
+
+/**
+ * Builds a Map of trusted vaults keyed by their vault key (chainId:address)
+ * for efficient lookup operations
+ *
+ * @param vaults - Array of trusted vaults
+ * @returns Map with vault keys as keys and TrustedVault objects as values
+ */
+export function buildTrustedVaultMap(vaults: TrustedVault[]): Map {
+ const map = new Map();
+ vaults.forEach((vault) => {
+ map.set(getVaultKey(vault.address, vault.chainId), vault);
+ });
+ return map;
+}