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/BorrowerFiltersModal.tsx b/app/market/[chainId]/[marketid]/components/BorrowerFiltersModal.tsx new file mode 100644 index 00000000..6ff71a31 --- /dev/null +++ b/app/market/[chainId]/[marketid]/components/BorrowerFiltersModal.tsx @@ -0,0 +1,74 @@ +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; + onOpenChange: (isOpen: boolean) => void; + minShares: string; + onMinSharesChange: (value: string) => void; + loanAssetSymbol: string; +}; + +export default function BorrowerFiltersModal({ + isOpen, + onOpenChange, + minShares, + onMinSharesChange, + loanAssetSymbol, +}: BorrowerFiltersModalProps) { + const handleSharesChange = createNumericInputHandler(onMinSharesChange); + + return ( + + {(onClose) => ( + <> + } + onClose={onClose} + /> + +
+

Minimum Amount

+

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

+ + + +
+
+ + + + + )} +
+ ); +} 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]/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..be6d712c --- /dev/null +++ b/app/market/[chainId]/[marketid]/components/SupplierFiltersModal.tsx @@ -0,0 +1,74 @@ +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 SupplierFiltersModalProps = { + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; + minShares: string; + onMinSharesChange: (value: string) => void; + loanAssetSymbol: string; +}; + +export default function SupplierFiltersModal({ + isOpen, + onOpenChange, + minShares, + onMinSharesChange, + loanAssetSymbol, +}: SupplierFiltersModalProps) { + const handleSharesChange = createNumericInputHandler(onMinSharesChange); + + 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]/components/shared-filter-utils.tsx b/app/market/[chainId]/[marketid]/components/shared-filter-utils.tsx new file mode 100644 index 00000000..393071ed --- /dev/null +++ b/app/market/[chainId]/[marketid]/components/shared-filter-utils.tsx @@ -0,0 +1,31 @@ +import type { ChangeEvent, ReactNode } from 'react'; + +/** + * Shared SettingItem component for filter modals + */ +export function SettingItem({ title, description, children }: { title: string; description: string; children: ReactNode }) { + 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/app/market/[chainId]/[marketid]/content.tsx b/app/market/[chainId]/[marketid]/content.tsx index 8f7505a7..bca0e93d 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'; @@ -28,11 +29,15 @@ 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'; 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 +68,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 +85,10 @@ 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'); + const [showBorrowerFiltersModal, setShowBorrowerFiltersModal] = useState(false); + const [minBorrowerShares, setMinBorrowerShares] = useState('0'); // 4. Data fetching hooks - use unified time range const { @@ -148,6 +155,54 @@ function MarketContent() { } }, [minBorrowAmount, market]); + // Convert user-specified asset amount to shares for filtering suppliers + // 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]); + + // 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); @@ -174,13 +229,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 +261,20 @@ function MarketContent() { return ( <>
-
- {/* navigation buttons */} -
- +
+ {/* Market title and actions */} +
+
+

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

+ +