From bbab8cd237c2163b66c7810e725d71487b882c9e Mon Sep 17 00:00:00 2001 From: antoncoding Date: Thu, 6 Mar 2025 23:57:44 +0800 Subject: [PATCH 1/3] feat: add liquidations table to market page --- .../components/LiquidationsTable.tsx | 170 ++++++++++++++++++ app/market/[chainId]/[marketid]/content.tsx | 17 ++ docs/Styling.md | 1 - src/components/Account/AccountWithAvatar.tsx | 20 +++ src/components/Account/AccountWithENS.tsx | 26 +++ .../layout/header/AccountDropdown.tsx | 13 +- src/graphql/queries.ts | 32 ++++ src/hooks/useMarketLiquidations.ts | 81 +++++++++ 8 files changed, 348 insertions(+), 12 deletions(-) create mode 100644 app/market/[chainId]/[marketid]/components/LiquidationsTable.tsx create mode 100644 src/components/Account/AccountWithAvatar.tsx create mode 100644 src/components/Account/AccountWithENS.tsx create mode 100644 src/hooks/useMarketLiquidations.ts diff --git a/app/market/[chainId]/[marketid]/components/LiquidationsTable.tsx b/app/market/[chainId]/[marketid]/components/LiquidationsTable.tsx new file mode 100644 index 00000000..62b0a6ad --- /dev/null +++ b/app/market/[chainId]/[marketid]/components/LiquidationsTable.tsx @@ -0,0 +1,170 @@ +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'; + +// 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: any; // Using any for now, would be better to type this properly +}; + +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), + market?.loanAsset?.decimals || 6, + )} + {market?.loanAsset?.symbol && ( + + {loanToken?.img && ( + + )} + + )} + + + {formatUnits( + BigInt(liquidation.data.seizedAssets), + market?.collateralAsset?.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..a4eb47bf --- /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..38f272b2 100644 --- a/src/components/layout/header/AccountDropdown.tsx +++ b/src/components/layout/header/AccountDropdown.tsx @@ -5,6 +5,7 @@ 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'; @@ -56,17 +57,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..1f95d830 --- /dev/null +++ b/src/hooks/useMarketLiquidations.ts @@ -0,0 +1,81 @@ +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(); + + 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(() => { + fetchLiquidations(); + }, [fetchLiquidations]); + + return { + liquidations, + loading, + error, + }; +}; + +export default useMarketLiquidations; From 73e6084189130791c3b22a63546ae8b087e2bdcf Mon Sep 17 00:00:00 2001 From: antoncoding Date: Fri, 7 Mar 2025 00:07:04 +0800 Subject: [PATCH 2/3] chore: lint --- .../[marketid]/components/LiquidationsTable.tsx | 11 ++++++----- src/components/Account/AccountWithAvatar.tsx | 2 +- src/components/layout/header/AccountDropdown.tsx | 2 -- src/hooks/useMarketLiquidations.ts | 6 ++++-- 4 files changed, 11 insertions(+), 10 deletions(-) diff --git a/app/market/[chainId]/[marketid]/components/LiquidationsTable.tsx b/app/market/[chainId]/[marketid]/components/LiquidationsTable.tsx index 62b0a6ad..5e907c84 100644 --- a/app/market/[chainId]/[marketid]/components/LiquidationsTable.tsx +++ b/app/market/[chainId]/[marketid]/components/LiquidationsTable.tsx @@ -9,6 +9,7 @@ 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) => { @@ -20,7 +21,7 @@ type LiquidationsTableProps = { liquidations: MarketLiquidationTransaction[]; loading: boolean; error: string | null; - market: any; // Using any for now, would be better to type this properly + market: Market; }; export function LiquidationsTable({ @@ -114,14 +115,14 @@ export function LiquidationsTable({ {formatUnits( BigInt(liquidation.data.repaidAssets), - market?.loanAsset?.decimals || 6, + loanToken?.decimals || 6, )} {market?.loanAsset?.symbol && ( {loanToken?.img && ( {market.loanAsset.symbol} {formatUnits( BigInt(liquidation.data.seizedAssets), - market?.collateralAsset?.decimals || 18, + collateralToken?.decimals || 18, )} {market?.collateralAsset?.symbol && ( {collateralToken?.img && ( {market.collateralAsset.symbol} { throw new Error('Failed to fetch market liquidations'); } - const result = await response.json(); + const result = (await response.json()) as { + data: { transactions: { items: MarketLiquidationTransaction[] } }; + }; if (result.data?.transactions?.items) { setLiquidations(result.data.transactions.items); @@ -68,7 +70,7 @@ const useMarketLiquidations = (marketUniqueKey: string | undefined) => { }, [marketUniqueKey]); useEffect(() => { - fetchLiquidations(); + void fetchLiquidations(); }, [fetchLiquidations]); return { From 3b3ccfa22f90c4398fcdddec39df2df5024404af Mon Sep 17 00:00:00 2001 From: antoncoding Date: Fri, 7 Mar 2025 00:11:22 +0800 Subject: [PATCH 3/3] chore: lint --- .../[marketid]/components/LiquidationsTable.tsx | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/app/market/[chainId]/[marketid]/components/LiquidationsTable.tsx b/app/market/[chainId]/[marketid]/components/LiquidationsTable.tsx index 5e907c84..ef7dbf22 100644 --- a/app/market/[chainId]/[marketid]/components/LiquidationsTable.tsx +++ b/app/market/[chainId]/[marketid]/components/LiquidationsTable.tsx @@ -84,7 +84,7 @@ export function LiquidationsTable({ > Liquidator - Repaid ({market?.loanAsset?.symbol || 'USDC'}) + Repaid ({market?.loanAsset?.symbol ?? 'USDC'}) Seized{' '} {market?.collateralAsset?.symbol && ( @@ -113,10 +113,7 @@ export function LiquidationsTable({ - {formatUnits( - BigInt(liquidation.data.repaidAssets), - loanToken?.decimals || 6, - )} + {formatUnits(BigInt(liquidation.data.repaidAssets), loanToken?.decimals ?? 6)} {market?.loanAsset?.symbol && ( {loanToken?.img && ( @@ -134,14 +131,14 @@ export function LiquidationsTable({ {formatUnits( BigInt(liquidation.data.seizedAssets), - collateralToken?.decimals || 18, + collateralToken?.decimals ?? 18, )} {market?.collateralAsset?.symbol && ( {collateralToken?.img && (