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 (
);
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
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({
)}
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 ?
:
}
-