diff --git a/src/features/market-detail/components/suppliers-table.tsx b/src/features/market-detail/components/suppliers-table.tsx index fd193765..a183f1d1 100644 --- a/src/features/market-detail/components/suppliers-table.tsx +++ b/src/features/market-detail/components/suppliers-table.tsx @@ -12,6 +12,7 @@ import { TokenIcon } from '@/components/shared/token-icon'; import { TooltipContent } from '@/components/shared/tooltip-content'; import { MONARCH_PRIMARY } from '@/constants/chartColors'; import { useMarketSuppliers } from '@/hooks/useMarketSuppliers'; +import { useSupplierPositionChanges, type SupplierPositionChange } from '@/hooks/useSupplierPositionChanges'; import { formatSimple } from '@/utils/balance'; import type { Market } from '@/utils/types'; @@ -22,12 +23,90 @@ type SuppliersTableProps = { onOpenFiltersModal: () => void; }; +type PositionChangeIndicatorProps = { + change: SupplierPositionChange | undefined; + decimals: number; + currentAssets: bigint; + symbol: string; +}; + +/** + * Displays a 7-day position change indicator with arrow and percentage + */ +function PositionChangeIndicator({ change, decimals, currentAssets, symbol }: PositionChangeIndicatorProps) { + if (!change || change.transactionCount === 0) { + return ; + } + + const netChange = change.netChange; + const isPositive = netChange > 0n; + const isNegative = netChange < 0n; + const isNeutral = netChange === 0n; + + // Calculate percentage change relative to current position + // If current position is 0, we can't calculate percentage + let percentChange = 0; + if (currentAssets > 0n && netChange !== 0n) { + // Previous assets = current - net change + const previousAssets = currentAssets - netChange; + if (previousAssets > 0n) { + percentChange = (Number(netChange) / Number(previousAssets)) * 100; + } else if (isPositive) { + // New position entirely from 7d activity + percentChange = 100; + } + } + + const absChange = netChange < 0n ? -netChange : netChange; + const formattedChange = formatSimple(Number(formatUnits(absChange, decimals))); + const formattedPercent = Math.abs(percentChange) < 0.01 && percentChange !== 0 ? '<0.01' : Math.abs(percentChange).toFixed(2); + + // Color and arrow based on direction + let colorClass = 'text-secondary'; + let arrow = '−'; + if (isPositive) { + colorClass = 'text-green-500'; + arrow = '↑'; + } else if (isNegative) { + colorClass = 'text-red-500'; + arrow = '↓'; + } + + const tooltipContent = ( + 1 ? 's' : ''}`} + /> + ); + + return ( + + + {arrow} + {!isNeutral && {formattedPercent}%} + + + ); +} + 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); + // Fetch 7-day position changes + const { data: positionChanges, isLoading: isLoadingChanges } = useSupplierPositionChanges( + market?.uniqueKey, + market?.loanAsset?.address, + chainId, + ); + const suppliers = paginatedData?.items ?? []; const totalCount = paginatedData?.totalCount ?? 0; const totalPages = Math.ceil(totalCount / pageSize); @@ -107,6 +186,7 @@ export function SuppliersTable({ chainId, market, minShares, onOpenFiltersModal ACCOUNT SUPPLIED + 7D % OF SUPPLY @@ -114,7 +194,7 @@ export function SuppliersTable({ chainId, market, minShares, onOpenFiltersModal {suppliersWithAssets.length === 0 && !isLoading ? ( No suppliers found for this market @@ -127,6 +207,9 @@ export function SuppliersTable({ chainId, market, minShares, onOpenFiltersModal const percentOfSupply = totalSupply > 0n ? (Number(supplierAssets) / Number(totalSupply)) * 100 : 0; const percentDisplay = percentOfSupply < 0.01 && percentOfSupply > 0 ? '<0.01%' : `${percentOfSupply.toFixed(2)}%`; + // Get position change for this supplier + const positionChange = positionChanges.get(supplier.userAddress.toLowerCase()); + return ( @@ -151,6 +234,18 @@ export function SuppliersTable({ chainId, market, minShares, onOpenFiltersModal )} + + {isLoadingChanges ? ( + + ) : ( + + )} + {percentDisplay} ); diff --git a/src/features/markets/components/table/market-table-body.tsx b/src/features/markets/components/table/market-table-body.tsx index 8d79e8fa..7cd86017 100644 --- a/src/features/markets/components/table/market-table-body.tsx +++ b/src/features/markets/components/table/market-table-body.tsx @@ -241,9 +241,7 @@ export function MarketTableBody({ currentEntries, expandedRowId, setExpandedRowI className="z-50 text-center" style={{ minWidth: '85px', paddingLeft: 3, paddingRight: 3 }} > -

- {item.state.dailySupplyApy != null ? : '—'} -

+

{item.state.dailySupplyApy != null ? : '—'}

)} {columnVisibility.dailyBorrowAPY && ( @@ -252,9 +250,7 @@ export function MarketTableBody({ currentEntries, expandedRowId, setExpandedRowI className="z-50 text-center" style={{ minWidth: '85px', paddingLeft: 3, paddingRight: 3 }} > -

- {item.state.dailyBorrowApy != null ? : '—'} -

+

{item.state.dailyBorrowApy != null ? : '—'}

)} {columnVisibility.weeklySupplyAPY && ( diff --git a/src/hooks/useSupplierPositionChanges.ts b/src/hooks/useSupplierPositionChanges.ts new file mode 100644 index 00000000..498fc4e4 --- /dev/null +++ b/src/hooks/useSupplierPositionChanges.ts @@ -0,0 +1,150 @@ +import { useQuery } from '@tanstack/react-query'; +import { supportsMorphoApi } from '@/config/dataSources'; +import { fetchMorphoMarketSupplies } from '@/data-sources/morpho-api/market-supplies'; +import { fetchSubgraphMarketSupplies } from '@/data-sources/subgraph/market-supplies'; +import type { SupportedNetworks } from '@/utils/networks'; +import type { MarketActivityTransaction } from '@/utils/types'; + +const SEVEN_DAYS_IN_SECONDS = 7 * 24 * 60 * 60; + +export type SupplierPositionChange = { + userAddress: string; + netChange: bigint; // positive = net supply, negative = net withdraw + supplyTotal: bigint; + withdrawTotal: bigint; + transactionCount: number; +}; + +export type SupplierPositionChangesMap = Map; + +/** + * Calculate net position changes from transactions + */ +function calculatePositionChanges(transactions: MarketActivityTransaction[]): SupplierPositionChangesMap { + const changes = new Map(); + + for (const tx of transactions) { + const address = tx.userAddress.toLowerCase(); + const amount = BigInt(tx.amount); + + let existing = changes.get(address); + if (!existing) { + existing = { + userAddress: address, + netChange: 0n, + supplyTotal: 0n, + withdrawTotal: 0n, + transactionCount: 0, + }; + } + + if (tx.type === 'MarketSupply') { + existing.netChange += amount; + existing.supplyTotal += amount; + } else if (tx.type === 'MarketWithdraw') { + existing.netChange -= amount; + existing.withdrawTotal += amount; + } + existing.transactionCount += 1; + + changes.set(address, existing); + } + + return changes; +} + +/** + * Hook to fetch 7-day supply/withdraw transactions and calculate net position changes per user. + * Returns a map of userAddress (lowercase) -> position change data. + * + * @param marketId The unique key of the market. + * @param loanAssetId The address of the loan asset. + * @param network The blockchain network. + * @returns Map of position changes keyed by lowercase user address. + */ +export const useSupplierPositionChanges = ( + marketId: string | undefined, + loanAssetId: string | undefined, + network: SupportedNetworks | undefined, +) => { + const queryKey = ['supplierPositionChanges', marketId, loanAssetId, network]; + + const queryFn = async (): Promise => { + if (!marketId || !loanAssetId || !network) { + return new Map(); + } + + const sevenDaysAgo = Math.floor(Date.now() / 1000) - SEVEN_DAYS_IN_SECONDS; + const allTransactions: MarketActivityTransaction[] = []; + + // Fetch transactions in batches until we have all from the last 7 days + // or reach a reasonable limit + const pageSize = 100; + const maxPages = 10; // Max 1000 transactions + let currentPage = 1; + let hasMore = true; + + while (hasMore && currentPage <= maxPages) { + const skip = (currentPage - 1) * pageSize; + let result = null; + + // Try Morpho API first if supported + if (supportsMorphoApi(network)) { + try { + result = await fetchMorphoMarketSupplies(marketId, '0', pageSize, skip); + } catch (morphoError) { + console.error('Failed to fetch supplies via Morpho API:', morphoError); + } + } + + // Fallback to Subgraph + if (!result) { + try { + result = await fetchSubgraphMarketSupplies(marketId, loanAssetId, network, '0', pageSize, skip); + } catch (subgraphError) { + console.error('Failed to fetch supplies via Subgraph:', subgraphError); + break; + } + } + + if (!result || result.items.length === 0) { + hasMore = false; + break; + } + + // Filter to only transactions from last 7 days + const recentTransactions = result.items.filter((tx) => tx.timestamp >= sevenDaysAgo); + allTransactions.push(...recentTransactions); + + // If oldest transaction in this batch is older than 7 days, we have all we need + const oldestInBatch = result.items.at(-1); + if (oldestInBatch && oldestInBatch.timestamp < sevenDaysAgo) { + hasMore = false; + } else if (result.items.length < pageSize) { + hasMore = false; + } else { + currentPage++; + } + } + + return calculatePositionChanges(allTransactions); + }; + + const { data, isLoading, error, refetch } = useQuery({ + queryKey, + queryFn, + enabled: !!marketId && !!loanAssetId && !!network, + staleTime: 1000 * 60 * 5, // 5 minutes + placeholderData: () => new Map(), + retry: 1, + }); + + return { + data: data ?? new Map(), + isLoading, + error, + refetch, + }; +}; + +export default useSupplierPositionChanges;