diff --git a/app/markets/components/MarketSettingsModal.tsx b/app/markets/components/MarketSettingsModal.tsx index 54001aa5..ad7cd935 100644 --- a/app/markets/components/MarketSettingsModal.tsx +++ b/app/markets/components/MarketSettingsModal.tsx @@ -10,40 +10,33 @@ import { } from '@heroui/react'; import { Button } from '@/components/common'; import { IconSwitch } from '@/components/common/IconSwitch'; +import { TrustedByCell } from '@/components/vaults/TrustedVaultBadges'; +import { defaultTrustedVaults, type TrustedVault } from '@/constants/vaults/known_vaults'; import { useMarkets } from '@/hooks/useMarkets'; -import { ColumnVisibility, COLUMN_LABELS, COLUMN_DESCRIPTIONS } from './columnVisibility'; +import { + ColumnVisibility, + COLUMN_LABELS, + COLUMN_DESCRIPTIONS, + DEFAULT_COLUMN_VISIBILITY, +} from './columnVisibility'; type MarketSettingsModalProps = { isOpen: boolean; onOpenChange: () => void; - // Unknown Filters - includeUnknownTokens: boolean; - setIncludeUnknownTokens: (value: boolean) => void; - showUnknownOracle: boolean; - setShowUnknownOracle: (value: boolean) => void; - // USD Filters (with enabled/disabled states) usdFilters: { minSupply: string; minBorrow: string; minLiquidity: string; }; setUsdFilters: (filters: MarketSettingsModalProps['usdFilters']) => void; - // USD Filter enabled states - minSupplyEnabled: boolean; - setMinSupplyEnabled: (value: boolean) => void; - minBorrowEnabled: boolean; - setMinBorrowEnabled: (value: boolean) => void; - minLiquidityEnabled: boolean; - setMinLiquidityEnabled: (value: boolean) => void; - // Pagination entriesPerPage: number; onEntriesPerPageChange: (value: number) => void; - // Column Visibility columnVisibility: ColumnVisibility; setColumnVisibility: (visibility: ColumnVisibility) => void; + onOpenTrustedVaultsModal?: () => void; + trustedVaults?: TrustedVault[]; }; -// Reusable component for consistent setting layout function SettingItem({ title, description, @@ -55,14 +48,42 @@ function SettingItem({ }) { return (
-
-

{title}

-

{description}

+
+

{title}

+

{description}

-
- {' '} - {/* Align control slightly lower */} - {children} +
{children}
+
+ ); +} + +function TrustedVaultsSummary({ + vaults, + total, + onManage, +}: { + vaults: TrustedVault[]; + total: number; + onManage?: () => void; +}) { + return ( +
+
+
+

Trusted Vaults

+

+ Vaults that power the “Trusted By” column and filters. Click "Manage" to add or remove trusted vaults. +

+
+ {onManage && ( + + )} +
+
+ + {total} total
); @@ -71,305 +92,134 @@ function SettingItem({ export default function MarketSettingsModal({ isOpen, onOpenChange, - includeUnknownTokens, - setIncludeUnknownTokens, - showUnknownOracle, - setShowUnknownOracle, usdFilters, setUsdFilters, - minSupplyEnabled, - setMinSupplyEnabled, - minBorrowEnabled, - setMinBorrowEnabled, - minLiquidityEnabled, - setMinLiquidityEnabled, entriesPerPage, onEntriesPerPageChange, columnVisibility, setColumnVisibility, + onOpenTrustedVaultsModal, + trustedVaults, }: MarketSettingsModalProps) { const [customEntries, setCustomEntries] = React.useState(entriesPerPage.toString()); - const { - showUnwhitelistedMarkets, - setShowUnwhitelistedMarkets, - showFullRewardAPY, - setShowFullRewardAPY, - } = useMarkets(); - - const handleEntriesChange = (value: number) => { - onEntriesPerPageChange(value); - setCustomEntries(value.toString()); // Update local state if preset is clicked - }; + const previewVaults = (trustedVaults ?? defaultTrustedVaults).slice(0, 6); + const totalVaults = trustedVaults?.length ?? defaultTrustedVaults.length; + const { showFullRewardAPY, setShowFullRewardAPY } = useMarkets(); const handleCustomEntriesSubmit = () => { - const value = parseInt(customEntries, 10); - if (!isNaN(value) && value > 0) { + const value = Number(customEntries); + if (!Number.isNaN(value) && value > 0) { onEntriesPerPageChange(value); - } else { - setCustomEntries(entriesPerPage.toString()); } + setCustomEntries(value > 0 ? String(value) : entriesPerPage.toString()); }; const handleUsdFilterChange = (e: React.ChangeEvent) => { const { name, value } = e.target; if (/^\d*$/.test(value)) { - setUsdFilters({ - ...usdFilters, - [name]: value, - }); + setUsdFilters({ ...usdFilters, [name]: value }); } }; - return ( {(onClose) => ( <> - Market View Settings - - {/* --- Filter Settings Section --- */} + + Market Preferences + + Fine-tune filter thresholds, pagination, and column visibility. + + + + {onOpenTrustedVaultsModal && ( + + )} +
- {/* Section Header: Adjusted style & position */} -

Filter Settings

+

Filter Thresholds

+

+ Edit the numbers that power the Filters modal. Enable or disable filters directly from the Filters button on the + markets page. +

- $} /> - $} /> -
-
-

- Show Unwhitelisted Markets -

-

- Display markets that haven't been verified or whitelisted. These may have - additional risks. -

-
-
- -
-
-
- - {/* --- USD Value Filters Section --- */} -
- {/* Section Header: Adjusted style & position */} -

- Filter by Min USD Value -

-

- Note: USD values are estimates and may not be available or accurate for all - markets. Toggle the switch to enable/disable each filter. -

- {/* Min Supply Filter */} -
-
-

- Min Supply (USD) -

- -
-
-

- Only show markets where total supplied assets meet or exceed this threshold. -

-
- - - $ - -
- } - /> -
-
-
- - - {/* Min Borrow Filter */} -
-
-

- Min Borrow (USD) -

- -
-
-

- Only show markets where borrowed assets meet or exceed this threshold. -

-
- - - $ - -
- } - /> -
-
-
- - - {/* Min Liquidity Filter */} -
-
-

- Min Liquidity (USD) -

- -
-
-

- Only show markets where available liquidity meets or exceeds this threshold. -

-
- - - $ - -
- } - /> -
-
-
+ + $} + /> + - {/* --- Column Visibility Section --- */}
-

- Visible Columns -

-

- Choose which columns to display in the markets table. -

+

Visible Columns

+

Choose which columns to display.

{(Object.keys(COLUMN_LABELS) as (keyof ColumnVisibility)[]).map((key) => { - const isVisible = columnVisibility[key] ?? true; - + const isVisible = columnVisibility[key] ?? DEFAULT_COLUMN_VISIBILITY[key] ?? false; return (
- {/* --- View Options Section --- */}
- {/* Section Header: Adjusted style & position */} -

View Options

- - {/* Full Reward APY Setting */} +

View Options

- - {/* Pagination Settings */} -
-
-
-

Entries Per Page

-

- Choose how many markets appear per page in the table. -

-
-
- {[8, 10, 15].map((value) => ( - - ))} -
- setCustomEntries(e.target.value)} - min="1" - size="sm" - className="w-20" - onKeyDown={(e) => e.key === 'Enter' && handleCustomEntriesSubmit()} - /> - -
-
+ +
+ setCustomEntries(e.target.value)} + min="1" + size="sm" + className="w-24" + onKeyDown={(e) => e.key === 'Enter' && handleCustomEntriesSubmit()} + /> +
-
+
- diff --git a/app/markets/components/MarketTableBody.tsx b/app/markets/components/MarketTableBody.tsx index 0fbb805d..4e9a4900 100644 --- a/app/markets/components/MarketTableBody.tsx +++ b/app/markets/components/MarketTableBody.tsx @@ -5,6 +5,8 @@ import { Button } from '@/components/common/Button'; import { MarketIdBadge } from '@/components/MarketIdBadge'; import { MarketIndicators } from '@/components/MarketIndicators'; import OracleVendorBadge from '@/components/OracleVendorBadge'; +import { TrustedByCell } from '@/components/vaults/TrustedVaultBadges'; +import { getVaultKey, type TrustedVault } from '@/constants/vaults/known_vaults'; import { Market } from '@/utils/types'; import { APYCell } from './APYBreakdownTooltip'; import { ColumnVisibility } from './columnVisibility'; @@ -23,6 +25,7 @@ type MarketTableBodyProps = { unstarMarket: (id: string) => void; onMarketClick: (market: Market) => void; columnVisibility: ColumnVisibility; + trustedVaultMap: Map; }; export function MarketTableBody({ @@ -36,6 +39,7 @@ export function MarketTableBody({ unstarMarket, onMarketClick, columnVisibility, + trustedVaultMap, }: MarketTableBodyProps) { // Calculate colspan for expanded row based on visible columns const visibleColumnsCount = @@ -45,14 +49,45 @@ export function MarketTableBody({ (columnVisibility.liquidity ? 1 : 0) + (columnVisibility.supplyAPY ? 1 : 0) + (columnVisibility.borrowAPY ? 1 : 0) + - (columnVisibility.rateAtTarget ? 1 : 0); + (columnVisibility.rateAtTarget ? 1 : 0) + + (columnVisibility.trustedBy ? 1 : 0); + + const getTrustedVaultsForMarket = (market: Market): TrustedVault[] => { + if (!columnVisibility.trustedBy || !market.supplyingVaults?.length) { + return []; + } + + const chainId = market.morphoBlue.chain.id; + const uniqueMatches: TrustedVault[] = []; + const seen = new Set(); + + market.supplyingVaults.forEach((vault) => { + if (!vault.address) return; + const key = getVaultKey(vault.address as string, chainId); + if (seen.has(key)) return; + seen.add(key); + const trusted = trustedVaultMap.get(key); + if (trusted) { + uniqueMatches.push(trusted); + } + }); + + return uniqueMatches.sort((a, b) => { + const aUnknown = a.curator === 'unknown'; + const bUnknown = b.curator === 'unknown'; + if (aUnknown !== bUnknown) { + return aUnknown ? 1 : -1; + } + return a.name.localeCompare(b.name); + }); + }; return ( {currentEntries.map((item, index) => { const collatToShow = item.collateralAsset.symbol - .slice(0, 6) - .concat(item.collateralAsset.symbol.length > 6 ? '...' : ''); + .slice(0, 6) + .concat(item.collateralAsset.symbol.length > 6 ? '...' : ''); const isStared = staredIds.includes(item.uniqueKey); return ( @@ -122,6 +157,15 @@ export function MarketTableBody({ {Number(item.lltv) / 1e16}% + {columnVisibility.trustedBy && ( + + + + )} {columnVisibility.totalSupply && ( = { @@ -25,6 +27,7 @@ export const COLUMN_LABELS: Record = { supplyAPY: 'Supply APY', borrowAPY: 'Borrow APY', rateAtTarget: 'Target Rate', + trustedBy: 'Trusted By', }; export const COLUMN_DESCRIPTIONS: Record = { @@ -34,4 +37,5 @@ export const COLUMN_DESCRIPTIONS: Record = { supplyAPY: 'Annual percentage yield for suppliers', borrowAPY: 'Annual percentage rate for borrowers', rateAtTarget: 'Interest rate at target utilization', + trustedBy: 'Highlights your trusted vaults that currently supply this market', }; diff --git a/app/markets/components/constants.ts b/app/markets/components/constants.ts index a52cb54c..0d446665 100644 --- a/app/markets/components/constants.ts +++ b/app/markets/components/constants.ts @@ -9,6 +9,7 @@ export enum SortColumn { Liquidity = 8, BorrowAPY = 9, RateAtTarget = 10, + TrustedBy = 11, } // Gas cost to simplify tx flow: do not need to estimate gas for transactions diff --git a/app/markets/components/markets.tsx b/app/markets/components/markets.tsx index c8728e53..edffea20 100644 --- a/app/markets/components/markets.tsx +++ b/app/markets/components/markets.tsx @@ -12,11 +12,13 @@ import { Button } from '@/components/common'; import { SuppliedAssetFilterCompactSwitch } from '@/components/common/SuppliedAssetFilterCompactSwitch'; import Header from '@/components/layout/header/Header'; import { useTokens } from '@/components/providers/TokenProvider'; +import TrustedVaultsModal from '@/components/settings/TrustedVaultsModal'; import EmptyScreen from '@/components/Status/EmptyScreen'; import LoadingScreen from '@/components/Status/LoadingScreen'; import { SupplyModalV2 } from '@/components/SupplyModalV2'; import { TooltipContent } from '@/components/TooltipContent'; import { DEFAULT_MIN_SUPPLY_USD, DEFAULT_MIN_LIQUIDITY_USD } from '@/constants/markets'; +import { defaultTrustedVaults, getVaultKey, type TrustedVault } from '@/constants/vaults/known_vaults'; import { useLocalStorage } from '@/hooks/useLocalStorage'; import { useMarkets } from '@/hooks/useMarkets'; import { usePagination } from '@/hooks/usePagination'; @@ -54,7 +56,14 @@ export default function Markets({ const toast = useStyledToast(); - const { loading, markets: rawMarkets, refetch, isRefetching } = useMarkets(); + const { + loading, + markets: rawMarkets, + refetch, + isRefetching, + showUnwhitelistedMarkets, + setShowUnwhitelistedMarkets, + } = useMarkets(); const { staredIds, starMarket, unstarMarket } = useStaredMarkets(); const { @@ -121,18 +130,59 @@ export default function Markets({ false, ); + const [trustedVaultsOnly, setTrustedVaultsOnly] = useLocalStorage( + keys.MarketsTrustedVaultsOnlyKey, + false, + ); + // Column visibility state - const [columnVisibility, setColumnVisibility] = useLocalStorage( + const [columnVisibilityState, setColumnVisibilityState] = useLocalStorage( keys.MarketsColumnVisibilityKey, DEFAULT_COLUMN_VISIBILITY, ); + const columnVisibility = useMemo( + () => ({ ...DEFAULT_COLUMN_VISIBILITY, ...columnVisibilityState }), + [columnVisibilityState], + ); + + const setColumnVisibility = useCallback( + (visibility: ColumnVisibility) => { + setColumnVisibilityState({ ...DEFAULT_COLUMN_VISIBILITY, ...visibility }); + }, + [setColumnVisibilityState], + ); + // Table view mode: 'compact' (scrollable) or 'expanded' (full width) const [tableViewMode, setTableViewMode] = useLocalStorage<'compact' | 'expanded'>( keys.MarketsTableViewModeKey, 'compact', ); + const [userTrustedVaults, setUserTrustedVaults] = useLocalStorage( + 'userTrustedVaults', + defaultTrustedVaults, + ); + const [isTrustedVaultsModalOpen, setIsTrustedVaultsModalOpen] = useState(false); + + const trustedVaultKeys = useMemo(() => { + return new Set( + userTrustedVaults.map((vault) => getVaultKey(vault.address, vault.chainId)), + ); + }, [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 trustedVaultKeys.has(getVaultKey(vault.address as string, chainId)); + }); + }, + [trustedVaultKeys], + ); + // Create memoized usdFilters object from individual localStorage values to prevent re-renders const usdFilters = useMemo( () => ({ @@ -153,6 +203,8 @@ export default function Markets({ ); const effectiveMinSupply = parseNumericThreshold(usdFilters.minSupply); + const effectiveMinBorrow = parseNumericThreshold(usdFilters.minBorrow); + const effectiveMinLiquidity = parseNumericThreshold(usdFilters.minLiquidity); useEffect(() => { // return if no markets @@ -240,7 +292,7 @@ export default function Markets({ if (!rawMarkets) return; // Apply filters using the new composable filtering system - const filtered = filterMarkets(rawMarkets, { + let filtered = filterMarkets(rawMarkets, { selectedNetwork, showUnknownTokens: includeUnknownTokens, showUnknownOracle, @@ -256,10 +308,20 @@ export default function Markets({ searchQuery, }); + if (trustedVaultsOnly) { + filtered = filtered.filter(hasTrustedVault); + } + // Apply sorting let sorted: Market[]; if (sortColumn === SortColumn.Starred) { sorted = sortMarkets(filtered, createStarredSort(staredIds), 1); + } else if (sortColumn === SortColumn.TrustedBy) { + sorted = sortMarkets( + filtered, + (a, b) => Number(hasTrustedVault(a)) - Number(hasTrustedVault(b)), + sortDirection as 1 | -1, + ); } else { const sortPropertyMap: Record = { [SortColumn.Starred]: 'uniqueKey', @@ -272,6 +334,7 @@ export default function Markets({ [SortColumn.Liquidity]: 'state.liquidityAssets', [SortColumn.BorrowAPY]: 'state.borrowApy', [SortColumn.RateAtTarget]: 'state.apyAtTarget', + [SortColumn.TrustedBy]: '', }; const propertyPath = sortPropertyMap[sortColumn]; if (propertyPath) { @@ -299,8 +362,10 @@ export default function Markets({ minSupplyEnabled, minBorrowEnabled, minLiquidityEnabled, + trustedVaultsOnly, searchQuery, resetPage, + hasTrustedVault, ]); useEffect(() => { @@ -383,8 +448,10 @@ export default function Markets({ }; return ( -
-
+ <> +
+
+

Markets

@@ -395,22 +462,14 @@ export default function Markets({ setIsTrustedVaultsModalOpen(true)} + trustedVaults={userTrustedVaults} />
@@ -472,21 +531,46 @@ export default function Markets({ {/* Settings */}
- + + - + +
@@ -567,6 +659,7 @@ export default function Markets({ setShowSupplyModal={setShowSupplyModal} setSelectedMarket={setSelectedMarket} columnVisibility={columnVisibility} + trustedVaults={userTrustedVaults} className={tableViewMode === 'compact' ? 'w-full' : undefined} wrapperClassName={tableViewMode === 'compact' ? 'w-full' : undefined} tableClassName={tableViewMode === 'compact' ? 'w-full min-w-full' : undefined} @@ -580,6 +673,8 @@ export default function Markets({ ? "Try enabling 'Show Unknown Tokens' in settings, or adjust your current filters." : selectedOracles.length > 0 && !showUnknownOracle ? "Try enabling 'Show Unknown Oracles' in settings, or adjust your oracle filters." + : trustedVaultsOnly + ? 'Disable the Trusted Vaults filter or update your trusted list in Settings.' : minSupplyEnabled || minBorrowEnabled || minLiquidityEnabled ? 'Try disabling USD filters in settings, or adjust your filter thresholds.' : 'Try adjusting your filters or search query to see more results.' @@ -589,6 +684,12 @@ export default function Markets({
)}
-
- ); + setIsTrustedVaultsModalOpen((prev) => !prev)} + userTrustedVaults={userTrustedVaults} + setUserTrustedVaults={setUserTrustedVaults} + /> + + ); } diff --git a/app/markets/components/marketsTable.tsx b/app/markets/components/marketsTable.tsx index 40ac4d37..1b6f73b2 100644 --- a/app/markets/components/marketsTable.tsx +++ b/app/markets/components/marketsTable.tsx @@ -1,6 +1,8 @@ -import { useState } from 'react'; +import { useMemo, useState } from 'react'; import { FaRegStar, FaStar } from 'react-icons/fa'; +import { type TrustedVault } from '@/constants/vaults/known_vaults'; import { Market } from '@/utils/types'; +import { buildTrustedVaultMap } from '@/utils/vaults'; import { ColumnVisibility } from './columnVisibility'; import { SortColumn } from './constants'; import { MarketTableBody } from './MarketTableBody'; @@ -22,6 +24,7 @@ type MarketsTableProps = { setCurrentPage: (value: number) => void; onMarketClick: (market: Market) => void; columnVisibility: ColumnVisibility; + trustedVaults: TrustedVault[]; className?: string; wrapperClassName?: string; tableClassName?: string; @@ -42,12 +45,15 @@ function MarketsTable({ setCurrentPage, onMarketClick, columnVisibility, + trustedVaults, className, wrapperClassName, tableClassName, }: MarketsTableProps) { const [expandedRowId, setExpandedRowId] = useState(null); + const trustedVaultMap = useMemo(() => buildTrustedVaultMap(trustedVaults), [trustedVaults]); + const indexOfLastEntry = currentPage * entriesPerPage; const indexOfFirstEntry = indexOfLastEntry - entriesPerPage; const currentEntries = markets.slice(indexOfFirstEntry, indexOfLastEntry); @@ -101,6 +107,15 @@ function MarketsTable({ sortDirection={sortDirection} targetColumn={SortColumn.LLTV} /> + {columnVisibility.trustedBy && ( + + )} {columnVisibility.totalSupply && (
diff --git a/app/markets/components/utils.ts b/app/markets/components/utils.ts index f23a8909..6786ba28 100644 --- a/app/markets/components/utils.ts +++ b/app/markets/components/utils.ts @@ -17,6 +17,7 @@ export const sortProperties = { [SortColumn.Liquidity]: 'state.liquidityAssets', [SortColumn.BorrowAPY]: 'state.borrowApy', [SortColumn.RateAtTarget]: 'state.apyAtTarget', + [SortColumn.TrustedBy]: 'uniqueKey', }; export const getNestedProperty = (obj: Market, path: string | ((item: Market) => number)) => { diff --git a/app/positions/components/PositionsContent.tsx b/app/positions/components/PositionsContent.tsx index ff28561c..2d742fef 100644 --- a/app/positions/components/PositionsContent.tsx +++ b/app/positions/components/PositionsContent.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useMemo, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import Link from 'next/link'; import { useParams } from 'next/navigation'; import { FaHistory, FaPlus } from 'react-icons/fa'; @@ -30,10 +30,16 @@ export default function Positions() { const { account } = useParams<{ account: string }>(); const { address } = useAccount(); + const [mounted, setMounted] = useState(false); + const isOwner = useMemo(() => { - if (!account) return false; + if (!account || !address || !mounted) return false; return account === address; - }, [account, address]); + }, [account, address, mounted]); + + useEffect(() => { + setMounted(true); + }, []); const { loading: isMarketsLoading } = useMarkets(); diff --git a/app/positions/components/RebalanceModal.tsx b/app/positions/components/RebalanceModal.tsx index cf425d63..feb5b1c6 100644 --- a/app/positions/components/RebalanceModal.tsx +++ b/app/positions/components/RebalanceModal.tsx @@ -285,6 +285,8 @@ export function RebalanceModal({ size="4xl" classNames={{ base: 'p-4 rounded-sm', + wrapper: 'z-[2000]', + backdrop: 'z-[1990]', }} > diff --git a/app/settings/page.tsx b/app/settings/page.tsx index 64841f37..65a60eb9 100644 --- a/app/settings/page.tsx +++ b/app/settings/page.tsx @@ -1,8 +1,13 @@ 'use client'; +import React from 'react'; +import { Button } from '@/components/common'; import { IconSwitch } from '@/components/common/IconSwitch'; import Header from '@/components/layout/header/Header'; import { AdvancedRpcSettings } from '@/components/settings/CustomRpcSettings'; +import TrustedVaultsModal from '@/components/settings/TrustedVaultsModal'; +import { VaultIdentity } from '@/components/vaults/VaultIdentity'; +import { defaultTrustedVaults, type TrustedVault } from '@/constants/vaults/known_vaults'; import { useLocalStorage } from '@/hooks/useLocalStorage'; import { useMarkets } from '@/hooks/useMarkets'; @@ -13,9 +18,37 @@ export default function SettingsPage() { false, ); const [showUnknownOracle, setShowUnknownOracle] = useLocalStorage('showUnknownOracle', false); + const [userTrustedVaults, setUserTrustedVaults] = useLocalStorage( + 'userTrustedVaults', + defaultTrustedVaults, + ); const { showUnwhitelistedMarkets, setShowUnwhitelistedMarkets } = useMarkets(); + const [isTrustedVaultsModalOpen, setIsTrustedVaultsModalOpen] = React.useState(false); + const [mounted, setMounted] = React.useState(false); + + const defaultVaultKeys = React.useMemo(() => { + return new Set( + defaultTrustedVaults.map( + (vault) => `${vault.chainId}-${vault.address.toLowerCase()}`, + ), + ); + }, []); + + const sortedTrustedVaults = React.useMemo(() => { + return [...userTrustedVaults].sort((a, b) => { + const aDefault = defaultVaultKeys.has(`${a.chainId}-${a.address.toLowerCase()}`); + const bDefault = defaultVaultKeys.has(`${b.chainId}-${b.address.toLowerCase()}`); + if (aDefault === bDefault) return 0; + return aDefault ? -1 : 1; + }); + }, [userTrustedVaults, defaultVaultKeys]); + + React.useEffect(() => { + setMounted(true); + }, []); + return (
@@ -101,6 +134,67 @@ export default function SettingsPage() {
+ {/* Trusted Vaults Section */} +
+

Trusted Vaults

+ +
+
+

Manage Trusted Vaults

+

+ Choose which vaults you trust. Only vaults marked as default trusted are selected + automatically, and you can adjust the list any time. +

+
+ + {/* Display trusted vault icons */} +
+
+ {mounted ? ( + <> + {sortedTrustedVaults.slice(0, 12).map((vault) => ( + + ))} + {userTrustedVaults.length > 12 && ( +
+ +{userTrustedVaults.length - 12} +
+ )} + + ) : ( +
+ Loading vaults... +
+ )} +
+
+ +
+ + + {userTrustedVaults.length} vault{userTrustedVaults.length !== 1 ? 's' : ''} trusted + +
+
+
+ {/* Advanced Section */}
@@ -134,6 +228,14 @@ export default function SettingsPage() {
+ + {/* Trusted Vaults Modal */} + setIsTrustedVaultsModalOpen(!isTrustedVaultsModalOpen)} + userTrustedVaults={userTrustedVaults} + setUserTrustedVaults={setUserTrustedVaults} + /> ); } diff --git a/public/imgs/curators/avantgarde.svg b/public/imgs/curators/avantgarde.svg new file mode 100644 index 00000000..dad9ad20 --- /dev/null +++ b/public/imgs/curators/avantgarde.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/public/imgs/curators/block-analitica.png b/public/imgs/curators/block-analitica.png new file mode 100644 index 00000000..4850039b Binary files /dev/null and b/public/imgs/curators/block-analitica.png differ diff --git a/public/imgs/curators/clearstar.svg b/public/imgs/curators/clearstar.svg new file mode 100644 index 00000000..2e212298 --- /dev/null +++ b/public/imgs/curators/clearstar.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/public/imgs/curators/felix.svg b/public/imgs/curators/felix.svg new file mode 100644 index 00000000..eeab1a9d --- /dev/null +++ b/public/imgs/curators/felix.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/public/imgs/curators/gauntlet.svg b/public/imgs/curators/gauntlet.svg new file mode 100644 index 00000000..f7f6b5c8 --- /dev/null +++ b/public/imgs/curators/gauntlet.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/public/imgs/curators/mevcapital.png b/public/imgs/curators/mevcapital.png new file mode 100644 index 00000000..86a6663a Binary files /dev/null and b/public/imgs/curators/mevcapital.png differ diff --git a/public/imgs/curators/re7.png b/public/imgs/curators/re7.png new file mode 100644 index 00000000..2d0d46f4 Binary files /dev/null and b/public/imgs/curators/re7.png differ diff --git a/public/imgs/curators/relend.png b/public/imgs/curators/relend.png new file mode 100644 index 00000000..bdff60b0 Binary files /dev/null and b/public/imgs/curators/relend.png differ diff --git a/public/imgs/curators/spark.svg b/public/imgs/curators/spark.svg new file mode 100644 index 00000000..50d1c32a --- /dev/null +++ b/public/imgs/curators/spark.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/public/imgs/curators/steakhouse.svg b/public/imgs/curators/steakhouse.svg new file mode 100644 index 00000000..744fb550 --- /dev/null +++ b/public/imgs/curators/steakhouse.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/public/imgs/curators/unknown.svg b/public/imgs/curators/unknown.svg new file mode 100644 index 00000000..4bdfc00e --- /dev/null +++ b/public/imgs/curators/unknown.svg @@ -0,0 +1,4 @@ + + + ? + diff --git a/public/imgs/curators/yearn.svg b/public/imgs/curators/yearn.svg new file mode 100644 index 00000000..946e7598 --- /dev/null +++ b/public/imgs/curators/yearn.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/components/TokenIcon.tsx b/src/components/TokenIcon.tsx index c8cc4f85..6ac092ef 100644 --- a/src/components/TokenIcon.tsx +++ b/src/components/TokenIcon.tsx @@ -17,6 +17,7 @@ type TokenIconProps = { customTooltipDetail?: string; showExplorerLink?: boolean; showTokenSource?: boolean; + disableTooltip?: boolean; }; export function TokenIcon({ @@ -29,6 +30,7 @@ export function TokenIcon({ customTooltipDetail, showExplorerLink = false, showTokenSource = true, + disableTooltip = false, }: TokenIconProps) { const { findToken } = useTokens(); @@ -43,6 +45,7 @@ export function TokenIcon({ alt={token.symbol} width={width} height={height} + style={{ opacity }} /> ); @@ -58,6 +61,10 @@ export function TokenIcon({ const detail = customTooltipDetail || (showTokenSource ? tokenSource : undefined); const secondaryDetail = customTooltipDetail && showTokenSource ? tokenSource : undefined; + if (disableTooltip) { + return img; + } + return ( } > - {token.symbol} + {img} ); } diff --git a/src/components/TooltipContent.tsx b/src/components/TooltipContent.tsx index 8e623029..b089916f 100644 --- a/src/components/TooltipContent.tsx +++ b/src/components/TooltipContent.tsx @@ -4,9 +4,9 @@ import { ReactNode } from 'react'; type TooltipContentProps = { icon?: ReactNode; - title?: string; - detail?: string; - secondaryDetail?: string; + title?: ReactNode; + detail?: ReactNode; + secondaryDetail?: ReactNode; className?: string; actionIcon?: ReactNode; actionHref?: string; diff --git a/src/components/common/FilterComponents.tsx b/src/components/common/FilterComponents.tsx new file mode 100644 index 00000000..c0e1b51e --- /dev/null +++ b/src/components/common/FilterComponents.tsx @@ -0,0 +1,41 @@ +import { type ReactNode } from 'react'; + +export function FilterSection({ + title, + helper, + children, +}: { + title: string; + helper?: string; + children: ReactNode; +}) { + return ( +
+
+ {title} + {helper && {helper}} +
+
{children}
+
+ ); +} + +export function FilterRow({ + title, + description, + children, +}: { + title: string; + description: string; + children: ReactNode; +}) { + return ( +
+
+ {title} + {description} +
+
{children}
+
+ ); +} diff --git a/src/components/common/MarketsTableWithSameLoanAsset.tsx b/src/components/common/MarketsTableWithSameLoanAsset.tsx index 2ab4493b..ac97f038 100644 --- a/src/components/common/MarketsTableWithSameLoanAsset.tsx +++ b/src/components/common/MarketsTableWithSameLoanAsset.tsx @@ -9,7 +9,10 @@ import { LuX } from 'react-icons/lu'; import { Button } from '@/components/common'; import { SuppliedAssetFilterCompactSwitch } from '@/components/common/SuppliedAssetFilterCompactSwitch'; import { useTokens } from '@/components/providers/TokenProvider'; +import TrustedVaultsModal from '@/components/settings/TrustedVaultsModal'; +import { TrustedByCell } from '@/components/vaults/TrustedVaultBadges'; import { DEFAULT_MIN_SUPPLY_USD, DEFAULT_MIN_LIQUIDITY_USD } from '@/constants/markets'; +import { defaultTrustedVaults, getVaultKey, type TrustedVault } from '@/constants/vaults/known_vaults'; import { useLocalStorage } from '@/hooks/useLocalStorage'; import { useMarkets } from '@/hooks/useMarkets'; import { formatBalance, formatReadable } from '@/utils/balance'; @@ -20,6 +23,7 @@ import { parsePriceFeedVendors, PriceFeedVendors, OracleVendorIcons } from '@/ut import * as keys from "@/utils/storageKeys" import { ERC20Token, UnknownERC20Token, infoToKey } from '@/utils/tokens'; import { Market } from '@/utils/types'; +import { buildTrustedVaultMap } from '@/utils/vaults'; import { DEFAULT_COLUMN_VISIBILITY, ColumnVisibility } from 'app/markets/components/columnVisibility'; import MarketSettingsModal from 'app/markets/components/MarketSettingsModal'; import { Pagination } from '../../../app/markets/components/Pagination'; @@ -57,6 +61,40 @@ enum SortColumn { BorrowAPY = 5, RateAtTarget = 6, Risk = 7, + TrustedBy = 8, +} + +function getTrustedVaultsForMarket( + market: Market, + trustedVaultMap: Map, +): TrustedVault[] { + if (!market.supplyingVaults?.length) { + return []; + } + + const chainId = market.morphoBlue.chain.id; + const seen = new Set(); + const matches: TrustedVault[] = []; + + market.supplyingVaults.forEach((vault) => { + if (!vault.address) return; + const key = getVaultKey(vault.address, chainId); + if (seen.has(key)) return; + seen.add(key); + const trusted = trustedVaultMap.get(key); + if (trusted) { + matches.push(trusted); + } + }); + + return matches.sort((a, b) => { + const aUnknown = a.curator === 'unknown'; + const bUnknown = b.curator === 'unknown'; + if (aUnknown !== bUnknown) { + return aUnknown ? 1 : -1; + } + return a.name.localeCompare(b.name); + }); } function HTSortable({ @@ -376,14 +414,22 @@ function MarketRow({ disabled, showSelectColumn, columnVisibility, + trustedVaultMap, }: { marketWithSelection: MarketWithSelection; onToggle: () => void; disabled: boolean; showSelectColumn: boolean; columnVisibility: ColumnVisibility; + trustedVaultMap: Map; }) { 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 && (
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. +

+
+ + + + + + + + + + + + + + + + + +
+ +
+
+ + + + + + + + + +
+
+ + + + + + )} +
+
); } 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" + /> + +
+ + +
+ {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}`} + /> +
+ ); + })} +
+ )} +
+ +
+ + {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}`} + /> +
+ ); + })} +
+ ) + )} +
+
+ + + + + )} +
+
+ ); +} 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 ( +
+ {`${curator} +
+ ); +} 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; +}