From 25f7123f62fabbc2fb43f241ef1d9070f7ad1c33 Mon Sep 17 00:00:00 2001 From: antoncoding Date: Wed, 10 Dec 2025 16:32:05 +0800 Subject: [PATCH 1/4] feat: suppliers table --- .claude/settings.local.json | 3 +- app/market/[chainId]/[marketid]/RateChart.tsx | 2 +- .../[chainId]/[marketid]/VolumeChart.tsx | 2 +- .../[marketid]/components/BorrowsTable.tsx | 2 +- .../components/LiquidationsTable.tsx | 2 +- .../components/SupplierFiltersModal.tsx | 92 ++++++++ .../[marketid]/components/SuppliersTable.tsx | 171 +++++++++++++++ .../[marketid]/components/SuppliesTable.tsx | 2 +- app/market/[chainId]/[marketid]/content.tsx | 199 +++++++++++------- package.json | 1 + pnpm-lock.yaml | 32 +++ src/components/ui/tabs.tsx | 51 +++++ .../morpho-api/market-suppliers.ts | 80 +++++++ src/data-sources/subgraph/market-suppliers.ts | 74 +++++++ src/graphql/morpho-api-queries.ts | 32 ++- src/graphql/morpho-subgraph-queries.ts | 20 ++ src/hooks/useMarketSuppliers.ts | 112 ++++++++++ src/utils/types.ts | 13 ++ 18 files changed, 806 insertions(+), 84 deletions(-) create mode 100644 app/market/[chainId]/[marketid]/components/SupplierFiltersModal.tsx create mode 100644 app/market/[chainId]/[marketid]/components/SuppliersTable.tsx create mode 100644 src/components/ui/tabs.tsx create mode 100644 src/data-sources/morpho-api/market-suppliers.ts create mode 100644 src/data-sources/subgraph/market-suppliers.ts create mode 100644 src/hooks/useMarketSuppliers.ts diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 62acf38b..b68cf6cb 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -42,7 +42,8 @@ "Bash(tee:*)", "WebFetch(domain:medium.com)", "Bash(pnpm info:*)", - "Bash(for file in )" + "Bash(for file in )", + "Bash(timeout 30 npx tsc:*)" ], "deny": [] } diff --git a/app/market/[chainId]/[marketid]/RateChart.tsx b/app/market/[chainId]/[marketid]/RateChart.tsx index e40f57b2..e94bc21f 100644 --- a/app/market/[chainId]/[marketid]/RateChart.tsx +++ b/app/market/[chainId]/[marketid]/RateChart.tsx @@ -109,7 +109,7 @@ function RateChart({ historicalData, market, isLoading, selectedTimeframe, selec ]; return ( - + +
diff --git a/app/market/[chainId]/[marketid]/components/BorrowsTable.tsx b/app/market/[chainId]/[marketid]/components/BorrowsTable.tsx index d4c9da3e..54f758e7 100644 --- a/app/market/[chainId]/[marketid]/components/BorrowsTable.tsx +++ b/app/market/[chainId]/[marketid]/components/BorrowsTable.tsx @@ -51,7 +51,7 @@ export function BorrowsTable({ chainId, market, minAssets, onOpenFiltersModal }: } return ( -
+

Borrow & Repay

diff --git a/app/market/[chainId]/[marketid]/components/LiquidationsTable.tsx b/app/market/[chainId]/[marketid]/components/LiquidationsTable.tsx index dbd1b6be..9b9cf4b1 100644 --- a/app/market/[chainId]/[marketid]/components/LiquidationsTable.tsx +++ b/app/market/[chainId]/[marketid]/components/LiquidationsTable.tsx @@ -40,7 +40,7 @@ export function LiquidationsTable({ chainId, market }: LiquidationsTableProps) { } return ( -
+

Liquidations

diff --git a/app/market/[chainId]/[marketid]/components/SupplierFiltersModal.tsx b/app/market/[chainId]/[marketid]/components/SupplierFiltersModal.tsx new file mode 100644 index 00000000..1cc8d5da --- /dev/null +++ b/app/market/[chainId]/[marketid]/components/SupplierFiltersModal.tsx @@ -0,0 +1,92 @@ +import type React from 'react'; +import { Input } from '@heroui/react'; +import { FiSliders } from 'react-icons/fi'; +import { Button } from '@/components/ui/button'; +import { Modal, ModalHeader, ModalBody, ModalFooter } from '@/components/common/Modal'; + +type SupplierFiltersModalProps = { + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; + minShares: string; + onMinSharesChange: (value: string) => void; + loanAssetSymbol: string; +}; + +function SettingItem({ title, description, children }: { title: string; description: string; children: React.ReactNode }) { + return ( +
+
+

{title}

+

{description}

+
+
{children}
+
+ ); +} + +export default function SupplierFiltersModal({ + isOpen, + onOpenChange, + minShares, + onMinSharesChange, + loanAssetSymbol, +}: SupplierFiltersModalProps) { + const handleSharesChange = (e: React.ChangeEvent) => { + const { value } = e.target; + // Allow decimals and empty string + if (value === '' || /^\d*\.?\d*$/.test(value)) { + onMinSharesChange(value); + } + }; + + return ( + + {(onClose) => ( + <> + } + onClose={onClose} + /> + +
+

Minimum Amount

+

Filter suppliers to show only those above the specified minimum amount.

+ + + +
+
+ + + + + )} +
+ ); +} diff --git a/app/market/[chainId]/[marketid]/components/SuppliersTable.tsx b/app/market/[chainId]/[marketid]/components/SuppliersTable.tsx new file mode 100644 index 00000000..d73af9a1 --- /dev/null +++ b/app/market/[chainId]/[marketid]/components/SuppliersTable.tsx @@ -0,0 +1,171 @@ +import { useState, useMemo } from 'react'; +import { Table, TableHeader, TableColumn, TableBody, TableRow, TableCell, Tooltip } from '@heroui/react'; +import { FiFilter } from 'react-icons/fi'; +import type { Address } from 'viem'; +import { formatUnits } from 'viem'; +import { Button } from '@/components/ui/button'; +import { AccountIdentity } from '@/components/common/AccountIdentity'; +import { Spinner } from '@/components/common/Spinner'; +import { TablePagination } from '@/components/common/TablePagination'; +import { TokenIcon } from '@/components/TokenIcon'; +import { TooltipContent } from '@/components/TooltipContent'; +import { MONARCH_PRIMARY } from '@/constants/chartColors'; +import { useMarketSuppliers } from '@/hooks/useMarketSuppliers'; +import { formatSimple } from '@/utils/balance'; +import type { Market } from '@/utils/types'; + +type SuppliersTableProps = { + chainId: number; + market: Market; + minShares: string; + onOpenFiltersModal: () => void; +}; + +export function SuppliersTable({ chainId, market, minShares, onOpenFiltersModal }: SuppliersTableProps) { + const [currentPage, setCurrentPage] = useState(1); + const pageSize = 10; + + const { data: paginatedData, isLoading, isFetching } = useMarketSuppliers(market?.uniqueKey, chainId, minShares, currentPage, pageSize); + + const suppliers = paginatedData?.items ?? []; + const totalCount = paginatedData?.totalCount ?? 0; + const totalPages = Math.ceil(totalCount / pageSize); + + // Convert shares to assets using market state + // Formula: assets = (shares * totalSupply) / totalSupplyShares + const suppliersWithAssets = useMemo(() => { + if (!market?.state) return []; + + const totalSupply = BigInt(market.state.supplyAssets); + const totalSupplyShares = BigInt(market.state.supplyShares); + + if (totalSupplyShares === 0n) return []; + + return suppliers.map((supplier) => { + const shares = BigInt(supplier.supplyShares); + const assets = (shares * totalSupply) / totalSupplyShares; + + return { + ...supplier, + supplyAssets: assets.toString(), + }; + }); + }, [suppliers, market?.state]); + + const handlePageChange = (page: number) => { + setCurrentPage(page); + }; + + const hasActiveFilter = minShares !== '0'; + const tableKey = `suppliers-table-${currentPage}`; + + return ( +
+
+

Top Suppliers

+
+ } + /> + } + > + + +
+
+ +
+ {/* Loading overlay */} + {isFetching && ( +
+ +
+ )} + + + + ACCOUNT + SUPPLIED + % OF SUPPLY + + + {suppliersWithAssets.map((supplier) => { + const totalSupply = BigInt(market.state.supplyAssets); + const supplierAssets = BigInt(supplier.supplyAssets); + const percentOfSupply = totalSupply > 0n ? (Number(supplierAssets) / Number(totalSupply)) * 100 : 0; + const percentDisplay = percentOfSupply < 0.01 && percentOfSupply > 0 ? '<0.01%' : `${percentOfSupply.toFixed(2)}%`; + + return ( + + + + + + {formatSimple(Number(formatUnits(BigInt(supplier.supplyAssets), market.loanAsset.decimals)))} + {market?.loanAsset?.symbol && ( + + + + )} + + {percentDisplay} + + ); + })} + +
+
+ + {totalCount > 0 && ( + + )} +
+ ); +} diff --git a/app/market/[chainId]/[marketid]/components/SuppliesTable.tsx b/app/market/[chainId]/[marketid]/components/SuppliesTable.tsx index fd67d6ca..12a2acb1 100644 --- a/app/market/[chainId]/[marketid]/components/SuppliesTable.tsx +++ b/app/market/[chainId]/[marketid]/components/SuppliesTable.tsx @@ -46,7 +46,7 @@ export function SuppliesTable({ chainId, market, minAssets, onOpenFiltersModal } const tableKey = `supplies-table-${currentPage}`; return ( -
+

Supply & Withdraw

diff --git a/app/market/[chainId]/[marketid]/content.tsx b/app/market/[chainId]/[marketid]/content.tsx index 8f7505a7..c33c9641 100644 --- a/app/market/[chainId]/[marketid]/content.tsx +++ b/app/market/[chainId]/[marketid]/content.tsx @@ -4,14 +4,15 @@ import { useState, useCallback, useMemo } from 'react'; import { Card, CardHeader, CardBody } from '@heroui/react'; -import { ExternalLinkIcon, ChevronLeftIcon } from '@radix-ui/react-icons'; +import { ExternalLinkIcon } from '@radix-ui/react-icons'; import Image from 'next/image'; import Link from 'next/link'; -import { useParams, useRouter, useSearchParams } from 'next/navigation'; +import { useParams } from 'next/navigation'; import { formatUnits, parseUnits } from 'viem'; import { useConnection } from 'wagmi'; import { BorrowModal } from '@/components/BorrowModal'; import { Button } from '@/components/ui/button'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Spinner } from '@/components/common/Spinner'; import Header from '@/components/layout/header/Header'; import { OracleTypeInfo } from '@/components/MarketOracle'; @@ -33,6 +34,8 @@ import { CampaignBadge } from './components/CampaignBadge'; import { LiquidationsTable } from './components/LiquidationsTable'; import { PositionStats } from './components/PositionStats'; import { SuppliesTable } from './components/SuppliesTable'; +import { SuppliersTable } from './components/SuppliersTable'; +import SupplierFiltersModal from './components/SupplierFiltersModal'; import TransactionFiltersModal from './components/TransactionFiltersModal'; import RateChart from './RateChart'; import VolumeChart from './VolumeChart'; @@ -63,10 +66,8 @@ const calculateTimeRange = (timeframe: '1d' | '7d' | '30d'): TimeseriesOptions = }; function MarketContent() { - // 1. Get URL params and router first + // 1. Get URL params first const { marketid, chainId } = useParams(); - const router = useRouter(); - const searchParams = useSearchParams(); // 2. Network setup const network = Number(chainId as string) as SupportedNetworks; @@ -82,6 +83,8 @@ function MarketContent() { const [volumeView, setVolumeView] = useState<'USD' | 'Asset'>('Asset'); const [isRefreshing, setIsRefreshing] = useState(false); const [showTransactionFiltersModal, setShowTransactionFiltersModal] = useState(false); + const [showSupplierFiltersModal, setShowSupplierFiltersModal] = useState(false); + const [minSupplierShares, setMinSupplierShares] = useState('0'); // 4. Data fetching hooks - use unified time range const { @@ -148,6 +151,30 @@ function MarketContent() { } }, [minBorrowAmount, market]); + // Convert user-specified asset amount to shares for filtering + // Formula: effectiveMinShares = (minAssetAmount × totalSupplyShares) / totalSupplyAssets + const scaledMinSupplierShares = useMemo(() => { + if (!market || !minSupplierShares || minSupplierShares === '0' || minSupplierShares === '') { + return '0'; + } + try { + const minAssetAmount = parseUnits(minSupplierShares, market.loanAsset.decimals); + const totalSupplyAssets = BigInt(market.state.supplyAssets); + const totalSupplyShares = BigInt(market.state.supplyShares); + + // If no supply yet, return 0 + if (totalSupplyAssets === 0n) { + return '0'; + } + + // Convert asset amount to shares + const effectiveMinShares = (minAssetAmount * totalSupplyShares) / totalSupplyAssets; + return effectiveMinShares.toString(); + } catch { + return '0'; + } + }, [minSupplierShares, market]); + // Unified refetch function for both market and user position const handleRefreshAll = useCallback(async () => { setIsRefreshing(true); @@ -174,13 +201,6 @@ function MarketContent() { // No explicit refetch needed, change in selectedTimeRange (part of queryKey) triggers it }, []); - const handleBackToMarkets = useCallback(() => { - const currentParams = searchParams.toString(); - const marketsPath = '/markets'; - const targetPath = currentParams ? `${marketsPath}?${currentParams}` : marketsPath; - router.push(targetPath); - }, [router, searchParams]); - // 7. Early returns for loading/error states if (isMarketLoading) { return ( @@ -213,18 +233,20 @@ function MarketContent() { return ( <>
-
- {/* navigation buttons */} -
- +
+ {/* Market title and actions */} +
+
+

+ {market.loanAsset.symbol}/{market.collateralAsset.symbol} Market +

+ +
+ + + )} + + ); +} diff --git a/app/market/[chainId]/[marketid]/components/BorrowersTable.tsx b/app/market/[chainId]/[marketid]/components/BorrowersTable.tsx new file mode 100644 index 00000000..f536d884 --- /dev/null +++ b/app/market/[chainId]/[marketid]/components/BorrowersTable.tsx @@ -0,0 +1,195 @@ +import { useState, useMemo } from 'react'; +import { Table, TableHeader, TableColumn, TableBody, TableRow, TableCell, Tooltip } from '@heroui/react'; +import { FiFilter } from 'react-icons/fi'; +import type { Address } from 'viem'; +import { formatUnits } from 'viem'; +import { Button } from '@/components/ui/button'; +import { AccountIdentity } from '@/components/common/AccountIdentity'; +import { Spinner } from '@/components/common/Spinner'; +import { TablePagination } from '@/components/common/TablePagination'; +import { TokenIcon } from '@/components/TokenIcon'; +import { TooltipContent } from '@/components/TooltipContent'; +import { MONARCH_PRIMARY } from '@/constants/chartColors'; +import { useMarketBorrowers } from '@/hooks/useMarketBorrowers'; +import { formatSimple } from '@/utils/balance'; +import type { Market } from '@/utils/types'; + +type BorrowersTableProps = { + chainId: number; + market: Market; + minShares: string; + oraclePrice: bigint; + onOpenFiltersModal: () => void; +}; + +export function BorrowersTable({ chainId, market, minShares, oraclePrice, onOpenFiltersModal }: BorrowersTableProps) { + const [currentPage, setCurrentPage] = useState(1); + const pageSize = 10; + + const { data: paginatedData, isLoading, isFetching } = useMarketBorrowers(market?.uniqueKey, chainId, minShares, currentPage, pageSize); + + const borrowers = paginatedData?.items ?? []; + const totalCount = paginatedData?.totalCount ?? 0; + const totalPages = Math.ceil(totalCount / pageSize); + + const handlePageChange = (page: number) => { + setCurrentPage(page); + }; + + const hasActiveFilter = minShares !== '0'; + const tableKey = `borrowers-table-${currentPage}`; + + // Calculate LTV for each borrower + // LTV = borrowAssets / (collateral * oraclePrice) + const borrowersWithLTV = useMemo(() => { + if (!oraclePrice || oraclePrice === 0n) return []; + + return borrowers.map((borrower) => { + const borrowAssets = BigInt(borrower.borrowAssets); + const collateral = BigInt(borrower.collateral); + + // Calculate collateral value in loan asset terms + // oraclePrice is scaled by 10^36, need to adjust for token decimals + const collateralValueInLoan = (collateral * oraclePrice) / BigInt(10 ** 36); + + // Calculate LTV as a percentage + // LTV = (borrowAssets / collateralValue) * 100 + let ltv = 0; + if (collateralValueInLoan > 0n) { + ltv = Number((borrowAssets * 10000n) / collateralValueInLoan) / 100; + } + + return { + ...borrower, + ltv, + }; + }); + }, [borrowers, oraclePrice]); + + return ( +
+
+

Top Borrowers

+
+ } + /> + } + > + + +
+
+ +
+ {/* Loading overlay */} + {isFetching && ( +
+ +
+ )} + + + + ACCOUNT + BORROWED + COLLATERAL + LTV + % OF BORROW + + + {borrowersWithLTV.map((borrower) => { + const totalBorrow = BigInt(market.state.borrowAssets); + const borrowerAssets = BigInt(borrower.borrowAssets); + const percentOfBorrow = totalBorrow > 0n ? (Number(borrowerAssets) / Number(totalBorrow)) * 100 : 0; + const percentDisplay = percentOfBorrow < 0.01 && percentOfBorrow > 0 ? '<0.01%' : `${percentOfBorrow.toFixed(2)}%`; + + return ( + + + + + + {formatSimple(Number(formatUnits(BigInt(borrower.borrowAssets), market.loanAsset.decimals)))} + {market?.loanAsset?.symbol && ( + + + + )} + + + {formatSimple(Number(formatUnits(BigInt(borrower.collateral), market.collateralAsset.decimals)))} + {market?.collateralAsset?.symbol && ( + + + + )} + + {borrower.ltv.toFixed(2)}% + {percentDisplay} + + ); + })} + +
+
+ + {totalCount > 0 && ( + + )} +
+ ); +} diff --git a/app/market/[chainId]/[marketid]/content.tsx b/app/market/[chainId]/[marketid]/content.tsx index c33c9641..bca0e93d 100644 --- a/app/market/[chainId]/[marketid]/content.tsx +++ b/app/market/[chainId]/[marketid]/content.tsx @@ -29,7 +29,9 @@ import { getIRMTitle } from '@/utils/morpho'; import { getNetworkImg, getNetworkName, type SupportedNetworks } from '@/utils/networks'; import { getTruncatedAssetName } from '@/utils/oracle'; import type { TimeseriesOptions } from '@/utils/types'; +import { BorrowersTable } from './components/BorrowersTable'; import { BorrowsTable } from './components/BorrowsTable'; +import BorrowerFiltersModal from './components/BorrowerFiltersModal'; import { CampaignBadge } from './components/CampaignBadge'; import { LiquidationsTable } from './components/LiquidationsTable'; import { PositionStats } from './components/PositionStats'; @@ -85,6 +87,8 @@ function MarketContent() { const [showTransactionFiltersModal, setShowTransactionFiltersModal] = useState(false); const [showSupplierFiltersModal, setShowSupplierFiltersModal] = useState(false); const [minSupplierShares, setMinSupplierShares] = useState('0'); + const [showBorrowerFiltersModal, setShowBorrowerFiltersModal] = useState(false); + const [minBorrowerShares, setMinBorrowerShares] = useState('0'); // 4. Data fetching hooks - use unified time range const { @@ -151,7 +155,7 @@ function MarketContent() { } }, [minBorrowAmount, market]); - // Convert user-specified asset amount to shares for filtering + // Convert user-specified asset amount to shares for filtering suppliers // Formula: effectiveMinShares = (minAssetAmount × totalSupplyShares) / totalSupplyAssets const scaledMinSupplierShares = useMemo(() => { if (!market || !minSupplierShares || minSupplierShares === '0' || minSupplierShares === '') { @@ -175,6 +179,30 @@ function MarketContent() { } }, [minSupplierShares, market]); + // Convert user-specified asset amount to shares for filtering borrowers + // Formula: effectiveMinShares = (minAssetAmount × totalBorrowShares) / totalBorrowAssets + const scaledMinBorrowerShares = useMemo(() => { + if (!market || !minBorrowerShares || minBorrowerShares === '0' || minBorrowerShares === '') { + return '0'; + } + try { + const minAssetAmount = parseUnits(minBorrowerShares, market.loanAsset.decimals); + const totalBorrowAssets = BigInt(market.state.borrowAssets); + const totalBorrowShares = BigInt(market.state.borrowShares); + + // If no borrows yet, return 0 + if (totalBorrowAssets === 0n) { + return '0'; + } + + // Convert asset amount to shares + const effectiveMinShares = (minAssetAmount * totalBorrowShares) / totalBorrowAssets; + return effectiveMinShares.toString(); + } catch { + return '0'; + } + }, [minBorrowerShares, market]); + // Unified refetch function for both market and user position const handleRefreshAll = useCallback(async () => { setIsRefreshing(true); @@ -324,6 +352,16 @@ function MarketContent() { /> )} + {showBorrowerFiltersModal && ( + + )} +
@@ -511,6 +549,15 @@ function MarketContent() { minShares={scaledMinSupplierShares} onOpenFiltersModal={() => setShowSupplierFiltersModal(true)} /> +
+ setShowBorrowerFiltersModal(true)} + /> +
diff --git a/src/components/ui/tabs.tsx b/src/components/ui/tabs.tsx index 14630df9..787750dd 100644 --- a/src/components/ui/tabs.tsx +++ b/src/components/ui/tabs.tsx @@ -1,26 +1,26 @@ -import * as React from 'react'; -import * as TabsPrimitive from '@radix-ui/react-tabs'; +import { forwardRef, type ElementRef, type ComponentPropsWithoutRef } from 'react'; +import { Root, List, Trigger, Content } from '@radix-ui/react-tabs'; import { cn } from '@/lib/utils'; -const Tabs = TabsPrimitive.Root; +const Tabs = Root; -const TabsList = React.forwardRef, React.ComponentPropsWithoutRef>( +const TabsList = forwardRef, ComponentPropsWithoutRef>( ({ className, ...props }, ref) => ( - ), ); -TabsList.displayName = TabsPrimitive.List.displayName; +TabsList.displayName = List.displayName; -const TabsTrigger = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef +const TabsTrigger = forwardRef< + ElementRef, + ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( - )); -TabsTrigger.displayName = TabsPrimitive.Trigger.displayName; +TabsTrigger.displayName = Trigger.displayName; -const TabsContent = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef +const TabsContent = forwardRef< + ElementRef, + ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( - )); -TabsContent.displayName = TabsPrimitive.Content.displayName; +TabsContent.displayName = Content.displayName; export { Tabs, TabsList, TabsTrigger, TabsContent }; diff --git a/src/data-sources/morpho-api/market-borrowers.ts b/src/data-sources/morpho-api/market-borrowers.ts new file mode 100644 index 00000000..c4164d43 --- /dev/null +++ b/src/data-sources/morpho-api/market-borrowers.ts @@ -0,0 +1,82 @@ +import { marketBorrowersQuery } from '@/graphql/morpho-api-queries'; +import type { PaginatedMarketBorrowers } from '@/utils/types'; +import { morphoGraphqlFetcher } from './fetchers'; + +// Type specifically for the raw Morpho API response structure within this module +type MorphoAPIBorrowersResponse = { + data?: { + marketPositions?: { + items?: { + state: { + borrowAssets: string; + collateral: string; + }; + user: { + address: string; + }; + }[]; + pageInfo?: { + countTotal: number; + count: number; + limit: number; + skip: number; + }; + }; + }; +}; + +/** + * Fetches current market borrowers (positions) from the Morpho Blue API. + * Returns borrowers sorted by borrow shares (descending). + * Uses the shared Morpho API fetcher. + * @param marketId The unique key or ID of the market. + * @param chainId The chain ID where the market exists. + * @param minShares Minimum borrow share amount to filter borrowers (optional, defaults to '0'). + * @param first Number of items to fetch per page (optional, defaults to 10). + * @param skip Number of items to skip for pagination (optional, defaults to 0). + * @returns A promise resolving to paginated MarketBorrower objects. + */ +export const fetchMorphoMarketBorrowers = async ( + marketId: string, + chainId: number, + minShares = '0', + first = 10, + skip = 0, +): Promise => { + const variables = { + uniqueKey: marketId, + chainId, + minShares, + first, + skip, + }; + + try { + // Use the shared fetcher + const result = await morphoGraphqlFetcher(marketBorrowersQuery, variables); + + // Fetcher handles network and basic GraphQL errors + const items = result.data?.marketPositions?.items ?? []; + const totalCount = result.data?.marketPositions?.pageInfo?.countTotal ?? 0; + + // Map to unified type + const mappedItems = items.map((item) => ({ + userAddress: item.user.address, + borrowAssets: item.state.borrowAssets, + collateral: item.state.collateral, + })); + + return { + items: mappedItems, + totalCount, + }; + } catch (error) { + // Catch errors from the fetcher or during processing + console.error(`Error fetching or processing Morpho API market borrowers for ${marketId}:`, error); + // Re-throw the error to be handled by the calling hook + if (error instanceof Error) { + throw error; + } + throw new Error('An unknown error occurred while fetching Morpho API market borrowers'); + } +}; diff --git a/src/graphql/morpho-api-queries.ts b/src/graphql/morpho-api-queries.ts index e6a00325..b0b33444 100644 --- a/src/graphql/morpho-api-queries.ts +++ b/src/graphql/morpho-api-queries.ts @@ -519,6 +519,38 @@ export const marketSuppliersQuery = ` } `; +// Query for fetching market borrowers from Morpho API +export const marketBorrowersQuery = ` + query getMarketBorrowers($uniqueKey: String!, $chainId: Int!, $minShares: BigInt, $first: Int, $skip: Int) { + marketPositions (where: { + marketUniqueKey_in: [$uniqueKey], + borrowShares_gte: $minShares, + chainId_in: [$chainId] + }, + orderBy: BorrowShares, + orderDirection: Desc, + first: $first, + skip: $skip + ) { + items { + state { + borrowAssets + collateral + } + user { + address + } + } + pageInfo { + countTotal + count + limit + skip + } + } + } +`; + // Query for VaultV2 details from Morpho API export const vaultV2Query = ` query VaultV2Query($addresses: [String!], $chainId: Int!) { diff --git a/src/hooks/useMarketBorrowers.ts b/src/hooks/useMarketBorrowers.ts new file mode 100644 index 00000000..c68d6beb --- /dev/null +++ b/src/hooks/useMarketBorrowers.ts @@ -0,0 +1,105 @@ +import { useCallback, useEffect } from 'react'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { supportsMorphoApi } from '@/config/dataSources'; +import { fetchMorphoMarketBorrowers } from '@/data-sources/morpho-api/market-borrowers'; +import type { SupportedNetworks } from '@/utils/networks'; +import type { PaginatedMarketBorrowers } from '@/utils/types'; + +/** + * Hook to fetch current borrowers (positions) for a specific market, + * using the appropriate data source based on the network. + * Supports pagination with server-side pagination for Morpho API. + * Returns borrowers sorted by borrow shares (descending). + * + * @param marketId The ID of the market (e.g., 0x...). + * @param network The blockchain network. + * @param minShares Minimum borrow share amount to filter borrowers (optional, defaults to '0'). + * @param page Current page number (1-indexed, defaults to 1). + * @param pageSize Number of items per page (defaults to 10). + * @returns Paginated borrowers for the market. + */ +export const useMarketBorrowers = ( + marketId: string | undefined, + network: SupportedNetworks | undefined, + minShares = '0', + page = 1, + pageSize = 10, +) => { + const queryClient = useQueryClient(); + + const queryKey = ['marketBorrowers', marketId, network, minShares, page, pageSize]; + + const queryFn = useCallback( + async (targetPage: number): Promise => { + if (!marketId || !network) { + return null; + } + + const targetSkip = (targetPage - 1) * pageSize; + let result: PaginatedMarketBorrowers | null = null; + + // Try Morpho API first if supported + if (supportsMorphoApi(network)) { + try { + console.log(`Attempting to fetch borrowers via Morpho API for ${marketId} (page ${targetPage})`); + result = await fetchMorphoMarketBorrowers(marketId, network, minShares, pageSize, targetSkip); + } catch (morphoError) { + console.error('Failed to fetch borrowers via Morpho API:', morphoError); + throw morphoError; + } + } else { + // Subgraph support to be implemented later + console.warn(`Network ${network} does not support Morpho API for borrowers yet`); + return { items: [], totalCount: 0 }; + } + + return result; + }, + [marketId, network, minShares, pageSize], + ); + + const { data, isLoading, isFetching, error, refetch } = useQuery({ + queryKey: queryKey, + queryFn: async () => queryFn(page), + enabled: !!marketId && !!network, + staleTime: 1000 * 60 * 2, // 2 minutes - positions change more frequently than historical data + placeholderData: (previousData) => previousData ?? null, + retry: 1, + }); + + // Prefetch adjacent pages for faster navigation + useEffect(() => { + if (!marketId || !network || !data) return; + + const totalPages = data.totalCount > 0 ? Math.ceil(data.totalCount / pageSize) : 0; + + if (page > 1) { + const prevPageKey = ['marketBorrowers', marketId, network, minShares, page - 1, pageSize]; + void queryClient.prefetchQuery({ + queryKey: prevPageKey, + queryFn: async () => queryFn(page - 1), + staleTime: 1000 * 60 * 2, + }); + } + + if (page < totalPages) { + const nextPageKey = ['marketBorrowers', marketId, network, minShares, page + 1, pageSize]; + void queryClient.prefetchQuery({ + queryKey: nextPageKey, + queryFn: async () => queryFn(page + 1), + staleTime: 1000 * 60 * 2, + }); + } + }, [page, data, queryClient, queryFn, marketId, network, minShares, pageSize]); + + return { + data: data, + isLoading: isLoading, + isFetching: isFetching, + error: error, + refetch: refetch, + }; +}; + +// Keep export default for potential existing imports, but prefer named export +export default useMarketBorrowers; diff --git a/src/utils/types.ts b/src/utils/types.ts index 68d63d28..4b053b3a 100644 --- a/src/utils/types.ts +++ b/src/utils/types.ts @@ -440,3 +440,17 @@ export type PaginatedMarketSuppliers = { items: MarketSupplier[]; totalCount: number; }; + +// Type for Market Borrower (current position state, not historical transactions) +// Stores borrowAssets and collateral - shares can be calculated if needed +export type MarketBorrower = { + userAddress: string; + borrowAssets: string; + collateral: string; +}; + +// Paginated result type for market borrowers +export type PaginatedMarketBorrowers = { + items: MarketBorrower[]; + totalCount: number; +}; From 0326b06059549fa1d47d91c53428bbbb74898ba5 Mon Sep 17 00:00:00 2001 From: antoncoding Date: Wed, 10 Dec 2025 17:28:03 +0800 Subject: [PATCH 3/4] feat: borrowers table --- src/components/ui/tabs.tsx | 26 ++-- src/data-sources/subgraph/market-borrowers.ts | 145 ++++++++++++++++++ src/data-sources/subgraph/market-suppliers.ts | 106 +++++++++---- src/graphql/morpho-subgraph-queries.ts | 57 +++++++ src/hooks/useMarketBorrowers.ts | 17 +- 5 files changed, 297 insertions(+), 54 deletions(-) create mode 100644 src/data-sources/subgraph/market-borrowers.ts diff --git a/src/components/ui/tabs.tsx b/src/components/ui/tabs.tsx index 787750dd..b8775374 100644 --- a/src/components/ui/tabs.tsx +++ b/src/components/ui/tabs.tsx @@ -5,21 +5,16 @@ import { cn } from '@/lib/utils'; const Tabs = Root; -const TabsList = forwardRef, ComponentPropsWithoutRef>( - ({ className, ...props }, ref) => ( - - ), -); +const TabsList = forwardRef, ComponentPropsWithoutRef>(({ className, ...props }, ref) => ( + +)); TabsList.displayName = List.displayName; -const TabsTrigger = forwardRef< - ElementRef, - ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( +const TabsTrigger = forwardRef, ComponentPropsWithoutRef>(({ className, ...props }, ref) => ( , - ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( +const TabsContent = forwardRef, ComponentPropsWithoutRef>(({ className, ...props }, ref) => ( (); +const CACHE_TTL = 2 * 60 * 1000; // 2 minutes (same as React Query staleTime) + +function getCacheKey(marketId: string, network: SupportedNetworks, minShares: string): string { + return `${network}-${marketId}-${minShares}`; +} + +/** + * Fetches current market borrowers (positions) from the Subgraph. + * Uses adapter pattern: Always fetches top 1000 items and performs client-side pagination. + * Returns borrowers with their collateral balances. + * This approach keeps the interface identical to Morpho API while working within subgraph limits. + * + * @param marketId The ID of the market (unique key). + * @param network The blockchain network. + * @param minShares Minimum borrow share amount to filter borrowers (optional, defaults to '0'). + * @param pageSize Number of items to return per page (optional, defaults to 10). + * @param skip Number of items to skip for pagination (optional, defaults to 0). + * @returns A promise resolving to paginated MarketBorrower objects. + */ +export const fetchSubgraphMarketBorrowers = async ( + marketId: string, + network: SupportedNetworks, + minShares = '0', + pageSize = 10, + skip = 0, +): Promise => { + const subgraphUrl = getSubgraphUrl(network); + if (!subgraphUrl) { + console.warn(`No Subgraph URL configured for network: ${network}. Returning empty results.`); + return { items: [], totalCount: 0 }; + } + + const cacheKey = getCacheKey(marketId, network, minShares); + const now = Date.now(); + + // Check cache first + const cached = borrowersCache.get(cacheKey); + let allMappedItems: MarketBorrower[]; + + if (cached && now - cached.timestamp < CACHE_TTL) { + // Use cached data + allMappedItems = cached.data; + console.log(`Using cached borrowers data for ${marketId} (${allMappedItems.length} items)`); + } else { + // Fetch fresh data - always fetch top 1000 items (subgraph limit) + const variables = { + market: marketId, + minShares, + first: 1000, + skip: 0, + }; + + try { + const result = await subgraphGraphqlFetcher(subgraphUrl, marketBorrowersQuery, variables); + + const positions = result.data?.positions ?? []; + const market = result.data?.market; + + // Get market totals for share-to-asset conversion + const totalBorrow = BigInt(market?.totalBorrow ?? '0'); + const totalBorrowShares = BigInt(market?.totalBorrowShares ?? '0'); + + // Map all items to unified type + allMappedItems = positions.map((position) => { + // Convert borrow shares to borrow assets + // borrowAssets = (shares * totalBorrow) / totalBorrowShares + const shares = BigInt(position.shares); + let borrowAssets = '0'; + + if (totalBorrowShares > 0n) { + const assets = (shares * totalBorrow) / totalBorrowShares; + borrowAssets = assets.toString(); + } + + // Get collateral balance from nested positions (should be exactly 1) + const collateralBalance = position.account.positions[0]?.balance ?? '0'; + + return { + userAddress: position.account.id, + borrowAssets, + collateral: collateralBalance, + }; + }); + + // Update cache + borrowersCache.set(cacheKey, { + data: allMappedItems, + timestamp: now, + }); + + console.log(`Fetched and cached ${allMappedItems.length} borrowers for ${marketId}`); + } catch (error) { + console.error(`Error fetching or processing Subgraph market borrowers for ${marketId}:`, error); + if (error instanceof Error) { + throw error; + } + throw new Error('An unknown error occurred while fetching subgraph market borrowers'); + } + } + + // Perform client-side pagination by slicing the results + const start = skip; + const end = skip + pageSize; + const paginatedItems = allMappedItems.slice(start, end); + + // Return with actual total count (capped at 1000 by subgraph) + return { + items: paginatedItems, + totalCount: allMappedItems.length, + }; +}; diff --git a/src/data-sources/subgraph/market-suppliers.ts b/src/data-sources/subgraph/market-suppliers.ts index 0b7876b0..9558e13a 100644 --- a/src/data-sources/subgraph/market-suppliers.ts +++ b/src/data-sources/subgraph/market-suppliers.ts @@ -1,29 +1,45 @@ -import { marketPositionsQuery } from '@/graphql/morpho-subgraph-queries'; +import { marketSuppliersQuery } from '@/graphql/morpho-subgraph-queries'; import type { SupportedNetworks } from '@/utils/networks'; import { getSubgraphUrl } from '@/utils/subgraph-urls'; -import type { PaginatedMarketSuppliers } from '@/utils/types'; +import type { MarketSupplier, PaginatedMarketSuppliers } from '@/utils/types'; import { subgraphGraphqlFetcher } from './fetchers'; // Type for the Subgraph response -type SubgraphPositionItem = { +type SubgraphSupplierItem = { shares: string; account: { id: string; }; }; -type SubgraphPositionsResponse = { +type SubgraphSuppliersResponse = { data?: { - positions?: SubgraphPositionItem[]; + positions?: SubgraphSupplierItem[]; }; }; +// In-memory cache for subgraph data (avoids refetching 1000 items on page change) +type CacheEntry = { + data: MarketSupplier[]; + timestamp: number; +}; + +const suppliersCache = new Map(); +const CACHE_TTL = 2 * 60 * 1000; // 2 minutes (same as React Query staleTime) + +function getCacheKey(marketId: string, network: SupportedNetworks, minShares: string): string { + return `${network}-${marketId}-${minShares}`; +} + /** * Fetches current market suppliers (positions) from the Subgraph. + * Uses adapter pattern: Always fetches top 1000 items and performs client-side pagination. + * This approach keeps the interface identical to Morpho API while working within subgraph limits. + * * @param marketId The ID of the market (unique key). * @param network The blockchain network. * @param minShares Minimum share amount to filter suppliers (optional, defaults to '0'). - * @param first Number of items to return per page (optional, defaults to 8). + * @param pageSize Number of items to return per page (optional, defaults to 8). * @param skip Number of items to skip for pagination (optional, defaults to 0). * @returns A promise resolving to paginated MarketSupplier objects. */ @@ -31,7 +47,7 @@ export const fetchSubgraphMarketSuppliers = async ( marketId: string, network: SupportedNetworks, minShares = '0', - first = 8, + pageSize = 8, skip = 0, ): Promise => { const subgraphUrl = getSubgraphUrl(network); @@ -40,35 +56,61 @@ export const fetchSubgraphMarketSuppliers = async ( return { items: [], totalCount: 0 }; } - const variables = { - market: marketId, - minShares, - first, - skip, - }; + const cacheKey = getCacheKey(marketId, network, minShares); + const now = Date.now(); - try { - const result = await subgraphGraphqlFetcher(subgraphUrl, marketPositionsQuery, variables); + // Check cache first + const cached = suppliersCache.get(cacheKey); + let allMappedItems: MarketSupplier[]; - const positions = result.data?.positions ?? []; + if (cached && now - cached.timestamp < CACHE_TTL) { + // Use cached data + allMappedItems = cached.data; + console.log(`Using cached suppliers data for ${marketId} (${allMappedItems.length} items)`); + } else { + // Fetch fresh data - always fetch top 1000 items (subgraph limit) + const variables = { + market: marketId, + minShares, + first: 1000, + skip: 0, + }; - // Map to unified type - const mappedItems = positions.map((position) => ({ - userAddress: position.account.id, - supplyShares: position.shares, - })); + try { + const result = await subgraphGraphqlFetcher(subgraphUrl, marketSuppliersQuery, variables); - // Note: Subgraph doesn't provide total count, so we use the length of items - // For proper pagination, we'd need a separate count query - return { - items: mappedItems, - totalCount: mappedItems.length, - }; - } catch (error) { - console.error(`Error fetching or processing Subgraph market suppliers for ${marketId}:`, error); - if (error instanceof Error) { - throw error; + const positions = result.data?.positions ?? []; + + // Map all items to unified type + allMappedItems = positions.map((position) => ({ + userAddress: position.account.id, + supplyShares: position.shares, + })); + + // Update cache + suppliersCache.set(cacheKey, { + data: allMappedItems, + timestamp: now, + }); + + console.log(`Fetched and cached ${allMappedItems.length} suppliers for ${marketId}`); + } catch (error) { + console.error(`Error fetching or processing Subgraph market suppliers for ${marketId}:`, error); + if (error instanceof Error) { + throw error; + } + throw new Error('An unknown error occurred while fetching subgraph market suppliers'); } - throw new Error('An unknown error occurred while fetching subgraph market suppliers'); } + + // Perform client-side pagination by slicing the results + const start = skip; + const end = skip + pageSize; + const paginatedItems = allMappedItems.slice(start, end); + + // Return with actual total count (capped at 1000 by subgraph) + return { + items: paginatedItems, + totalCount: allMappedItems.length, + }; }; diff --git a/src/graphql/morpho-subgraph-queries.ts b/src/graphql/morpho-subgraph-queries.ts index 3e40f3f6..3000fb3e 100644 --- a/src/graphql/morpho-subgraph-queries.ts +++ b/src/graphql/morpho-subgraph-queries.ts @@ -422,3 +422,60 @@ export const marketPositionsQuery = ` } } `; + +// Query for market suppliers (positions with side: SUPPLIER, isCollateral: false) +export const marketSuppliersQuery = ` + query getMarketSuppliers($market: String!, $minShares: BigInt!, $first: Int!, $skip: Int!) { + positions( + where: { + shares_gt: $minShares + side: SUPPLIER + isCollateral: false + market: $market + } + orderBy: shares + orderDirection: desc + first: $first + skip: $skip + ) { + shares + account { + id + } + } + } +`; + +// Query for market borrowers (positions with side: BORROWER) including collateral and market totals for conversion +export const marketBorrowersQuery = ` + query getMarketBorrowers($market: String!, $minShares: BigInt!, $first: Int!, $skip: Int!) { + market(id: $market) { + totalBorrow + totalBorrowShares + } + positions( + where: { + shares_gt: $minShares + side: BORROWER + market: $market + } + orderBy: shares + orderDirection: desc + first: $first + skip: $skip + ) { + shares + account { + id + positions( + where: { + side: COLLATERAL + market: $market + } + ) { + balance + } + } + } + } +`; diff --git a/src/hooks/useMarketBorrowers.ts b/src/hooks/useMarketBorrowers.ts index c68d6beb..5efca4cb 100644 --- a/src/hooks/useMarketBorrowers.ts +++ b/src/hooks/useMarketBorrowers.ts @@ -2,6 +2,7 @@ import { useCallback, useEffect } from 'react'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { supportsMorphoApi } from '@/config/dataSources'; import { fetchMorphoMarketBorrowers } from '@/data-sources/morpho-api/market-borrowers'; +import { fetchSubgraphMarketBorrowers } from '@/data-sources/subgraph/market-borrowers'; import type { SupportedNetworks } from '@/utils/networks'; import type { PaginatedMarketBorrowers } from '@/utils/types'; @@ -45,12 +46,18 @@ export const useMarketBorrowers = ( result = await fetchMorphoMarketBorrowers(marketId, network, minShares, pageSize, targetSkip); } catch (morphoError) { console.error('Failed to fetch borrowers via Morpho API:', morphoError); - throw morphoError; } - } else { - // Subgraph support to be implemented later - console.warn(`Network ${network} does not support Morpho API for borrowers yet`); - return { items: [], totalCount: 0 }; + } + + // Fallback to Subgraph if Morpho API failed or not supported + if (!result) { + try { + console.log(`Attempting to fetch borrowers via Subgraph for ${marketId} (page ${targetPage})`); + result = await fetchSubgraphMarketBorrowers(marketId, network, minShares, pageSize, targetSkip); + } catch (subgraphError) { + console.error('Failed to fetch borrowers via Subgraph:', subgraphError); + throw subgraphError; + } } return result; From b3ab755adc6c29b1a8495628772e134d3efaa8dc Mon Sep 17 00:00:00 2001 From: antoncoding Date: Wed, 10 Dec 2025 17:43:42 +0800 Subject: [PATCH 4/4] chore: review fixes --- .../components/BorrowerFiltersModal.tsx | 22 ++----------- .../components/SupplierFiltersModal.tsx | 22 ++----------- .../components/shared-filter-utils.tsx | 31 +++++++++++++++++++ src/hooks/useMarketBorrowers.ts | 2 +- src/hooks/useMarketSuppliers.ts | 2 +- 5 files changed, 37 insertions(+), 42 deletions(-) create mode 100644 app/market/[chainId]/[marketid]/components/shared-filter-utils.tsx diff --git a/app/market/[chainId]/[marketid]/components/BorrowerFiltersModal.tsx b/app/market/[chainId]/[marketid]/components/BorrowerFiltersModal.tsx index edd5a060..6ff71a31 100644 --- a/app/market/[chainId]/[marketid]/components/BorrowerFiltersModal.tsx +++ b/app/market/[chainId]/[marketid]/components/BorrowerFiltersModal.tsx @@ -1,8 +1,8 @@ -import type React from 'react'; import { Input } from '@heroui/react'; import { FiSliders } from 'react-icons/fi'; import { Button } from '@/components/ui/button'; import { Modal, ModalHeader, ModalBody, ModalFooter } from '@/components/common/Modal'; +import { SettingItem, createNumericInputHandler } from './shared-filter-utils'; type BorrowerFiltersModalProps = { isOpen: boolean; @@ -12,18 +12,6 @@ type BorrowerFiltersModalProps = { loanAssetSymbol: string; }; -function SettingItem({ title, description, children }: { title: string; description: string; children: React.ReactNode }) { - return ( -
-
-

{title}

-

{description}

-
-
{children}
-
- ); -} - export default function BorrowerFiltersModal({ isOpen, onOpenChange, @@ -31,13 +19,7 @@ export default function BorrowerFiltersModal({ onMinSharesChange, loanAssetSymbol, }: BorrowerFiltersModalProps) { - const handleSharesChange = (e: React.ChangeEvent) => { - const { value } = e.target; - // Allow decimals and empty string - if (value === '' || /^\d*\.?\d*$/.test(value)) { - onMinSharesChange(value); - } - }; + const handleSharesChange = createNumericInputHandler(onMinSharesChange); return ( -
-

{title}

-

{description}

-
-
{children}
-
- ); -} - export default function SupplierFiltersModal({ isOpen, onOpenChange, @@ -31,13 +19,7 @@ export default function SupplierFiltersModal({ onMinSharesChange, loanAssetSymbol, }: SupplierFiltersModalProps) { - const handleSharesChange = (e: React.ChangeEvent) => { - const { value } = e.target; - // Allow decimals and empty string - if (value === '' || /^\d*\.?\d*$/.test(value)) { - onMinSharesChange(value); - } - }; + const handleSharesChange = createNumericInputHandler(onMinSharesChange); return ( +
+

{title}

+

{description}

+
+
{children}
+
+ ); +} + +/** + * Creates a handler for numeric input fields that only allows valid decimal numbers + * @param onChange Callback to invoke with the validated value + * @returns Event handler for input change events + */ +export function createNumericInputHandler(onChange: (value: string) => void) { + return (e: ChangeEvent) => { + const { value } = e.target; + // Allow empty string or valid decimal numbers + if (value === '' || /^\d*\.?\d*$/.test(value)) { + onChange(value); + } + }; +} diff --git a/src/hooks/useMarketBorrowers.ts b/src/hooks/useMarketBorrowers.ts index 5efca4cb..674fe790 100644 --- a/src/hooks/useMarketBorrowers.ts +++ b/src/hooks/useMarketBorrowers.ts @@ -43,7 +43,7 @@ export const useMarketBorrowers = ( if (supportsMorphoApi(network)) { try { console.log(`Attempting to fetch borrowers via Morpho API for ${marketId} (page ${targetPage})`); - result = await fetchMorphoMarketBorrowers(marketId, network, minShares, pageSize, targetSkip); + result = await fetchMorphoMarketBorrowers(marketId, Number(network), minShares, pageSize, targetSkip); } catch (morphoError) { console.error('Failed to fetch borrowers via Morpho API:', morphoError); } diff --git a/src/hooks/useMarketSuppliers.ts b/src/hooks/useMarketSuppliers.ts index efdcea85..1115aefd 100644 --- a/src/hooks/useMarketSuppliers.ts +++ b/src/hooks/useMarketSuppliers.ts @@ -43,7 +43,7 @@ export const useMarketSuppliers = ( if (supportsMorphoApi(network)) { try { console.log(`Attempting to fetch suppliers via Morpho API for ${marketId} (page ${targetPage})`); - result = await fetchMorphoMarketSuppliers(marketId, network, minShares, pageSize, targetSkip); + result = await fetchMorphoMarketSuppliers(marketId, Number(network), minShares, pageSize, targetSkip); } catch (morphoError) { console.error('Failed to fetch suppliers via Morpho API:', morphoError); }