diff --git a/src/abis/chainlinkOraclev2.ts b/src/abis/chainlinkOraclev2.ts index ed73dff3..36a6dad7 100644 --- a/src/abis/chainlinkOraclev2.ts +++ b/src/abis/chainlinkOraclev2.ts @@ -1,3 +1,5 @@ +import type { Abi } from 'viem'; + export const abi = [ { inputs: [ @@ -86,4 +88,4 @@ export const abi = [ stateMutability: 'view', type: 'function', }, -]; +] as const satisfies Abi; diff --git a/src/components/shared/network-icon.tsx b/src/components/shared/network-icon.tsx index 8ec34315..3460975c 100644 --- a/src/components/shared/network-icon.tsx +++ b/src/components/shared/network-icon.tsx @@ -1,14 +1,19 @@ import Image from 'next/image'; import { getNetworkImg } from '@/utils/networks'; -export function NetworkIcon({ networkId }: { networkId: number }) { +type NetworkIconProps = { + networkId: number; + size?: number; +}; + +export function NetworkIcon({ networkId, size = 16 }: NetworkIconProps) { const url = getNetworkImg(networkId); return ( {`networkId-${networkId}`} ); diff --git a/src/features/market-detail/components/market-header.tsx b/src/features/market-detail/components/market-header.tsx index a4c23987..d67015c9 100644 --- a/src/features/market-detail/components/market-header.tsx +++ b/src/features/market-detail/components/market-header.tsx @@ -258,20 +258,74 @@ function MarketHeaderSkeleton(): React.ReactNode { return (
-
+
+ {/* LEFT: Market Identity skeleton */}
-
-
-
+
+
+
+
+
+
+
+ +
+ +
+
+
+
+ + {/* RIGHT: Stats + Actions skeleton */} +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+
-
-
-
+
+ + {/* Mobile stats row placeholder */} +
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + {/* Advanced details row placeholder */} +
+
+
+
diff --git a/src/features/market-detail/market-view.tsx b/src/features/market-detail/market-view.tsx index eeff2f19..b71c954e 100644 --- a/src/features/market-detail/market-view.tsx +++ b/src/features/market-detail/market-view.tsx @@ -253,14 +253,17 @@ function MarketContent() { isLoading={true} /> -
-
- Trend - Analysis - Activities - Positions -
-
+ + + Trend + Analysis + Activities + Positions + +
); diff --git a/src/features/positions/components/borrow-position-actions-dropdown.tsx b/src/features/positions/components/borrow-position-actions-dropdown.tsx new file mode 100644 index 00000000..7f45ab56 --- /dev/null +++ b/src/features/positions/components/borrow-position-actions-dropdown.tsx @@ -0,0 +1,68 @@ +'use client'; + +import type React from 'react'; +import { IoEllipsisVertical } from 'react-icons/io5'; +import { BsArrowDownLeftCircle, BsArrowUpRightCircle } from 'react-icons/bs'; +import { Button } from '@/components/ui/button'; +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'; + +type BorrowPositionActionsDropdownProps = { + isOwner: boolean; + isActiveDebt: boolean; + onBorrowMoreClick: () => void; + onRepayClick: () => void; +}; + +export function BorrowPositionActionsDropdown({ + isOwner, + isActiveDebt, + onBorrowMoreClick, + onRepayClick, +}: BorrowPositionActionsDropdownProps) { + const handleClick = (event: React.MouseEvent) => { + event.stopPropagation(); + }; + + const handleKeyDown = (event: React.KeyboardEvent) => { + event.stopPropagation(); + }; + + return ( +
+ + + + + + } + disabled={!isOwner} + className={isOwner ? '' : 'cursor-not-allowed opacity-50'} + > + Borrow More + + } + disabled={!isOwner} + className={isOwner ? '' : 'cursor-not-allowed opacity-50'} + > + {isActiveDebt ? 'Repay' : 'Manage'} + + + +
+ ); +} diff --git a/src/features/positions/components/borrowed-morpho-blue-table.tsx b/src/features/positions/components/borrowed-morpho-blue-table.tsx new file mode 100644 index 00000000..65b7fca7 --- /dev/null +++ b/src/features/positions/components/borrowed-morpho-blue-table.tsx @@ -0,0 +1,216 @@ +'use client'; + +import { useMemo } from 'react'; +import { useConnection } from 'wagmi'; +import { Button } from '@/components/ui/button'; +import { RefetchIcon } from '@/components/ui/refetch-icon'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; +import { TableContainerWithHeader } from '@/components/common/table-container-with-header'; +import { NetworkIcon } from '@/components/shared/network-icon'; +import { TooltipContent } from '@/components/shared/tooltip-content'; +import { TokenIcon } from '@/components/shared/token-icon'; +import { Tooltip } from '@/components/ui/tooltip'; +import { MarketIdentity, MarketIdentityFocus, MarketIdentityMode } from '@/features/markets/components/market-identity'; +import { useModal } from '@/hooks/useModal'; +import { useRateLabel } from '@/hooks/useRateLabel'; +import { useAppSettings } from '@/stores/useAppSettings'; +import { formatReadable } from '@/utils/balance'; +import { convertApyToApr } from '@/utils/rateMath'; +import { buildBorrowPositionRows } from '@/utils/positions'; +import type { MarketPositionWithEarnings } from '@/utils/types'; +import { BorrowPositionActionsDropdown } from './borrow-position-actions-dropdown'; + +type BorrowedMorphoBlueTableProps = { + account: string; + positions: MarketPositionWithEarnings[]; + onRefetch: (onSuccess?: () => void) => Promise; + isRefetching: boolean; +}; + +export function BorrowedMorphoBlueTable({ account, positions, onRefetch, isRefetching }: BorrowedMorphoBlueTableProps) { + const { address } = useConnection(); + const { open } = useModal(); + const { short: rateLabel } = useRateLabel(); + const { isAprDisplay } = useAppSettings(); + + const borrowRows = useMemo(() => buildBorrowPositionRows(positions), [positions]); + const isOwner = useMemo(() => !!account && !!address && account.toLowerCase() === address.toLowerCase(), [account, address]); + + const headerActions = ( + + } + > + + + + + ); + + if (borrowRows.length === 0) { + return null; + } + + return ( +
+ + + + + Network + Market + Loan + {rateLabel} (now) + Collateral + LTV + Actions + + + + {borrowRows.map((row) => { + const rowKey = `${row.market.uniqueKey}-${row.market.morphoBlue.chain.id}`; + + return ( + + +
+ +
+
+ + +
+ + {row.hasResidualCollateral && ( + Inactive + )} +
+
+ + +
+ {row.isActiveDebt ? ( + <> + {formatReadable(row.borrowAmount)} + {row.market.loanAsset.symbol} + + + ) : ( + - + )} +
+
+ + +
+ + {formatReadable( + (isAprDisplay ? convertApyToApr(row.market.state.borrowApy ?? 0) : (row.market.state.borrowApy ?? 0)) * 100, + )} + % + +
+
+ + +
+ {row.collateralAmount > 0 ? ( + <> + {formatReadable(row.collateralAmount)} + {row.market.collateralAsset.symbol} + + + ) : ( + - + )} +
+
+ + +
+ {row.ltvPercent === null ? ( + - + ) : ( + {formatReadable(row.ltvPercent)}% + )} +
+
+ + +
+ + open('borrow', { + market: row.market, + defaultMode: 'borrow', + toggleBorrowRepay: false, + refetch: () => { + void onRefetch(); + }, + }) + } + onRepayClick={() => + open('borrow', { + market: row.market, + defaultMode: 'repay', + toggleBorrowRepay: false, + refetch: () => { + void onRefetch(); + }, + }) + } + /> +
+
+
+ ); + })} +
+
+
+
+ ); +} diff --git a/src/features/positions/components/portfolio-value-badge.tsx b/src/features/positions/components/portfolio-value-badge.tsx index c21511af..d7b40224 100644 --- a/src/features/positions/components/portfolio-value-badge.tsx +++ b/src/features/positions/components/portfolio-value-badge.tsx @@ -5,11 +5,15 @@ import { PulseLoader } from 'react-spinners'; type PortfolioValueBadgeProps = { totalUsd: number; + totalDebtUsd: number; assetBreakdown: AssetBreakdownItem[]; + debtBreakdown: AssetBreakdownItem[]; isLoading: boolean; error: Error | null; }; +const VALUE_TEXT_CLASS = 'font-zen text-2xl font-normal tabular-nums sm:text-2xl'; + function formatBalance(value: number): string { if (value === 0) return '0'; if (value < 0.01) return '<0.01'; @@ -25,9 +29,9 @@ function formatBalance(value: number): string { }).format(value); } -function BreakdownTooltipContent({ items }: { items: AssetBreakdownItem[] }) { +function BreakdownTooltipContent({ items, emptyLabel = 'No holdings' }: { items: AssetBreakdownItem[]; emptyLabel?: string }) { if (items.length === 0) { - return No holdings; + return {emptyLabel}; } return ( @@ -56,12 +60,12 @@ function BreakdownTooltipContent({ items }: { items: AssetBreakdownItem[] }) { ); } -export function PortfolioValueBadge({ totalUsd, assetBreakdown, isLoading, error }: PortfolioValueBadgeProps) { - const content = ( +function ValueBlock({ label, value, isLoading, error }: { label: string; value: number; isLoading: boolean; error: Error | null }) { + return (
- Total Value + {label} {isLoading ? ( -
+
) : error ? ( - + ) : ( - {formatUsdValue(totalUsd)} + {formatUsdValue(value)} )}
); +} - if (isLoading || error) { - return content; - } +export function PortfolioValueBadge({ totalUsd, totalDebtUsd, assetBreakdown, debtBreakdown, isLoading, error }: PortfolioValueBadgeProps) { + const valueContent = ( + + ); + + const debtContentBlock = + !isLoading && !error && totalDebtUsd > 0 ? ( + + ) : null; return ( - } - placement="bottom" - > - {content} - +
+ {isLoading || error ? ( + valueContent + ) : ( + } + placement="bottom" + > + {valueContent} + + )} + {debtContentBlock && } + {debtContentBlock && ( + + } + placement="bottom" + > + {debtContentBlock} + + )} +
); } diff --git a/src/features/positions/components/supplied-morpho-blue-grouped-table.tsx b/src/features/positions/components/supplied-morpho-blue-grouped-table.tsx index 57b99f8a..2b27f415 100644 --- a/src/features/positions/components/supplied-morpho-blue-grouped-table.tsx +++ b/src/features/positions/components/supplied-morpho-blue-grouped-table.tsx @@ -24,7 +24,7 @@ import { useAppSettings } from '@/stores/useAppSettings'; import { useModalStore } from '@/stores/useModalStore'; import { useRateLabel } from '@/hooks/useRateLabel'; import { useStyledToast } from '@/hooks/useStyledToast'; -import useUserPositionsSummaryData, { type EarningsPeriod } from '@/hooks/useUserPositionsSummaryData'; +import type { EarningsPeriod } from '@/hooks/useUserPositionsSummaryData'; import { formatReadable, formatBalance } from '@/utils/balance'; import { getNetworkImg } from '@/utils/networks'; import { getGroupedEarnings, groupPositionsByLoanAsset, processCollaterals } from '@/utils/positions'; @@ -35,25 +35,33 @@ import { PositionActionsDropdown } from './position-actions-dropdown'; import { SuppliedMarketsDetail } from './supplied-markets-detail'; import { CollateralIconsDisplay } from './collateral-icons-display'; import { RiArrowRightLine } from 'react-icons/ri'; +import type { MarketPositionWithEarnings, UserTransaction } from '@/utils/types'; +import type { PositionSnapshot } from '@/utils/positions'; type SuppliedMorphoBlueGroupedTableProps = { account: string; + positions: MarketPositionWithEarnings[]; + refetch: (onSuccess?: () => void) => Promise; + isRefetching: boolean; + isEarningsLoading: boolean; + actualBlockData: Record; + transactions: UserTransaction[]; + snapshotsByChain: Record>; }; -export function SuppliedMorphoBlueGroupedTable({ account }: SuppliedMorphoBlueGroupedTableProps) { +export function SuppliedMorphoBlueGroupedTable({ + account, + positions, + refetch, + isRefetching, + isEarningsLoading, + actualBlockData, + transactions, + snapshotsByChain, +}: SuppliedMorphoBlueGroupedTableProps) { const period = usePositionsFilters((s) => s.period); const setPeriod = usePositionsFilters((s) => s.setPeriod); - const { - positions: marketPositions, - refetch, - isRefetching, - isEarningsLoading, - actualBlockData, - transactions, - snapshotsByChain, - } = useUserPositionsSummaryData(account, period); - const [expandedRows, setExpandedRows] = useState>(new Set()); const { showEarningsInUsd, setShowEarningsInUsd } = usePositionsPreferences(); const { isOpen: isSettingsOpen, onOpen: onSettingsOpen, onOpenChange: onSettingsOpenChange } = useDisclosure(); @@ -78,7 +86,7 @@ export function SuppliedMorphoBlueGroupedTable({ account }: SuppliedMorphoBlueGr all: 'All', }; - const groupedPositions = useMemo(() => groupPositionsByLoanAsset(marketPositions), [marketPositions]); + const groupedPositions = useMemo(() => groupPositionsByLoanAsset(positions), [positions]); const processedPositions = useMemo(() => processCollaterals(groupedPositions), [groupedPositions]); @@ -193,8 +201,8 @@ export function SuppliedMorphoBlueGroupedTable({ account }: SuppliedMorphoBlueGr {`Chain
diff --git a/src/features/positions/components/user-vaults-table.tsx b/src/features/positions/components/user-vaults-table.tsx index a3630ccc..d22d0e0c 100644 --- a/src/features/positions/components/user-vaults-table.tsx +++ b/src/features/positions/components/user-vaults-table.tsx @@ -165,8 +165,8 @@ export function UserVaultsTable({ {`Chain )}
diff --git a/src/features/positions/positions-view.tsx b/src/features/positions/positions-view.tsx index ead1fdba..00531286 100644 --- a/src/features/positions/positions-view.tsx +++ b/src/features/positions/positions-view.tsx @@ -19,6 +19,7 @@ import { useModal } from '@/hooks/useModal'; import { usePositionsFilters } from '@/stores/usePositionsFilters'; import { usePortfolioBookmarks } from '@/stores/usePortfolioBookmarks'; import { SuppliedMorphoBlueGroupedTable } from './components/supplied-morpho-blue-grouped-table'; +import { BorrowedMorphoBlueTable } from './components/borrowed-morpho-blue-table'; import { PortfolioValueBadge } from './components/portfolio-value-badge'; import { UserVaultsTable } from './components/user-vaults-table'; import { PositionBreadcrumbs } from '@/features/position-detail/components/position-breadcrumbs'; @@ -32,7 +33,16 @@ export default function Positions() { const { loading: isMarketsLoading } = useProcessedMarkets(); - const { isPositionsLoading, positions: marketPositions } = useUserPositionsSummaryData(account, 'day'); + const { + isPositionsLoading, + positions: marketPositions, + refetch: refetchPositions, + isRefetching: isPositionsRefetching, + isEarningsLoading, + actualBlockData, + transactions, + snapshotsByChain, + } = useUserPositionsSummaryData(account, period); // Fetch user's auto vaults const { @@ -55,15 +65,25 @@ export default function Positions() { }, [vaults, vaultApyData]); // Calculate portfolio value from positions and vaults - const { totalUsd, assetBreakdown, isLoading: isPricesLoading, error: pricesError } = usePortfolioValue(marketPositions, vaults); + const { + totalUsd, + totalDebtUsd, + assetBreakdown, + debtBreakdown, + isLoading: isPricesLoading, + error: pricesError, + } = usePortfolioValue(marketPositions, vaults); const loading = isMarketsLoading || isPositionsLoading; const loadingMessage = isMarketsLoading ? 'Loading markets...' : 'Loading user positions...'; - const hasSuppliedMarkets = marketPositions && marketPositions.length > 0; + const hasSuppliedMarkets = marketPositions.some((position) => BigInt(position.state.supplyShares) > 0n); + const hasBorrowPositions = marketPositions.some( + (position) => BigInt(position.state.borrowShares) > 0n || BigInt(position.state.collateral) > 0n, + ); const hasVaults = vaults && vaults.length > 0; - const showEmpty = !loading && !isVaultsLoading && !hasSuppliedMarkets && !hasVaults; + const showEmpty = !loading && !isVaultsLoading && !hasSuppliedMarkets && !hasBorrowPositions && !hasVaults; const isBookmarked = isAddressBookmarked(account as Address); useEffect(() => { @@ -100,17 +120,18 @@ export default function Positions() { {isBookmarked ? : }
-
+
{!loading && ( )} - -
+
+ toggleBorrowRepay ? ( + + ) : undefined } /> diff --git a/src/modals/settings/monarch-settings/panels/ExperimentalPanel.tsx b/src/modals/settings/monarch-settings/panels/ExperimentalPanel.tsx index 0990d176..4d9df0f8 100644 --- a/src/modals/settings/monarch-settings/panels/ExperimentalPanel.tsx +++ b/src/modals/settings/monarch-settings/panels/ExperimentalPanel.tsx @@ -68,7 +68,7 @@ export function ExperimentalPanel({ onNavigateToDetail }: ExperimentalPanelProps

Developer

void; liquiditySourcing?: LiquiditySourcingResult; }; diff --git a/src/utils/portfolio.ts b/src/utils/portfolio.ts index b28d85ce..8c9a3f30 100644 --- a/src/utils/portfolio.ts +++ b/src/utils/portfolio.ts @@ -14,6 +14,7 @@ export type TokenBalance = { // Portfolio breakdown by source export type PortfolioBreakdown = { nativeSupplies: number; + nativeBorrows: number; vaults: number; }; @@ -118,6 +119,20 @@ export const positionsToBalances = (positions: MarketPositionWithEarnings[]): To })); }; +/** + * Convert native borrow positions to token balances + * @param positions - Array of market positions + * @returns Array of token balances + */ +export const positionsToBorrowBalances = (positions: MarketPositionWithEarnings[]): TokenBalance[] => { + return positions.map((position) => ({ + tokenAddress: position.market.loanAsset.address, + chainId: position.market.morphoBlue.chain.id, + balance: BigInt(position.state.borrowAssets), + decimals: position.market.loanAsset.decimals, + })); +}; + /** * Convert vaults to token balances * @param vaults - Array of user vaults @@ -161,6 +176,8 @@ export const calculatePortfolioValue = ( // Convert positions to balances const positionBalances = positionsToBalances(positions); const nativeSuppliesUsd = calculateUsdValue(positionBalances, prices); + const borrowBalances = positionsToBorrowBalances(positions); + const nativeBorrowsUsd = calculateUsdValue(borrowBalances, prices); // Convert vaults to balances (if provided) let vaultsUsd = 0; @@ -173,6 +190,7 @@ export const calculatePortfolioValue = ( total: nativeSuppliesUsd + vaultsUsd, breakdown: { nativeSupplies: nativeSuppliesUsd, + nativeBorrows: nativeBorrowsUsd, vaults: vaultsUsd, }, }; @@ -256,6 +274,54 @@ export const calculateAssetBreakdown = ( return items.sort((a, b) => b.usdValue - a.usdValue); }; +/** + * Calculate per-asset debt breakdown for tooltip display + * Aggregates borrowed amounts by loan token across positions + */ +export const calculateDebtBreakdown = (positions: MarketPositionWithEarnings[], prices: Map): AssetBreakdownItem[] => { + const aggregated = new Map(); + + for (const position of positions) { + const borrowAssets = BigInt(position.state.borrowAssets); + if (borrowAssets <= 0n) continue; + + const { address, symbol, decimals } = position.market.loanAsset; + const chainId = position.market.morphoBlue.chain.id; + const key = getTokenPriceKey(address, chainId); + const existing = aggregated.get(key); + + if (existing) { + existing.balance += borrowAssets; + } else { + aggregated.set(key, { + symbol, + tokenAddress: address, + chainId, + balance: borrowAssets, + decimals, + }); + } + } + + const items: AssetBreakdownItem[] = []; + for (const [key, data] of aggregated) { + const price = prices.get(key) ?? 0; + const balance = Number.parseFloat(formatUnits(data.balance, data.decimals)); + const usdValue = balance * price; + + items.push({ + symbol: data.symbol, + tokenAddress: data.tokenAddress, + chainId: data.chainId, + balance, + price, + usdValue, + }); + } + + return items.sort((a, b) => b.usdValue - a.usdValue); +}; + /** * Format USD value for display * @param value - USD value to format diff --git a/src/utils/positions.ts b/src/utils/positions.ts index 880af99f..e31d6120 100644 --- a/src/utils/positions.ts +++ b/src/utils/positions.ts @@ -1,8 +1,9 @@ import { type Address, formatUnits, type PublicClient } from 'viem'; +import { abi as chainlinkOracleAbi } from '@/abis/chainlinkOraclev2'; import morphoABI from '@/abis/morpho'; import { getMorphoAddress } from './morpho'; import type { SupportedNetworks } from './networks'; -import type { MarketPosition, MarketPositionWithEarnings, GroupedPosition } from './types'; +import type { Market as MorphoMarket, MarketPosition, MarketPositionWithEarnings, GroupedPosition } from './types'; export type PositionSnapshot = { supplyAssets: string; @@ -27,7 +28,7 @@ type Position = { collateral: bigint; }; -type Market = { +type MorphoMarketState = { totalSupplyAssets: bigint; totalSupplyShares: bigint; totalBorrowAssets: bigint; @@ -36,6 +37,39 @@ type Market = { fee: bigint; }; +export type PositionMarketOracleInput = { + marketUniqueKey: string; + oracleAddress?: string | null; +}; + +export type PositionSnapshotsWithOracleResult = { + snapshots: Map; + oraclePrices: Map; +}; + +export type BorrowPositionRow = { + market: MorphoMarket; + state: { + borrowAssets: string; + borrowShares: string; + collateral: string; + }; + borrowAmount: number; + collateralAmount: number; + ltvPercent: number | null; + isActiveDebt: boolean; + hasResidualCollateral: boolean; +}; + +const MARKET_ORACLE_SCALE = 10n ** 36n; + +function normalizeOraclePriceResult(value: unknown): string | null { + if (typeof value === 'bigint' || typeof value === 'number' || typeof value === 'string') { + return value.toString(); + } + return null; +} + // Helper functions function arrayToPosition(arr: readonly bigint[]): Position { return { @@ -45,7 +79,7 @@ function arrayToPosition(arr: readonly bigint[]): Position { }; } -function arrayToMarket(arr: readonly bigint[]): Market { +function arrayToMarket(arr: readonly bigint[]): MorphoMarketState { return { totalSupplyAssets: arr[0], totalSupplyShares: arr[1], @@ -186,6 +220,98 @@ export async function fetchPositionsSnapshots( } } +/** + * Fetches latest position snapshots plus live oracle prices in batched multicalls. + * + * @param markets - Array of market keys and oracle addresses + * @param userAddress - The user's address + * @param chainId - The chain ID of the network + * @param client - The viem PublicClient to use for the request + * @returns Snapshots and oracle prices keyed by market key (lowercase) + */ +export async function fetchLatestPositionSnapshotsWithOraclePrices( + markets: PositionMarketOracleInput[], + userAddress: Address, + chainId: number, + client: PublicClient, +): Promise { + const snapshots = new Map(); + const oraclePrices = new Map(); + + if (markets.length === 0) { + return { snapshots, oraclePrices }; + } + + const marketIds = markets.map((market) => market.marketUniqueKey); + const latestSnapshots = await fetchPositionsSnapshots(marketIds, userAddress, chainId, undefined, client); + + latestSnapshots.forEach((snapshot, marketId) => { + snapshots.set(marketId.toLowerCase(), snapshot); + }); + + const marketsWithOracle = markets.filter((market) => market.oracleAddress); + if (marketsWithOracle.length === 0) { + return { snapshots, oraclePrices }; + } + + try { + const oracleContracts = marketsWithOracle.map((market) => ({ + address: market.oracleAddress as `0x${string}`, + abi: chainlinkOracleAbi, + functionName: 'price' as const, + })); + + const oracleResults = await client.multicall({ + contracts: oracleContracts, + allowFailure: true, + }); + + oracleResults.forEach((oracleResult, index) => { + const marketKey = marketsWithOracle[index]?.marketUniqueKey.toLowerCase(); + if (!marketKey) return; + + if (oracleResult.status === 'success' && oracleResult.result !== undefined && oracleResult.result !== null) { + const normalizedPrice = normalizeOraclePriceResult(oracleResult.result); + oraclePrices.set(marketKey, normalizedPrice); + } else { + oraclePrices.set(marketKey, null); + } + }); + + return { snapshots, oraclePrices }; + } catch (error) { + console.error('Error fetching batched oracle prices:', { + chainId, + marketCount: marketsWithOracle.length, + error, + }); + return { snapshots, oraclePrices }; + } +} + +/** + * Compute LTV% from borrow assets, collateral assets and oracle price. + * + * @returns LTV percentage with two-decimal precision, or null when unavailable. + */ +export function calculatePositionLtvPercent( + borrowAssets: bigint, + collateralAssets: bigint, + oraclePrice: bigint | null | undefined, +): number | null { + if (!oraclePrice || oraclePrice <= 0n || borrowAssets <= 0n || collateralAssets <= 0n) { + return null; + } + + const collateralValueInLoan = (collateralAssets * oraclePrice) / MARKET_ORACLE_SCALE; + if (collateralValueInLoan <= 0n) { + return null; + } + + const ltvBps = (borrowAssets * 10_000n) / collateralValueInLoan; + return Number(ltvBps) / 100; +} + /** * Fetches a position snapshot for a specific market, user, and block number using a PublicClient * @@ -377,6 +503,53 @@ export function groupPositionsByLoanAsset(positions: MarketPositionWithEarnings[ .sort((a, b) => b.totalSupply - a.totalSupply); } +/** + * Build flat borrow rows (no grouping) for the positions page. + * Includes active borrow positions and fully repaid positions with remaining collateral. + */ +export function buildBorrowPositionRows(positions: MarketPositionWithEarnings[]): BorrowPositionRow[] { + return positions + .filter((position) => { + const borrowShares = BigInt(position.state.borrowShares); + const collateral = BigInt(position.state.collateral); + return borrowShares > 0n || collateral > 0n; + }) + .map((position) => { + const borrowShares = BigInt(position.state.borrowShares); + const borrowAssets = BigInt(position.state.borrowAssets); + const collateralAssets = BigInt(position.state.collateral); + const oraclePrice = position.oraclePrice ? BigInt(position.oraclePrice) : null; + const collateralAsset = position.market.collateralAsset; + const hasCollateralAsset = typeof collateralAsset?.decimals === 'number'; + + const isActiveDebt = borrowShares > 0n; + const hasResidualCollateral = hasCollateralAsset && borrowShares === 0n && collateralAssets > 0n; + + return { + market: position.market, + state: { + borrowAssets: position.state.borrowAssets, + borrowShares: position.state.borrowShares, + collateral: position.state.collateral, + }, + borrowAmount: Number(formatUnits(borrowAssets, position.market.loanAsset.decimals)), + collateralAmount: hasCollateralAsset ? Number(formatUnits(collateralAssets, collateralAsset.decimals)) : 0, + ltvPercent: isActiveDebt && hasCollateralAsset ? calculatePositionLtvPercent(borrowAssets, collateralAssets, oraclePrice) : null, + isActiveDebt, + hasResidualCollateral, + }; + }) + .sort((a, b) => { + if (a.isActiveDebt !== b.isActiveDebt) { + return a.isActiveDebt ? -1 : 1; + } + if (b.borrowAmount !== a.borrowAmount) { + return b.borrowAmount - a.borrowAmount; + } + return b.collateralAmount - a.collateralAmount; + }); +} + /** * Process collaterals for grouped positions, simplifying small collaterals into an "Others" category * diff --git a/src/utils/types.ts b/src/utils/types.ts index f524434d..ea6172a9 100644 --- a/src/utils/types.ts +++ b/src/utils/types.ts @@ -17,6 +17,7 @@ export type MarketPosition = { borrowAssets: string; collateral: string; }; + oraclePrice?: string | null; market: Market; // Now using the full Market type };