diff --git a/.gitignore b/.gitignore index b120727b..d4091397 100644 --- a/.gitignore +++ b/.gitignore @@ -48,4 +48,6 @@ next-env.d.ts !.yarn/sdks !.yarn/versions -.cursor \ No newline at end of file +.cursor + +CLAUDE.md \ No newline at end of file diff --git a/app/markets/components/MarketSettingsModal.tsx b/app/markets/components/MarketSettingsModal.tsx index addd7888..c9788705 100644 --- a/app/markets/components/MarketSettingsModal.tsx +++ b/app/markets/components/MarketSettingsModal.tsx @@ -101,7 +101,16 @@ export default function MarketSettingsModal({ }; return ( - + {(onClose) => ( <> diff --git a/app/markets/components/markets.tsx b/app/markets/components/markets.tsx index fe1c6707..e5d48bf9 100644 --- a/app/markets/components/markets.tsx +++ b/app/markets/components/markets.tsx @@ -11,6 +11,7 @@ import { useTokens } from '@/components/providers/TokenProvider'; import EmptyScreen from '@/components/Status/EmptyScreen'; import LoadingScreen from '@/components/Status/LoadingScreen'; import { SupplyModalV2 } from '@/components/SupplyModalV2'; +import { DEFAULT_MIN_SUPPLY_USD } from '@/constants/markets'; import { useLocalStorage } from '@/hooks/useLocalStorage'; import { useMarkets } from '@/hooks/useMarkets'; import { usePagination } from '@/hooks/usePagination'; @@ -81,14 +82,17 @@ export default function Markets({ usePagination(); const [includeUnknownTokens, setIncludeUnknownTokens] = useLocalStorage( - 'includeUnknownTokens', + keys.MarketsShowUnknownTokens, false, ); - const [showUnknownOracle, setShowUnknownOracle] = useLocalStorage('showUnknownOracle', false); + const [showUnknownOracle, setShowUnknownOracle] = useLocalStorage(keys.MarketsShowUnknownOracle, false); const { allTokens, findToken } = useTokens(); - const [usdMinSupply, setUsdMinSupply] = useLocalStorage(keys.MarketsUsdMinSupplyKey, ''); + const [usdMinSupply, setUsdMinSupply] = useLocalStorage( + keys.MarketsUsdMinSupplyKey, + DEFAULT_MIN_SUPPLY_USD.toString(), + ); const [usdMinBorrow, setUsdMinBorrow] = useLocalStorage(keys.MarketsUsdMinBorrowKey, ''); // Create memoized usdFilters object from individual localStorage values to prevent re-renders @@ -136,6 +140,7 @@ export default function Markets({ decimals: token.decimals, networks: [], isUnknown: true, + source: 'unknown', }; } acc[token.symbol].networks.push({ diff --git a/app/positions/components/RebalanceModal.tsx b/app/positions/components/RebalanceModal.tsx index a81010b7..dd99297b 100644 --- a/app/positions/components/RebalanceModal.tsx +++ b/app/positions/components/RebalanceModal.tsx @@ -16,6 +16,7 @@ import { FromMarketsTable } from './FromMarketsTable'; import { RebalanceActionInput } from './RebalanceActionInput'; import { RebalanceCart } from './RebalanceCart'; import { RebalanceProcessModal } from './RebalanceProcessModal'; + type RebalanceModalProps = { groupedPosition: GroupedPosition; isOpen: boolean; @@ -24,8 +25,6 @@ type RebalanceModalProps = { isRefetching: boolean; }; -export const PER_PAGE = 5; - export function RebalanceModal({ groupedPosition, isOpen, diff --git a/src/components/common/MarketSelectionModal.tsx b/src/components/common/MarketSelectionModal.tsx index c6d8f6cb..41db3abd 100644 --- a/src/components/common/MarketSelectionModal.tsx +++ b/src/components/common/MarketSelectionModal.tsx @@ -1,4 +1,5 @@ import { useState, useMemo } from 'react'; +import { Modal, ModalBody, ModalContent, ModalFooter, ModalHeader } from '@heroui/react'; import { Address } from 'viem'; import { Button } from '@/components/common/Button'; import { MarketsTableWithSameLoanAsset } from '@/components/common/MarketsTableWithSameLoanAsset'; @@ -14,6 +15,7 @@ type MarketSelectionModalProps = { chainId: SupportedNetworks; excludeMarketIds?: Set; multiSelect?: boolean; + isOpen?: boolean; onClose: () => void; onSelect: (markets: Market[]) => void; confirmButtonText?: string; @@ -30,6 +32,7 @@ export function MarketSelectionModal({ chainId, excludeMarketIds, multiSelect = true, + isOpen = true, onClose, onSelect, confirmButtonText, @@ -62,14 +65,10 @@ export function MarketSelectionModal({ const handleToggleMarket = (marketId: string) => { if (!multiSelect) { - // Single select mode - immediately select and close const market = availableMarkets.find((m) => m.uniqueKey === marketId); if (market) { - // Use setTimeout to ensure state updates happen in correct order onSelect([market]); - setTimeout(() => { - onClose(); - }, 0); + onClose(); } return; } @@ -94,40 +93,6 @@ export function MarketSelectionModal({ onClose(); }; - const handleBackdropClick = (e: React.MouseEvent) => { - e.stopPropagation(); - if (e.target === e.currentTarget) { - onClose(); - } - }; - - const handleBackdropKeyDown = (event: React.KeyboardEvent) => { - if (event.key === 'Escape') { - event.preventDefault(); - event.stopPropagation(); - onClose(); - } - }; - - if (marketsLoading) { - return ( -
-
-
- -
-
-
- ); - } - const selectedCount = selectedMarkets.size; const buttonText = confirmButtonText ?? ( multiSelect @@ -136,76 +101,82 @@ export function MarketSelectionModal({ ); return ( -
-
-
-
+ + <> +

{title}

-

{description}

-
-
- - {availableMarkets.length === 0 ? ( -
-

- {excludeMarketIds && excludeMarketIds.size > 0 - ? 'No more markets available to select.' - : 'No markets found matching the criteria.'} -

-
- ) : ( -
- ({ - market: m, - isSelected: selectedMarkets.has(m.uniqueKey), - }))} - - onToggleMarket={handleToggleMarket} - disabled={false} - uniqueCollateralTokens={undefined} - showSelectColumn={multiSelect} - itemsPerPage={7} - /> -
- )} - - {multiSelect && ( -
-

- {selectedCount} market{selectedCount !== 1 ? 's' : ''} selected -

-
- - -
-
- )} - - {!multiSelect && ( -
- -
- )} -
-
+

{description}

+ + + + {marketsLoading ? ( +
+ +
+ ) : availableMarkets.length === 0 ? ( +
+ {excludeMarketIds && excludeMarketIds.size > 0 + ? 'No more markets available to select.' + : 'No markets found matching the criteria.'} +
+ ) : ( + ({ + market: m, + isSelected: selectedMarkets.has(m.uniqueKey), + }))} + onToggleMarket={handleToggleMarket} + disabled={false} + uniqueCollateralTokens={undefined} + showSelectColumn={multiSelect} + /> + )} +
+ + + {multiSelect ? ( + <> +

+ {selectedCount} market{selectedCount !== 1 ? 's' : ''} selected +

+
+ + +
+ + ) : ( +
+ +
+ )} +
+ +
+
); } diff --git a/src/components/common/MarketsTableWithSameLoanAsset.tsx b/src/components/common/MarketsTableWithSameLoanAsset.tsx index bcb7e00a..bd1b89e8 100644 --- a/src/components/common/MarketsTableWithSameLoanAsset.tsx +++ b/src/components/common/MarketsTableWithSameLoanAsset.tsx @@ -1,15 +1,23 @@ import React, { useMemo, useState, useRef, useEffect } from 'react'; -import { Checkbox } from '@heroui/react'; -import { ArrowDownIcon, ArrowUpIcon, ChevronDownIcon, TrashIcon } from '@radix-ui/react-icons'; +import { Checkbox, Input } from '@heroui/react'; +import { ArrowDownIcon, ArrowUpIcon, ChevronDownIcon, TrashIcon, GearIcon } from '@radix-ui/react-icons'; import { motion, AnimatePresence } from 'framer-motion'; import Image from 'next/image'; +import { FaSearch } from 'react-icons/fa'; import { IoHelpCircleOutline } from 'react-icons/io5'; import { LuX } from 'react-icons/lu'; +import { Button } from '@/components/common'; +import { useTokens } from '@/components/providers/TokenProvider'; +import { DEFAULT_MIN_SUPPLY_USD } from '@/constants/markets'; +import { useLocalStorage } from '@/hooks/useLocalStorage'; +import { useMarkets } from '@/hooks/useMarkets'; import { formatBalance, formatReadable } from '@/utils/balance'; import { getViemChain } from '@/utils/networks'; import { parsePriceFeedVendors, PriceFeedVendors, OracleVendorIcons } from '@/utils/oracle'; -import { ERC20Token, UnknownERC20Token, infoToKey, findToken } from '@/utils/tokens'; +import * as keys from "@/utils/storageKeys" +import { ERC20Token, UnknownERC20Token, infoToKey } from '@/utils/tokens'; import { Market } from '@/utils/types'; +import MarketSettingsModal from 'app/markets/components/MarketSettingsModal'; import { Pagination } from '../../../app/markets/components/Pagination'; import { MarketIdBadge } from '../MarketIdBadge'; import { MarketIdentity, MarketIdentityMode, MarketIdentityFocus } from '../MarketIdentity'; @@ -32,8 +40,8 @@ type MarketsTableWithSameLoanAssetProps = { showSelectColumn?: boolean; // Optional: Hide the cart/staging area showing selected markets showCart?: boolean; - // Optional: entry per page - itemsPerPage?: number + // Optional: Show the settings button (default: true) + showSettings?: boolean; }; enum SortColumn { @@ -44,6 +52,19 @@ enum SortColumn { Risk = 4, } +const getMinSupplyThreshold = (rawValue: string): number => { + if (rawValue === undefined || rawValue === null || rawValue === '') { + return DEFAULT_MIN_SUPPLY_USD; + } + + const parsed = Number(rawValue); + if (Number.isNaN(parsed)) { + return DEFAULT_MIN_SUPPLY_USD; + } + + return Math.max(parsed, 0); +}; + function HTSortable({ label, column, @@ -439,13 +460,34 @@ export function MarketsTableWithSameLoanAsset({ uniqueCollateralTokens, showSelectColumn = true, showCart = true, - itemsPerPage = 8 + showSettings = true, }: MarketsTableWithSameLoanAssetProps): JSX.Element { + // Get global market settings + const { showUnwhitelistedMarkets } = useMarkets(); + const { findToken } = useTokens(); + + // Settings modal state + const [showSettingsModal, setShowSettingsModal] = useState(false); + + // Table state const [currentPage, setCurrentPage] = useState(1); const [sortColumn, setSortColumn] = useState(SortColumn.Supply); const [sortDirection, setSortDirection] = useState<1 | -1>(-1); // -1 = desc, 1 = asc const [collateralFilter, setCollateralFilter] = useState([]); const [oracleFilter, setOracleFilter] = useState([]); + const [searchQuery, setSearchQuery] = useState(''); + + // Settings state (persisted with storage key namespace) + const [hideSmallMarkets, setHideSmallMarkets] = useLocalStorage(keys.MarketsShowSmallMarkets, true); + const [entriesPerPage, setEntriesPerPage] = useLocalStorage(keys.MarketEntriesPerPageKey, 8); + const [includeUnknownTokens, setIncludeUnknownTokens] = useLocalStorage(keys.MarketsShowUnknownTokens, false); + const [showUnknownOracle, setShowUnknownOracle] = useLocalStorage(keys.MarketsShowUnknownOracle, false); + const [usdFilters, setUsdFilters] = useLocalStorage(keys.MarketsUsdMinSupplyKey, { + minSupply: DEFAULT_MIN_SUPPLY_USD.toString(), + minBorrow: '', + }); + + const effectiveMinSupply = getMinSupplyThreshold(usdFilters.minSupply); const handleSort = (column: SortColumn) => { if (sortColumn === column) { @@ -459,7 +501,11 @@ export function MarketsTableWithSameLoanAsset({ // Get unique collaterals with full token data const availableCollaterals = useMemo(() => { if (uniqueCollateralTokens) { - return uniqueCollateralTokens; + return [...uniqueCollateralTokens].sort( + (a, b) => + (a.source === 'local' ? 0 : 1) - (b.source === 'local' ? 0 : 1) || + a.symbol.localeCompare(b.symbol), + ); } // Fallback: build tokens manually from markets @@ -488,14 +534,20 @@ export function MarketsTableWithSameLoanAsset({ address: m.market.collateralAsset.address, chain: getViemChain(m.market.morphoBlue.chain.id), }], + isUnknown: true, + source: 'unknown', }; tokenMap.set(key, token); } } }); - return Array.from(tokenMap.values()).sort((a, b) => a.symbol.localeCompare(b.symbol)); - }, [markets, uniqueCollateralTokens]); + return Array.from(tokenMap.values()).sort( + (a, b) => + (a.source === 'local' ? 0 : 1) - (b.source === 'local' ? 0 : 1) || + a.symbol.localeCompare(b.symbol), + ); + }, [markets, uniqueCollateralTokens, findToken]); // Get unique oracles from current markets const availableOracles = useMemo(() => { @@ -516,6 +568,29 @@ export function MarketsTableWithSameLoanAsset({ const processedMarkets = useMemo(() => { let filtered = [...markets]; + // Apply search filter + if (searchQuery.trim()) { + const query = searchQuery.toLowerCase().trim(); + filtered = filtered.filter((m) => { + const collateralSymbol = m.market?.collateralAsset?.symbol?.toLowerCase() ?? ''; + const marketId = m.market?.uniqueKey?.toLowerCase() ?? ''; + return collateralSymbol.includes(query) || marketId.includes(query); + }); + } + + // Apply whitelist filter + if (!showUnwhitelistedMarkets) { + filtered = filtered.filter((m) => m.market?.whitelisted ?? false); + } + + // Apply small markets filter + if (hideSmallMarkets) { + filtered = filtered.filter((m) => { + const supplyUsd = Number(m.market?.state?.supplyAssetsUsd ?? 0); + return effectiveMinSupply === 0 || supplyUsd >= effectiveMinSupply; + }); + } + // Apply collateral filter if (collateralFilter.length > 0) { filtered = filtered.filter((m) => { @@ -570,7 +645,17 @@ export function MarketsTableWithSameLoanAsset({ }); return filtered; - }, [markets, collateralFilter, oracleFilter, sortColumn, sortDirection]); + }, [ + markets, + collateralFilter, + oracleFilter, + sortColumn, + sortDirection, + searchQuery, + showUnwhitelistedMarkets, + hideSmallMarkets, + effectiveMinSupply, + ]); // Get selected markets const selectedMarkets = useMemo(() => { @@ -578,7 +663,7 @@ export function MarketsTableWithSameLoanAsset({ }, [markets]); // Pagination with guards to prevent invalid states - const safePerPage = Math.max(1, Math.floor(itemsPerPage ?? 8)); + const safePerPage = Math.max(1, Math.floor(entriesPerPage)); const totalPages = Math.max(1, Math.ceil(processedMarkets.length / safePerPage)); const safePage = Math.min(Math.max(1, currentPage), totalPages); const startIndex = (safePage - 1) * safePerPage; @@ -634,6 +719,45 @@ export function MarketsTableWithSameLoanAsset({ )} + {/* Search and Controls */} +
+
+ setSearchQuery(event.target.value)} + endContent={} + classNames={{ + inputWrapper: 'bg-surface rounded-sm focus-within:outline-none', + input: 'bg-surface rounded-sm text-xs focus:outline-none', + }} + size="sm" + /> +
+
+
+ + + Hide markets below ${effectiveMinSupply.toLocaleString()} + +
+ {showSettings && ( + + )} +
+
+ {/* Filters */}
+ + {/* Settings Modal */} + {showSettingsModal && ( + setShowSettingsModal(false)} + includeUnknownTokens={includeUnknownTokens} + setIncludeUnknownTokens={setIncludeUnknownTokens} + showUnknownOracle={showUnknownOracle} + setShowUnknownOracle={setShowUnknownOracle} + usdFilters={usdFilters} + setUsdFilters={setUsdFilters} + entriesPerPage={entriesPerPage} + onEntriesPerPageChange={setEntriesPerPage} + /> + )}
); } diff --git a/src/components/providers/TokenProvider.tsx b/src/components/providers/TokenProvider.tsx index d74ad01c..14bfd034 100644 --- a/src/components/providers/TokenProvider.tsx +++ b/src/components/providers/TokenProvider.tsx @@ -23,6 +23,11 @@ type TokenContextType = { const TokenContext = createContext(null); +const localTokensWithSource: ERC20Token[] = supportedTokens.map((token) => ({ + ...token, + source: 'local', +})); + async function fetchPendleAssets(chainId: number): Promise { try { const response = await fetch(`https://api-v2.pendle.finance/core/v1/${chainId}/assets/all`); @@ -50,11 +55,12 @@ function convertPendleAssetToToken(asset: PendleAsset, chainId: SupportedNetwork protocol: { name: 'Pendle', }, + source: 'external', }; } export function TokenProvider({ children }: { children: React.ReactNode }) { - const [allTokens, setAllTokens] = useState(supportedTokens); + const [allTokens, setAllTokens] = useState(localTokensWithSource); useEffect(() => { async function fetchAllAssets() { @@ -85,7 +91,7 @@ export function TokenProvider({ children }: { children: React.ReactNode }) { ); }); - setAllTokens([...supportedTokens, ...filteredPendleTokens]); + setAllTokens([...localTokensWithSource, ...filteredPendleTokens]); } catch (err) { console.error('Error fetching Pendle assets:', err); } diff --git a/src/constants/markets.ts b/src/constants/markets.ts new file mode 100644 index 00000000..d1d1e3bc --- /dev/null +++ b/src/constants/markets.ts @@ -0,0 +1 @@ +export const DEFAULT_MIN_SUPPLY_USD = 1000; diff --git a/src/hooks/useVaultAllocations.ts b/src/hooks/useVaultAllocations.ts index a7e0efd4..b393761e 100644 --- a/src/hooks/useVaultAllocations.ts +++ b/src/hooks/useVaultAllocations.ts @@ -42,7 +42,7 @@ export function useVaultAllocations({ chainId, enabled = true, }: UseVaultAllocationsArgs): UseVaultAllocationsReturn { - const { markets } = useMarkets(); + const { allMarkets } = useMarkets(); // Parse and filter collateral caps const { validCollateralCaps, parsedCollateralCaps } = useMemo(() => { @@ -85,7 +85,7 @@ export function useVaultAllocations({ // Only include if this is a market cap with a valid market ID if (params.type === 'market' && params.marketId) { - const market = markets.find( + const market = allMarkets.find( (m) => m.uniqueKey.toLowerCase() === params.marketId?.toLowerCase() ); @@ -105,7 +105,7 @@ export function useVaultAllocations({ }); return { validMarketCaps: valid, parsedMarketCaps: parsed }; - }, [marketCaps, markets]); + }, [marketCaps, allMarkets]); // Combine all valid caps for fetching allocations const allValidCaps = useMemo( diff --git a/src/utils/storageKeys.ts b/src/utils/storageKeys.ts index 2ae4dbe3..5856fbe0 100644 --- a/src/utils/storageKeys.ts +++ b/src/utils/storageKeys.ts @@ -1,11 +1,9 @@ export const MarketSortColumnKey = 'monarch_marketsSortColumn'; export const MarketSortDirectionKey = 'monarch_marketsSortDirection'; -export const MarketsHideDustKey = 'monarch_marketsHideDust'; -export const MarketsHideUnknownKey = 'monarch_marketsHideUnknown'; + export const MarketFavoritesKey = 'monarch_marketsFavorites'; export const MarketEntriesPerPageKey = 'monarch_marketsEntriesPerPage'; -export const MarketsShowUnknownKey = 'monarch_marketsShowUnknown'; -export const MarketsShowUnknownOracleKey = 'monarch_marketsShowUnknownOracle'; + export const MarketsUsdMinSupplyKey = 'monarch_marketsUsdMinSupply'; export const MarketsUsdMinBorrowKey = 'monarch_marketsUsdMinBorrow'; @@ -15,3 +13,7 @@ export const PositionsShowCollateralExposureKey = 'positions:show-collateral-exp export const ThemeKey = 'theme'; export const CacheMarketPositionKeys = 'monarch_cache_market_unique_keys'; + +export const MarketsShowSmallMarkets = 'monarch_show_small_markets' +export const MarketsShowUnknownTokens = 'includeUnknownTokens'; +export const MarketsShowUnknownOracle = 'showUnknownOracle'; \ No newline at end of file diff --git a/src/utils/tokens.ts b/src/utils/tokens.ts index 0fd2db82..31f25437 100644 --- a/src/utils/tokens.ts +++ b/src/utils/tokens.ts @@ -1,6 +1,8 @@ import { Chain, base, mainnet, polygon, unichain, arbitrum } from 'viem/chains'; import { getWrappedNativeToken, hyperevm } from './networks'; +export type TokenSource = 'local' | 'external' | 'unknown'; + export type SingleChainERC20Basic = { symbol: string; decimals: number; @@ -23,6 +25,7 @@ export type ERC20Token = { name: string; }; isFactoryToken?: boolean; + source?: TokenSource; // this is not a "hard peg", instead only used for market supply / borrow USD value estimation peg?: TokenPeg; @@ -34,6 +37,7 @@ export type UnknownERC20Token = { decimals: number; networks: { chain: Chain; address: string }[]; isUnknown?: boolean; + source?: TokenSource; }; const MORPHO_TOKEN_BASE = '0xBAa5CC21fd487B8Fcc2F632f3F4E8D37262a0842';