diff --git a/app/market/[chainId]/[marketid]/components/LiquidationsTable.tsx b/app/market/[chainId]/[marketid]/components/LiquidationsTable.tsx new file mode 100644 index 00000000..ef7dbf22 --- /dev/null +++ b/app/market/[chainId]/[marketid]/components/LiquidationsTable.tsx @@ -0,0 +1,168 @@ +import { useMemo, useState } from 'react'; +import { Link, Pagination } from '@nextui-org/react'; +import { Table, TableHeader, TableBody, TableColumn, TableRow, TableCell } from '@nextui-org/table'; +import { ExternalLinkIcon } from '@radix-ui/react-icons'; +import moment from 'moment'; +import Image from 'next/image'; +import { Address, formatUnits } from 'viem'; +import AccountWithAvatar from '@/components/Account/AccountWithAvatar'; +import { MarketLiquidationTransaction } from '@/hooks/useMarketLiquidations'; +import { getExplorerTxURL, getExplorerURL } from '@/utils/external'; +import { findToken } from '@/utils/tokens'; +import { Market } from '@/utils/types'; + +// Helper functions to format data +const formatAddress = (address: string) => { + return `${address.substring(0, 6)}...${address.substring(address.length - 4)}`; +}; + +type LiquidationsTableProps = { + chainId: number; + liquidations: MarketLiquidationTransaction[]; + loading: boolean; + error: string | null; + market: Market; +}; + +export function LiquidationsTable({ + chainId, + liquidations, + loading, + error, + market, +}: LiquidationsTableProps) { + const [currentPage, setCurrentPage] = useState(1); + const pageSize = 8; + const totalPages = Math.ceil(liquidations.length / pageSize); + + const handlePageChange = (page: number) => { + setCurrentPage(page); + }; + + const paginatedLiquidations = useMemo(() => { + const sliced = liquidations.slice((currentPage - 1) * pageSize, currentPage * pageSize); + return sliced; + }, [currentPage, liquidations, pageSize]); + + const tableKey = `liquidations-table-${currentPage}`; + + const collateralToken = useMemo(() => { + if (!market) return null; + return findToken(market.collateralAsset.address, chainId); + }, [market, chainId]); + + const loanToken = useMemo(() => { + if (!market) return null; + return findToken(market.loanAsset.address, chainId); + }, [market, chainId]); + + if (error) { + return

Error loading liquidations: {error}

; + } + + return ( +
+

Liquidations

+ + 1 ? ( +
+ +
+ ) : null + } + > + + Liquidator + Repaid ({market?.loanAsset?.symbol ?? 'USDC'}) + + Seized{' '} + {market?.collateralAsset?.symbol && ( + {market.collateralAsset.symbol} + )} + + Time + Transaction + + + {paginatedLiquidations.map((liquidation) => ( + + + + + + + + + {formatUnits(BigInt(liquidation.data.repaidAssets), loanToken?.decimals ?? 6)} + {market?.loanAsset?.symbol && ( + + {loanToken?.img && ( + + )} + + )} + + + {formatUnits( + BigInt(liquidation.data.seizedAssets), + collateralToken?.decimals ?? 18, + )} + {market?.collateralAsset?.symbol && ( + + {collateralToken?.img && ( + + )} + + )} + + {moment.unix(liquidation.timestamp).fromNow()} + + + {formatAddress(liquidation.hash)} + + + + + ))} + +
+
+ ); +} diff --git a/app/market/[chainId]/[marketid]/content.tsx b/app/market/[chainId]/[marketid]/content.tsx index 850d4fa2..c7607e20 100644 --- a/app/market/[chainId]/[marketid]/content.tsx +++ b/app/market/[chainId]/[marketid]/content.tsx @@ -14,12 +14,14 @@ import Header from '@/components/layout/header/Header'; import OracleVendorBadge from '@/components/OracleVendorBadge'; import { SupplyModal } from '@/components/supplyModal'; import { useMarket, useMarketHistoricalData } from '@/hooks/useMarket'; +import useMarketLiquidations from '@/hooks/useMarketLiquidations'; import MORPHO_LOGO from '@/imgs/tokens/morpho.svg'; import { getExplorerURL, getMarketURL } from '@/utils/external'; import { getIRMTitle } from '@/utils/morpho'; import { getNetworkImg, getNetworkName, SupportedNetworks } from '@/utils/networks'; import { findToken } from '@/utils/tokens'; import { TimeseriesOptions } from '@/utils/types'; +import { LiquidationsTable } from './components/LiquidationsTable'; import RateChart from './RateChart'; import VolumeChart from './VolumeChart'; @@ -55,12 +57,19 @@ function MarketContent() { isLoading: isMarketLoading, error: marketError, } = useMarket(marketid as string, network); + const { data: historicalData, isLoading: isHistoricalLoading, refetch: refetchHistoricalData, } = useMarketHistoricalData(marketid as string, network, rateTimeRange, volumeTimeRange); + const { + liquidations, + loading: liquidationsLoading, + error: liquidationsError, + } = useMarketLiquidations(market?.uniqueKey); + const setTimeRangeAndRefetch = useCallback( (days: number, type: 'rate' | 'volume') => { const endTimestamp = Math.floor(Date.now() / 1000); @@ -321,6 +330,14 @@ function MarketContent() { setApyTimeframe={setApyTimeframe} setTimeRangeAndRefetch={setTimeRangeAndRefetch} /> + + ); diff --git a/docs/Styling.md b/docs/Styling.md index 97835694..97979624 100644 --- a/docs/Styling.md +++ b/docs/Styling.md @@ -7,7 +7,6 @@ Use these shared components instead of raw HTML elements: - `Button`: Import from `@/components/common/Button` for all clickable actions - `Modal`: For all modal dialogs - `Card`: For contained content sections -- `Typography`: For text elements ## Component Guidelines diff --git a/src/components/Account/AccountWithAvatar.tsx b/src/components/Account/AccountWithAvatar.tsx new file mode 100644 index 00000000..e14e5b71 --- /dev/null +++ b/src/components/Account/AccountWithAvatar.tsx @@ -0,0 +1,20 @@ +import { Address } from 'viem'; +import { Avatar } from '@/components/Avatar/Avatar'; +import { getSlicedAddress } from '@/utils/address'; + +type AccountWithAvatarProps = { + address: Address; +}; + +function AccountWithSmallAvatar({ address }: AccountWithAvatarProps) { + return ( +
+ + + {getSlicedAddress(address as `0x${string}`)} + +
+ ); +} + +export default AccountWithSmallAvatar; diff --git a/src/components/Account/AccountWithENS.tsx b/src/components/Account/AccountWithENS.tsx new file mode 100644 index 00000000..77ca856e --- /dev/null +++ b/src/components/Account/AccountWithENS.tsx @@ -0,0 +1,26 @@ +import { Address } from 'viem'; +import { Avatar } from '@/components/Avatar/Avatar'; +import { getSlicedAddress } from '@/utils/address'; +import { Name } from '../common/Name'; + +type AccountWithENSProps = { + address: Address; +}; + +function AccountWithENS({ address }: AccountWithENSProps) { + return ( +
+ +
+
+ +
+ + {getSlicedAddress(address)} + +
+
+ ); +} + +export default AccountWithENS; diff --git a/src/components/layout/header/AccountDropdown.tsx b/src/components/layout/header/AccountDropdown.tsx index df0c0a34..c9b5bb54 100644 --- a/src/components/layout/header/AccountDropdown.tsx +++ b/src/components/layout/header/AccountDropdown.tsx @@ -5,10 +5,9 @@ import { Dropdown, DropdownTrigger, DropdownMenu, DropdownItem } from '@nextui-o import { ExitIcon, ExternalLinkIcon, CopyIcon } from '@radix-ui/react-icons'; import { clsx } from 'clsx'; import { useAccount, useDisconnect } from 'wagmi'; +import AccountWithENS from '@/components/Account/AccountWithENS'; import { Avatar } from '@/components/Avatar/Avatar'; -import { Name } from '@/components/common/Name'; import { useStyledToast } from '@/hooks/useStyledToast'; -import { getSlicedAddress } from '@/utils/address'; import { getExplorerURL } from '@/utils/external'; export function AccountDropdown() { @@ -56,17 +55,7 @@ export function AccountDropdown() { >
-
- -
-
- -
- - {getSlicedAddress(address)} - -
-
+
diff --git a/src/graphql/queries.ts b/src/graphql/queries.ts index 001a62d9..dbfc707c 100644 --- a/src/graphql/queries.ts +++ b/src/graphql/queries.ts @@ -271,3 +271,35 @@ export const userTransactionsQuery = ` } } `; + +export const marketLiquidationsQuery = ` + query getMarketLiquidations($uniqueKey: String!, $first: Int, $skip: Int) { + transactions (where: { + marketUniqueKey_in: [$uniqueKey], + type_in: [MarketLiquidation] + }, + first: $first, + skip: $skip + ) { + items { + hash + timestamp + type + data { + ... on MarketLiquidationTransactionData { + repaidAssets + seizedAssets + liquidator + } + } + } + pageInfo { + countTotal + count + limit + skip + } + } + } + +`; diff --git a/src/hooks/useMarketLiquidations.ts b/src/hooks/useMarketLiquidations.ts new file mode 100644 index 00000000..e92a0100 --- /dev/null +++ b/src/hooks/useMarketLiquidations.ts @@ -0,0 +1,83 @@ +import { useState, useEffect, useCallback } from 'react'; +import { marketLiquidationsQuery } from '@/graphql/queries'; +import { URLS } from '@/utils/urls'; + +export type MarketLiquidationTransaction = { + hash: string; + timestamp: number; + type: string; + data: { + repaidAssets: string; + seizedAssets: string; + liquidator: string; + }; +}; + +/** + * Hook to fetch all liquidations for a specific market + * @param marketUniqueKey The unique key of the market + * @returns List of all liquidation transactions for the market + */ +const useMarketLiquidations = (marketUniqueKey: string | undefined) => { + const [liquidations, setLiquidations] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const fetchLiquidations = useCallback(async () => { + if (!marketUniqueKey) { + setLiquidations([]); + return; + } + + setLoading(true); + setError(null); + + try { + const variables = { + uniqueKey: marketUniqueKey, + }; + + const response = await fetch(`${URLS.MORPHO_BLUE_API}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + query: marketLiquidationsQuery, + variables, + }), + }); + + if (!response.ok) { + throw new Error('Failed to fetch market liquidations'); + } + + const result = (await response.json()) as { + data: { transactions: { items: MarketLiquidationTransaction[] } }; + }; + + if (result.data?.transactions?.items) { + setLiquidations(result.data.transactions.items); + } else { + setLiquidations([]); + } + } catch (err) { + console.error('Error fetching market liquidations:', err); + setError(err instanceof Error ? err.message : 'Unknown error'); + } finally { + setLoading(false); + } + }, [marketUniqueKey]); + + useEffect(() => { + void fetchLiquidations(); + }, [fetchLiquidations]); + + return { + liquidations, + loading, + error, + }; +}; + +export default useMarketLiquidations;