@@ -60,8 +60,8 @@ function RiskPage() {
This page covers advanced topics. For a comprehensive overview of Monarch, please visit
our{' '}
-
- introduction page
+
+ home page
.
@@ -91,4 +91,4 @@ function RiskPage() {
);
}
-export default RiskPage;
+export default RiskContent;
diff --git a/app/info/risks/page.tsx b/app/risks/page.tsx
similarity index 86%
rename from app/info/risks/page.tsx
rename to app/risks/page.tsx
index 24da7b4f..6c8574b5 100644
--- a/app/info/risks/page.tsx
+++ b/app/risks/page.tsx
@@ -1,6 +1,5 @@
import { generateMetadata } from '@/utils/generateMetadata';
-
-import RiskContent from '../components/risk';
+import RiskContent from './RiskContent';
export const metadata = generateMetadata({
title: 'Risks | Monarch',
diff --git a/package.json b/package.json
index ca2c6aba..14a98d58 100644
--- a/package.json
+++ b/package.json
@@ -42,6 +42,7 @@
"@types/react-table": "^7.7.20",
"@uniswap/permit2-sdk": "^1.2.1",
"abitype": "^0.10.3",
+ "animejs": "^4.2.2",
"clsx": "^2.1.0",
"downshift": "^9.0.8",
"framer-motion": "^11.2.10",
@@ -62,6 +63,7 @@
"react-spinners": "^0.14.1",
"react-table": "^7.8.0",
"react-toastify": "11.0.2",
+ "react-type-animation": "^3.2.0",
"recharts": "^2.13.0",
"rehype-pretty-code": "0.12.3",
"rehype-stringify": "^10.0.0",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index f7ba42bf..0956ecd1 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -77,6 +77,9 @@ importers:
abitype:
specifier: ^0.10.3
version: 0.10.3(typescript@5.6.3)(zod@3.25.76)
+ animejs:
+ specifier: ^4.2.2
+ version: 4.2.2
clsx:
specifier: ^2.1.0
version: 2.1.1
@@ -137,6 +140,9 @@ importers:
react-toastify:
specifier: 11.0.2
version: 11.0.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ react-type-animation:
+ specifier: ^3.2.0
+ version: 3.2.0(prop-types@15.8.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
recharts:
specifier: ^2.13.0
version: 2.15.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -3892,6 +3898,9 @@ packages:
ajv@8.17.1:
resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==}
+ animejs@4.2.2:
+ resolution: {integrity: sha512-Ys3RuvLdAeI14fsdKCQy7ytu4057QX6Bb7m4jwmfd6iKmUmLquTwk1ut0e4NtRQgCeq/s2Lv5+oMBjz6c7ZuIg==}
+
ansi-escapes@4.3.2:
resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==}
engines: {node: '>=8'}
@@ -6739,6 +6748,13 @@ packages:
react: '>=16.6.0'
react-dom: '>=16.6.0'
+ react-type-animation@3.2.0:
+ resolution: {integrity: sha512-WXTe0i3rRNKjmggPvT5ntye1QBt0ATGbijeW6V3cQe2W0jaMABXXlPPEdtofnS9tM7wSRHchEvI9SUw+0kUohw==}
+ peerDependencies:
+ prop-types: ^15.5.4
+ react: '>= 15.0.0'
+ react-dom: '>= 15.0.0'
+
react@18.3.1:
resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==}
engines: {node: '>=0.10.0'}
@@ -13437,6 +13453,8 @@ snapshots:
json-schema-traverse: 1.0.0
require-from-string: 2.0.2
+ animejs@4.2.2: {}
+
ansi-escapes@4.3.2:
dependencies:
type-fest: 0.21.3
@@ -16893,6 +16911,12 @@ snapshots:
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
+ react-type-animation@3.2.0(prop-types@15.8.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
+ dependencies:
+ prop-types: 15.8.1
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+
react@18.3.1:
dependencies:
loose-envify: 1.4.0
diff --git a/src/components/RiskNotificationModal.tsx b/src/components/RiskNotificationModal.tsx
index 1a41b940..f71aeccf 100644
--- a/src/components/RiskNotificationModal.tsx
+++ b/src/components/RiskNotificationModal.tsx
@@ -20,7 +20,7 @@ export default function RiskNotificationModal() {
useEffect(() => {
const hasReadRisks = localStorage.getItem('hasReadRisks');
- if (hasReadRisks !== 'true' && pathname !== '/info/risks') {
+ if (hasReadRisks !== 'true' && pathname !== '/risks') {
setIsOpen(true);
}
}, [pathname]);
@@ -32,7 +32,7 @@ export default function RiskNotificationModal() {
}
};
- if (pathname === '/info/risks' || pathname === '/' || pathname === '/info') {
+ if (pathname === '/risks' || pathname === '/') {
return null;
}
@@ -47,8 +47,8 @@ export default function RiskNotificationModal() {
Monarch enables direct lending to the Morpho Blue protocol. Before proceeding, it's
important to understand the key aspects of this approach. For a comprehensive overview,
please visit our{' '}
-
- introduction page
+
+ home page
.
@@ -65,7 +65,7 @@ export default function RiskNotificationModal() {
While this approach offers more control, it also requires a deeper understanding of
market dynamics. For a detailed explanation of the risks and considerations, please read
our{' '}
-
+
risk assessment page
.
diff --git a/src/components/animations/RebalanceAnimation.tsx b/src/components/animations/RebalanceAnimation.tsx
new file mode 100644
index 00000000..d5825977
--- /dev/null
+++ b/src/components/animations/RebalanceAnimation.tsx
@@ -0,0 +1,130 @@
+'use client';
+
+import { useState, useEffect } from 'react';
+import Image from 'next/image';
+
+// Import token images
+import monarchLogo from '../../components/imgs/logo.png';
+import cbBTCImg from '../../imgs/tokens/cbbtc.webp';
+import cbETHImg from '../../imgs/tokens/cbeth.png';
+import usdcImg from '../../imgs/tokens/usdc.webp';
+import wethImg from '../../imgs/tokens/weth.webp';
+import wstETHImg from '../../imgs/tokens/wsteth.webp';
+
+type Token = {
+ name: string;
+ image: any;
+};
+
+const tokens: Token[] = [
+ { name: 'USDC', image: usdcImg },
+ { name: 'cbBTC', image: cbBTCImg },
+ { name: 'cbETH', image: cbETHImg },
+ { name: 'wstETH', image: wstETHImg },
+ { name: 'WETH', image: wethImg },
+ { name: 'Monarch', image: monarchLogo },
+];
+
+type SlotState = {
+ currentIndex: number;
+ isSpinning: boolean;
+ nextIndex: number;
+};
+
+function RebalanceAnimation() {
+ const [slots, setSlots] = useState
([
+ { currentIndex: 0, isSpinning: false, nextIndex: 0 },
+ { currentIndex: 1, isSpinning: false, nextIndex: 1 },
+ { currentIndex: 2, isSpinning: false, nextIndex: 2 },
+ ]);
+
+ useEffect(() => {
+ const interval = setInterval(() => {
+ const SLOT_COUNT = 3
+
+ // Randomly decide how many slots to change (1-3)
+ const numSlotsToChange = Math.floor(Math.random() * SLOT_COUNT) + 1;
+
+ // Randomly select which slots to change
+ const slotsToChange = new Set();
+ while (slotsToChange.size < numSlotsToChange) {
+ slotsToChange.add(Math.floor(Math.random() * SLOT_COUNT));
+ }
+
+ setSlots((prevSlots) =>
+ prevSlots.map((slot, index) => {
+ if (slotsToChange.has(index)) {
+ // Pick a random next token
+ const nextIndex = Math.floor(Math.random() * tokens.length);
+ return {
+ ...slot,
+ isSpinning: true,
+ nextIndex,
+ };
+ }
+ return slot;
+ })
+ );
+
+ // After animation duration, update the current index
+ setTimeout(() => {
+ setSlots((prevSlots) =>
+ prevSlots.map((slot, index) => {
+ if (slotsToChange.has(index)) {
+ return {
+ currentIndex: slot.nextIndex,
+ isSpinning: false,
+ nextIndex: slot.nextIndex,
+ };
+ }
+ return slot;
+ })
+ );
+ }, 500); // Match this with CSS transition duration
+ }, 3000); // Change every 3 seconds
+
+ return () => clearInterval(interval);
+ }, []);
+
+ return (
+
+ {slots.map((slot, index) => (
+
+ {/* Current token - slides up when spinning */}
+
+
+
+ {/* Next token - slides up from bottom when spinning */}
+
+
+
+
+ ))}
+
+ );
+}
+
+export default RebalanceAnimation;
diff --git a/src/components/common/MarketsTableWithSameLoanAsset.tsx b/src/components/common/MarketsTableWithSameLoanAsset.tsx
index fe5aac05..6ad1ff37 100644
--- a/src/components/common/MarketsTableWithSameLoanAsset.tsx
+++ b/src/components/common/MarketsTableWithSameLoanAsset.tsx
@@ -8,10 +8,12 @@ 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 { DEFAULT_MIN_SUPPLY_USD, DEFAULT_MIN_LIQUIDITY_USD } from '@/constants/markets';
import { useLocalStorage } from '@/hooks/useLocalStorage';
import { useMarkets } from '@/hooks/useMarkets';
import { formatBalance, formatReadable } from '@/utils/balance';
+import { filterMarkets, sortMarkets, createPropertySort } from '@/utils/marketFilters';
+import { parseNumericThreshold } from '@/utils/markets';
import { getViemChain } from '@/utils/networks';
import { parsePriceFeedVendors, PriceFeedVendors, OracleVendorIcons } from '@/utils/oracle';
import * as keys from "@/utils/storageKeys"
@@ -52,19 +54,6 @@ 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,
@@ -478,7 +467,6 @@ export function MarketsTableWithSameLoanAsset({
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);
@@ -489,25 +477,45 @@ export function MarketsTableWithSameLoanAsset({
DEFAULT_MIN_SUPPLY_USD.toString(),
);
const [usdMinBorrow, setUsdMinBorrow] = useLocalStorage(keys.MarketsUsdMinBorrowKey, '');
+ const [usdMinLiquidity, setUsdMinLiquidity] = useLocalStorage(
+ keys.MarketsUsdMinLiquidityKey,
+ DEFAULT_MIN_LIQUIDITY_USD.toString(),
+ );
+
+ // USD Filter enabled states
+ const [minSupplyEnabled, setMinSupplyEnabled] = useLocalStorage(
+ keys.MarketsMinSupplyEnabledKey,
+ true, // Default to enabled for backward compatibility
+ );
+ const [minBorrowEnabled, setMinBorrowEnabled] = useLocalStorage(
+ keys.MarketsMinBorrowEnabledKey,
+ false,
+ );
+ const [minLiquidityEnabled, setMinLiquidityEnabled] = useLocalStorage(
+ keys.MarketsMinLiquidityEnabledKey,
+ false,
+ );
// Create memoized usdFilters object from individual localStorage values
const usdFilters = useMemo(
() => ({
minSupply: usdMinSupply,
minBorrow: usdMinBorrow,
+ minLiquidity: usdMinLiquidity,
}),
- [usdMinSupply, usdMinBorrow],
+ [usdMinSupply, usdMinBorrow, usdMinLiquidity],
);
const setUsdFilters = useCallback(
- (filters: { minSupply: string; minBorrow: string }) => {
+ (filters: { minSupply: string; minBorrow: string; minLiquidity: string }) => {
setUsdMinSupply(filters.minSupply);
setUsdMinBorrow(filters.minBorrow);
+ setUsdMinLiquidity(filters.minLiquidity);
},
- [setUsdMinSupply, setUsdMinBorrow],
+ [setUsdMinSupply, setUsdMinBorrow, setUsdMinLiquidity],
);
- const effectiveMinSupply = getMinSupplyThreshold(usdFilters.minSupply);
+ const effectiveMinSupply = parseNumericThreshold(usdFilters.minSupply);
const handleSort = (column: SortColumn) => {
if (sortColumn === column) {
@@ -584,87 +592,50 @@ export function MarketsTableWithSameLoanAsset({
return Array.from(oracleSet);
}, [markets]);
- // Filter and sort markets
+ // Filter and sort markets using the new shared filtering system
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);
- });
- }
+ // Extract just the markets for filtering
+ const marketsList = markets.map((m) => m.market);
+
+ // Apply global filters using the shared utility
+ let filtered = filterMarkets(marketsList, {
+ showUnknownTokens: includeUnknownTokens,
+ showUnknownOracle,
+ selectedCollaterals: collateralFilter,
+ selectedOracles: oracleFilter,
+ usdFilters: {
+ minSupply: { enabled: minSupplyEnabled, threshold: usdFilters.minSupply },
+ minBorrow: { enabled: minBorrowEnabled, threshold: usdFilters.minBorrow },
+ minLiquidity: { enabled: minLiquidityEnabled, threshold: usdFilters.minLiquidity },
+ },
+ findToken,
+ searchQuery,
+ });
- // Apply whitelist filter
+ // Apply whitelist filter (not in the shared utility because it uses global state)
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;
- });
+ filtered = filtered.filter((market) => market.whitelisted ?? false);
}
- // Apply collateral filter
- if (collateralFilter.length > 0) {
- filtered = filtered.filter((m) => {
- // Add null checks
- if (!m?.market?.collateralAsset?.address || !m?.market?.morphoBlue?.chain?.id) {
- return false;
- }
- const key = infoToKey(m.market.collateralAsset.address, m.market.morphoBlue.chain.id);
- return collateralFilter.some((filterKey) =>
- filterKey.split('|').includes(key)
- );
- });
- }
+ // Sort using the shared utility
+ const sortPropertyMap: Record = {
+ [SortColumn.MarketName]: 'collateralAsset.symbol',
+ [SortColumn.Supply]: 'state.supplyAssetsUsd',
+ [SortColumn.APY]: 'state.supplyApy',
+ [SortColumn.Liquidity]: 'state.liquidityAssets',
+ [SortColumn.Risk]: '', // No sorting for risk
+ };
- // Apply oracle filter
- if (oracleFilter.length > 0) {
- filtered = filtered.filter((m) => {
- // Add null checks
- if (!m?.market?.morphoBlue?.chain?.id) {
- return false;
- }
- const vendorInfo = parsePriceFeedVendors(m.market.oracle?.data, m.market.morphoBlue.chain.id);
- return vendorInfo?.coreVendors?.some((v) => oracleFilter.includes(v)) ?? false;
- });
+ const propertyPath = sortPropertyMap[sortColumn];
+ if (propertyPath && sortColumn !== SortColumn.Risk) {
+ filtered = sortMarkets(filtered, createPropertySort(propertyPath), sortDirection);
}
- // Sort
- filtered.sort((a, b) => {
- let comparison = 0;
- switch (sortColumn) {
- case SortColumn.MarketName:
- comparison = (a.market?.collateralAsset?.symbol ?? '').localeCompare(
- b.market?.collateralAsset?.symbol ?? '',
- );
- break;
- case SortColumn.Supply:
- comparison =
- Number(a.market?.state?.supplyAssetsUsd ?? 0) - Number(b.market?.state?.supplyAssetsUsd ?? 0);
- break;
- case SortColumn.APY:
- comparison = (a.market?.state?.supplyApy ?? 0) - (b.market?.state?.supplyApy ?? 0);
- break;
- case SortColumn.Liquidity:
- comparison =
- Number(a.market?.state?.liquidityAssets ?? 0) - Number(b.market?.state?.liquidityAssets ?? 0);
- break;
- case SortColumn.Risk:
- comparison = 0;
- break;
- }
- return comparison * sortDirection;
+ // Map back to MarketWithSelection
+ return filtered.map((market) => {
+ const original = markets.find((m) => m.market.uniqueKey === market.uniqueKey);
+ return original ?? { market, isSelected: false };
});
-
- return filtered;
}, [
markets,
collateralFilter,
@@ -673,8 +644,13 @@ export function MarketsTableWithSameLoanAsset({
sortDirection,
searchQuery,
showUnwhitelistedMarkets,
- hideSmallMarkets,
- effectiveMinSupply,
+ includeUnknownTokens,
+ showUnknownOracle,
+ minSupplyEnabled,
+ minBorrowEnabled,
+ minLiquidityEnabled,
+ usdFilters,
+ findToken,
]);
// Get selected markets
@@ -758,11 +734,11 @@ export function MarketsTableWithSameLoanAsset({
- Hide markets below ${effectiveMinSupply.toLocaleString()}
+ Hide markets below ${formatReadable(effectiveMinSupply)}
{showSettings && (
@@ -873,6 +849,12 @@ export function MarketsTableWithSameLoanAsset({
setShowUnknownOracle={setShowUnknownOracle}
usdFilters={usdFilters}
setUsdFilters={setUsdFilters}
+ minSupplyEnabled={minSupplyEnabled}
+ setMinSupplyEnabled={setMinSupplyEnabled}
+ minBorrowEnabled={minBorrowEnabled}
+ setMinBorrowEnabled={setMinBorrowEnabled}
+ minLiquidityEnabled={minLiquidityEnabled}
+ setMinLiquidityEnabled={setMinLiquidityEnabled}
entriesPerPage={entriesPerPage}
onEntriesPerPageChange={setEntriesPerPage}
/>
diff --git a/src/constants/markets.ts b/src/constants/markets.ts
index d1d1e3bc..84f1f936 100644
--- a/src/constants/markets.ts
+++ b/src/constants/markets.ts
@@ -1 +1,2 @@
export const DEFAULT_MIN_SUPPLY_USD = 1000;
+export const DEFAULT_MIN_LIQUIDITY_USD = 10000;
diff --git a/src/hooks/useAllocations.ts b/src/hooks/useAllocations.ts
index 8b8b30dd..07306895 100644
--- a/src/hooks/useAllocations.ts
+++ b/src/hooks/useAllocations.ts
@@ -31,7 +31,7 @@ export function useAllocations({
enabled = true,
}: UseAllocationsArgs): UseAllocationsReturn {
const [allocations, setAllocations] = useState([]);
- const [loading, setLoading] = useState(false);
+ const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
// Create a stable key from capIds to detect actual changes
@@ -42,6 +42,7 @@ export function useAllocations({
const load = useCallback(async () => {
if (!vaultAddress || !enabled || caps.length === 0) {
setAllocations([]);
+ setLoading(false);
return;
}
diff --git a/src/hooks/useMorphoMarketV1Adapters.ts b/src/hooks/useMorphoMarketV1Adapters.ts
index 44a6d953..cdfedd40 100644
--- a/src/hooks/useMorphoMarketV1Adapters.ts
+++ b/src/hooks/useMorphoMarketV1Adapters.ts
@@ -12,7 +12,7 @@ export function useMorphoMarketV1Adapters({
chainId: SupportedNetworks;
}) {
const [adapters, setAdapters] = useState([]);
- const [loading, setLoading] = useState(false);
+ const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const vaultConfig = useMemo(() => {
@@ -30,6 +30,7 @@ export function useMorphoMarketV1Adapters({
if (!vaultAddress || !subgraphUrl) {
setAdapters([]);
setError(null);
+ setLoading(false);
return;
}
diff --git a/src/hooks/useVaultV2Data.ts b/src/hooks/useVaultV2Data.ts
index 9ace3915..aba7ad53 100644
--- a/src/hooks/useVaultV2Data.ts
+++ b/src/hooks/useVaultV2Data.ts
@@ -52,12 +52,13 @@ export function useVaultV2Data({
const { findToken } = useTokens();
const [data, setData] = useState(null);
- const [loading, setLoading] = useState(false);
+ const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const load = useCallback(async () => {
if (!vaultAddress) {
setData(null);
+ setLoading(false);
return;
}
diff --git a/src/imgs/intro/morpho-logo-darkmode.svg b/src/imgs/intro/morpho-logo-darkmode.svg
new file mode 100644
index 00000000..813a9de5
--- /dev/null
+++ b/src/imgs/intro/morpho-logo-darkmode.svg
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/imgs/intro/morpho-logo-lightmode.svg b/src/imgs/intro/morpho-logo-lightmode.svg
new file mode 100644
index 00000000..9246b5b4
--- /dev/null
+++ b/src/imgs/intro/morpho-logo-lightmode.svg
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/imgs/intro/vault.webp b/src/imgs/intro/vault.webp
new file mode 100644
index 00000000..991fac4e
Binary files /dev/null and b/src/imgs/intro/vault.webp differ
diff --git a/src/imgs/intro/vaults.png b/src/imgs/intro/vaults.png
deleted file mode 100644
index b83697f7..00000000
Binary files a/src/imgs/intro/vaults.png and /dev/null differ
diff --git a/src/utils/marketFilters.ts b/src/utils/marketFilters.ts
new file mode 100644
index 00000000..df31ca74
--- /dev/null
+++ b/src/utils/marketFilters.ts
@@ -0,0 +1,377 @@
+/**
+ * Shared, composable filtering utilities for market tables.
+ *
+ * This module provides a flexible filtering system that can be used across
+ * different market views (main markets page, same-loan-asset tables, etc.)
+ */
+
+import { parseNumericThreshold } from '@/utils/markets';
+import { SupportedNetworks } from '@/utils/networks';
+import { parsePriceFeedVendors, PriceFeedVendors, getOracleType, OracleType } from '@/utils/oracle';
+import { ERC20Token } from '@/utils/tokens';
+import { Market } from '@/utils/types';
+
+// ============================================================================
+// Types
+// ============================================================================
+
+export type MarketFilter = (market: Market) => boolean;
+
+export type UsdFilterConfig = {
+ enabled: boolean;
+ threshold: string; // stored as string for input compatibility
+};
+
+export type UsdFiltersConfig = {
+ minSupply: UsdFilterConfig;
+ minBorrow: UsdFilterConfig;
+ minLiquidity: UsdFilterConfig;
+};
+
+export type MarketFilterOptions = {
+ // Network filter
+ selectedNetwork?: SupportedNetworks | null;
+
+ // Token visibility
+ showUnknownTokens?: boolean;
+ showUnknownOracle?: boolean;
+
+ // Asset filters
+ selectedCollaterals?: string[];
+ selectedLoanAssets?: string[];
+
+ // Oracle filters
+ selectedOracles?: PriceFeedVendors[];
+
+ // USD filters with enabled/disabled states
+ usdFilters?: UsdFiltersConfig;
+
+ // Helper function to find tokens
+ findToken?: (address: string, chainId: number) => ERC20Token | undefined;
+
+ // Search query
+ searchQuery?: string;
+
+ // Starred markets
+ staredIds?: string[];
+};
+
+// ============================================================================
+// Individual Filter Functions (Composable)
+// ============================================================================
+
+/**
+ * Filter by network/chain
+ */
+export const createNetworkFilter = (selectedNetwork: SupportedNetworks | null): MarketFilter => {
+ if (selectedNetwork === null) {
+ return () => true;
+ }
+ return (market) => market.morphoBlue.chain.id === selectedNetwork;
+};
+
+/**
+ * Filter by unknown tokens (requires token lookup)
+ */
+export const createUnknownTokenFilter = (
+ showUnknown: boolean,
+ findToken: (address: string, chainId: number) => ERC20Token | undefined,
+): MarketFilter => {
+ if (showUnknown) {
+ return () => true;
+ }
+ return (market) => {
+ const collateralToken = findToken(market.collateralAsset.address, market.morphoBlue.chain.id);
+ const loanToken = findToken(market.loanAsset.address, market.morphoBlue.chain.id);
+ return !!(collateralToken && loanToken);
+ };
+};
+
+/**
+ * Filter by unknown oracles
+ */
+export const createUnknownOracleFilter = (showUnknownOracle: boolean): MarketFilter => {
+ if (showUnknownOracle) {
+ return () => true;
+ }
+ return (market) => {
+ if (!market.oracle) return false;
+
+ const info = parsePriceFeedVendors(market.oracle.data, market.morphoBlue.chain.id);
+ const isCustom =
+ getOracleType(market.oracle?.data, market.oracleAddress, market.morphoBlue.chain.id) ===
+ OracleType.Custom;
+ const isUnknown = isCustom || (info?.hasUnknown ?? false);
+
+ return !isUnknown;
+ };
+};
+
+/**
+ * Filter by selected collateral assets
+ */
+export const createCollateralFilter = (selectedCollaterals: string[]): MarketFilter => {
+ if (selectedCollaterals.length === 0) {
+ return () => true;
+ }
+ return (market) => {
+ return selectedCollaterals.some((combinedKey) =>
+ combinedKey
+ .split('|')
+ .includes(
+ `${market.collateralAsset.address.toLowerCase()}-${market.morphoBlue.chain.id}`,
+ ),
+ );
+ };
+};
+
+/**
+ * Filter by selected loan assets
+ */
+export const createLoanAssetFilter = (selectedLoanAssets: string[]): MarketFilter => {
+ if (selectedLoanAssets.length === 0) {
+ return () => true;
+ }
+ return (market) => {
+ return selectedLoanAssets.some((combinedKey) =>
+ combinedKey
+ .split('|')
+ .includes(
+ `${market.loanAsset.address.toLowerCase()}-${market.morphoBlue.chain.id}`,
+ ),
+ );
+ };
+};
+
+/**
+ * Filter by selected oracles
+ */
+export const createOracleFilter = (selectedOracles: PriceFeedVendors[]): MarketFilter => {
+ if (selectedOracles.length === 0) {
+ return () => true;
+ }
+ return (market) => {
+ if (!market.oracle) return false;
+ const marketOracles = parsePriceFeedVendors(
+ market.oracle.data,
+ market.morphoBlue.chain.id,
+ ).vendors;
+ return marketOracles.some((oracle) => selectedOracles.includes(oracle));
+ };
+};
+
+/**
+ * Filter by minimum supply USD (with enabled flag)
+ */
+export const createMinSupplyFilter = (config: UsdFilterConfig): MarketFilter => {
+ if (!config.enabled) {
+ return () => true;
+ }
+ const threshold = parseNumericThreshold(config.threshold);
+ if (threshold === 0) {
+ return () => true;
+ }
+ return (market) => {
+ const supplyUsd = Number(market.state?.supplyAssetsUsd ?? 0);
+ return supplyUsd >= threshold;
+ };
+};
+
+/**
+ * Filter by minimum borrow USD (with enabled flag)
+ */
+export const createMinBorrowFilter = (config: UsdFilterConfig): MarketFilter => {
+ if (!config.enabled) {
+ return () => true;
+ }
+ const threshold = parseNumericThreshold(config.threshold);
+ if (threshold === 0) {
+ return () => true;
+ }
+ return (market) => {
+ const borrowUsd = Number(market.state?.borrowAssetsUsd ?? 0);
+ return borrowUsd >= threshold;
+ };
+};
+
+/**
+ * Filter by minimum liquidity (with enabled flag)
+ */
+export const createMinLiquidityFilter = (config: UsdFilterConfig): MarketFilter => {
+ if (!config.enabled) {
+ return () => true;
+ }
+ const threshold = parseNumericThreshold(config.threshold);
+ if (threshold === 0) {
+ return () => true;
+ }
+ return (market) => {
+ const liquidityUsd = Number(market.state?.liquidityAssetsUsd ?? 0);
+ return liquidityUsd >= threshold;
+ };
+};
+
+/**
+ * Filter by search query (collateral, loan, market ID, oracle vendors)
+ */
+export const createSearchFilter = (searchQuery: string): MarketFilter => {
+ if (!searchQuery || searchQuery.trim() === '') {
+ return () => true;
+ }
+ const lowercaseQuery = searchQuery.toLowerCase().trim();
+ return (market) => {
+ const { vendors } = parsePriceFeedVendors(market.oracle?.data, market.morphoBlue.chain.id);
+ const vendorsName = vendors.join(',');
+ return (
+ market.uniqueKey.toLowerCase().includes(lowercaseQuery) ||
+ market.collateralAsset.symbol.toLowerCase().includes(lowercaseQuery) ||
+ market.loanAsset.symbol.toLowerCase().includes(lowercaseQuery) ||
+ vendorsName.toLowerCase().includes(lowercaseQuery)
+ );
+ };
+};
+
+/**
+ * Filter by whitelisted status (uses global setting)
+ */
+export const createWhitelistFilter = (showUnwhitelistedMarkets: boolean): MarketFilter => {
+ if (showUnwhitelistedMarkets) {
+ return () => true;
+ }
+ return (market) => market.whitelisted ?? false;
+};
+
+// ============================================================================
+// Combined Filtering
+// ============================================================================
+
+/**
+ * Apply multiple filters to a list of markets.
+ * All filters must pass for a market to be included.
+ */
+export const applyFilters = (markets: Market[], filters: MarketFilter[]): Market[] => {
+ return markets.filter((market) => filters.every((filter) => filter(market)));
+};
+
+/**
+ * Create all filters from options and apply them to markets.
+ * This is the main entry point for filtering markets.
+ */
+export const filterMarkets = (
+ markets: Market[],
+ options: MarketFilterOptions,
+): Market[] => {
+ const filters: MarketFilter[] = [];
+
+ // Network filter
+ if (options.selectedNetwork !== undefined) {
+ filters.push(createNetworkFilter(options.selectedNetwork));
+ }
+
+ // Unknown tokens filter
+ if (options.showUnknownTokens !== undefined && options.findToken) {
+ filters.push(createUnknownTokenFilter(options.showUnknownTokens, options.findToken));
+ }
+
+ // Unknown oracle filter
+ if (options.showUnknownOracle !== undefined) {
+ filters.push(createUnknownOracleFilter(options.showUnknownOracle));
+ }
+
+ // Collateral filter
+ if (options.selectedCollaterals) {
+ filters.push(createCollateralFilter(options.selectedCollaterals));
+ }
+
+ // Loan asset filter
+ if (options.selectedLoanAssets) {
+ filters.push(createLoanAssetFilter(options.selectedLoanAssets));
+ }
+
+ // Oracle filter
+ if (options.selectedOracles) {
+ filters.push(createOracleFilter(options.selectedOracles));
+ }
+
+ // USD filters (with enabled flags)
+ if (options.usdFilters) {
+ filters.push(createMinSupplyFilter(options.usdFilters.minSupply));
+ filters.push(createMinBorrowFilter(options.usdFilters.minBorrow));
+ filters.push(createMinLiquidityFilter(options.usdFilters.minLiquidity));
+ }
+
+ // Search filter
+ if (options.searchQuery) {
+ filters.push(createSearchFilter(options.searchQuery));
+ }
+
+ return applyFilters(markets, filters);
+};
+
+// ============================================================================
+// Sorting
+// ============================================================================
+
+export type SortDirection = 1 | -1; // 1 = asc, -1 = desc
+
+export type MarketSortFn = (a: Market, b: Market) => number;
+
+/**
+ * Sort markets by a comparison function and direction
+ */
+export const sortMarkets = (
+ markets: Market[],
+ sortFn: MarketSortFn,
+ direction: SortDirection = -1,
+): Market[] => {
+ return [...markets].sort((a, b) => sortFn(a, b) * direction);
+};
+
+/**
+ * Get nested property from market object (helper for sorting)
+ */
+export const getNestedProperty = (obj: Market, path: string): unknown => {
+ if (!path) return undefined;
+ return path.split('.').reduce((acc: unknown, part: string) => {
+ return acc && typeof acc === 'object' && part in acc ? (acc as Record)[part] : undefined;
+ }, obj as unknown);
+};
+
+/**
+ * Create a sort function from a property path
+ */
+export const createPropertySort = (propertyPath: string): MarketSortFn => {
+ return (a, b) => {
+ const aValue: unknown = getNestedProperty(a, propertyPath);
+ const bValue: unknown = getNestedProperty(b, propertyPath);
+
+ // Handle undefined/null cases
+ if (aValue === bValue) return 0;
+ if (aValue === undefined || aValue === null) return 1;
+ if (bValue === undefined || bValue === null) return -1;
+
+ // Type guard for comparable values
+ if (typeof aValue === 'number' && typeof bValue === 'number') {
+ return aValue > bValue ? 1 : -1;
+ }
+ if (typeof aValue === 'string' && typeof bValue === 'string') {
+ return aValue > bValue ? 1 : -1;
+ }
+
+ // Fallback: convert to string for comparison
+ return String(aValue) > String(bValue) ? 1 : -1;
+ };
+};
+
+/**
+ * Sort by starred status (starred markets first)
+ */
+export const createStarredSort = (staredIds: string[]): MarketSortFn => {
+ return (a, b) => {
+ const aStared = staredIds.includes(a.uniqueKey);
+ const bStared = staredIds.includes(b.uniqueKey);
+ if (aStared && !bStared) return -1;
+ if (!aStared && bStared) return 1;
+ return 0;
+ };
+};
diff --git a/src/utils/markets.ts b/src/utils/markets.ts
index 9053ef85..ca3d484c 100644
--- a/src/utils/markets.ts
+++ b/src/utils/markets.ts
@@ -1,3 +1,22 @@
+/**
+ * Parse and normalize a numeric threshold value from user input.
+ * - Empty string or "0" → 0 (no threshold)
+ * - Invalid values → 0
+ * - Valid positive numbers → parsed value
+ */
+export const parseNumericThreshold = (rawValue: string | undefined | null): number => {
+ if (rawValue === undefined || rawValue === null || rawValue === '' || rawValue === '0') {
+ return 0;
+ }
+
+ const parsed = Number(rawValue);
+ if (Number.isNaN(parsed)) {
+ return 0;
+ }
+
+ return Math.max(parsed, 0);
+};
+
// Blacklisted markets by uniqueKey
export const blacklistedMarkets = [
'0x8eaf7b29f02ba8d8c1d7aeb587403dcb16e2e943e4e2f5f94b0963c2386406c9', // PAXG / USDC market with wrong oracle
diff --git a/src/utils/storageKeys.ts b/src/utils/storageKeys.ts
index 991a7dcc..3a44b067 100644
--- a/src/utils/storageKeys.ts
+++ b/src/utils/storageKeys.ts
@@ -6,6 +6,12 @@ export const MarketEntriesPerPageKey = 'monarch_marketsEntriesPerPage';
export const MarketsUsdMinSupplyKey = 'monarch_marketsUsdMinSupply_2';
export const MarketsUsdMinBorrowKey = 'monarch_marketsUsdMinBorrow';
+export const MarketsUsdMinLiquidityKey = 'monarch_marketsUsdMinLiquidity';
+
+// USD Filter enabled/disabled states
+export const MarketsMinSupplyEnabledKey = 'monarch_minSupplyEnabled';
+export const MarketsMinBorrowEnabledKey = 'monarch_minBorrowEnabled';
+export const MarketsMinLiquidityEnabledKey = 'monarch_minLiquidityEnabled';
export const PositionsShowEmptyKey = 'positions:show-empty';
export const PositionsShowCollateralExposureKey = 'positions:show-collateral-exposure';
@@ -14,6 +20,7 @@ export const ThemeKey = 'theme';
export const CacheMarketPositionKeys = 'monarch_cache_market_unique_keys';
+// Deprecated: Use MarketsMinSupplyEnabledKey instead
export const MarketsShowSmallMarkets = 'monarch_show_small_markets'
export const MarketsShowUnknownTokens = 'includeUnknownTokens';
export const MarketsShowUnknownOracle = 'showUnknownOracle';
\ No newline at end of file