diff --git a/src/features/market-detail/components/borrowers-table.tsx b/src/features/market-detail/components/borrowers-table.tsx
index a4d088e1..1d5e306f 100644
--- a/src/features/market-detail/components/borrowers-table.tsx
+++ b/src/features/market-detail/components/borrowers-table.tsx
@@ -10,10 +10,12 @@ import { Spinner } from '@/components/ui/spinner';
import { TablePagination } from '@/components/shared/table-pagination';
import { TokenIcon } from '@/components/shared/token-icon';
import { TooltipContent } from '@/components/shared/tooltip-content';
+import { useAppSettings } from '@/stores/useAppSettings';
import { MONARCH_PRIMARY } from '@/constants/chartColors';
import { useMarketBorrowers } from '@/hooks/useMarketBorrowers';
import { formatSimple } from '@/utils/balance';
import type { Market } from '@/utils/types';
+import { LiquidateModal } from '@/modals/liquidate/liquidate-modal';
type BorrowersTableProps = {
chainId: number;
@@ -25,7 +27,9 @@ type BorrowersTableProps = {
export function BorrowersTable({ chainId, market, minShares, oraclePrice, onOpenFiltersModal }: BorrowersTableProps) {
const [currentPage, setCurrentPage] = useState(1);
+ const [liquidateBorrower, setLiquidateBorrower] = useState
(null);
const pageSize = 10;
+ const { showDeveloperOptions } = useAppSettings();
const { data: paginatedData, isLoading, isFetching } = useMarketBorrowers(market?.uniqueKey, chainId, minShares, currentPage, pageSize);
@@ -117,13 +121,14 @@ export function BorrowersTable({ chainId, market, minShares, oraclePrice, onOpen
COLLATERAL
LTV
% OF BORROW
+ {showDeveloperOptions && ACTIONS}
{borrowersWithLTV.length === 0 && !isLoading ? (
No borrowers found for this market
@@ -176,6 +181,17 @@ export function BorrowersTable({ chainId, market, minShares, oraclePrice, onOpen
{borrower.ltv.toFixed(2)}%
{percentDisplay}
+ {showDeveloperOptions && (
+
+
+
+ )}
);
})
@@ -195,6 +211,17 @@ export function BorrowersTable({ chainId, market, minShares, oraclePrice, onOpen
isLoading={isFetching}
/>
)}
+
+ {liquidateBorrower && (
+ {
+ if (!open) setLiquidateBorrower(null);
+ }}
+ />
+ )}
);
}
diff --git a/src/hooks/useLiquidateTransaction.ts b/src/hooks/useLiquidateTransaction.ts
new file mode 100644
index 00000000..3d21ed43
--- /dev/null
+++ b/src/hooks/useLiquidateTransaction.ts
@@ -0,0 +1,114 @@
+import { useCallback } from 'react';
+import { type Address, encodeFunctionData } from 'viem';
+import { useConnection } from 'wagmi';
+import morphoAbi from '@/abis/morpho';
+import { formatBalance } from '@/utils/balance';
+import { getMorphoAddress } from '@/utils/morpho';
+import type { Market } from '@/utils/types';
+import { useERC20Approval } from './useERC20Approval';
+import { useTransactionWithToast } from './useTransactionWithToast';
+import { useTransactionTracking } from './useTransactionTracking';
+
+type UseLiquidateTransactionProps = {
+ market: Market;
+ borrower: Address;
+ seizedAssets: bigint;
+ repaidShares: bigint;
+ estimatedRepaidAmount: bigint; // raw loan token estimate before approval buffer
+ onSuccess?: () => void;
+};
+
+const APPROVAL_BUFFER_BPS = 400n;
+const BPS_SCALE = 10_000n;
+
+const addBufferBpsUp = (amount: bigint, bps: bigint): bigint => {
+ if (amount === 0n) return 0n;
+ return (amount * (BPS_SCALE + bps) + (BPS_SCALE - 1n)) / BPS_SCALE;
+};
+
+export function useLiquidateTransaction({
+ market,
+ borrower,
+ seizedAssets,
+ repaidShares,
+ estimatedRepaidAmount,
+ onSuccess,
+}: UseLiquidateTransactionProps) {
+ const { address: account, chainId } = useConnection();
+
+ const tracking = useTransactionTracking('liquidate');
+ const morphoAddress = chainId ? getMorphoAddress(chainId) : undefined;
+ const hasSeizedAssets = seizedAssets > 0n;
+ const hasRepaidShares = repaidShares > 0n;
+ const hasExactlyOneLiquidationMode = hasSeizedAssets !== hasRepaidShares;
+
+ // Liquidation repays debt in both modes:
+ // - repaidShares > 0 (max/share-based)
+ // - seizedAssets > 0 (asset-based)
+ const approvalAmount = hasExactlyOneLiquidationMode ? addBufferBpsUp(estimatedRepaidAmount, APPROVAL_BUFFER_BPS) : 0n;
+
+ const { isApproved, approve } = useERC20Approval({
+ token: market.loanAsset.address as Address,
+ spender: morphoAddress ?? '0x',
+ amount: approvalAmount,
+ tokenSymbol: market.loanAsset.symbol,
+ chainId,
+ });
+
+ const { isConfirming: liquidatePending, sendTransactionAsync } = useTransactionWithToast({
+ toastId: 'liquidate',
+ pendingText: `Liquidating ${formatBalance(seizedAssets, market.collateralAsset.decimals)} ${market.collateralAsset.symbol}`,
+ successText: 'Liquidation successful',
+ errorText: 'Failed to liquidate',
+ chainId,
+ pendingDescription: `Liquidating borrower ${borrower.slice(0, 6)}...`,
+ successDescription: `Successfully liquidated ${borrower.slice(0, 6)}`,
+ onSuccess,
+ ...tracking,
+ });
+
+ const liquidate = useCallback(async () => {
+ if (!account || !chainId || !morphoAddress) return;
+ if (!hasExactlyOneLiquidationMode) {
+ throw new Error('Invalid liquidation params: exactly one of seizedAssets or repaidShares must be non-zero');
+ }
+
+ const marketParams = {
+ loanToken: market.loanAsset.address as `0x${string}`,
+ collateralToken: market.collateralAsset.address as `0x${string}`,
+ oracle: market.oracleAddress as `0x${string}`,
+ irm: market.irmAddress as `0x${string}`,
+ lltv: BigInt(market.lltv),
+ };
+
+ const liquidateTx = encodeFunctionData({
+ abi: morphoAbi,
+ functionName: 'liquidate',
+ args: [marketParams, borrower, hasSeizedAssets ? seizedAssets : 0n, hasRepaidShares ? repaidShares : 0n, '0x'],
+ });
+
+ await sendTransactionAsync({ to: morphoAddress as Address, data: liquidateTx });
+ }, [
+ account,
+ chainId,
+ market,
+ borrower,
+ hasExactlyOneLiquidationMode,
+ hasSeizedAssets,
+ seizedAssets,
+ hasRepaidShares,
+ repaidShares,
+ morphoAddress,
+ sendTransactionAsync,
+ ]);
+
+ const handleLiquidate = useCallback(async () => {
+ if (!hasExactlyOneLiquidationMode) {
+ throw new Error('Invalid liquidation params: exactly one of seizedAssets or repaidShares must be non-zero');
+ }
+ if (!isApproved) await approve();
+ await liquidate();
+ }, [hasExactlyOneLiquidationMode, isApproved, approve, liquidate]);
+
+ return { liquidatePending, liquidate, handleLiquidate };
+}
diff --git a/src/modals/liquidate/components/liquidate-modal-content.tsx b/src/modals/liquidate/components/liquidate-modal-content.tsx
new file mode 100644
index 00000000..4f061a4b
--- /dev/null
+++ b/src/modals/liquidate/components/liquidate-modal-content.tsx
@@ -0,0 +1,228 @@
+import { useState, useCallback, useMemo } from 'react';
+import { RefetchIcon } from '@/components/ui/refetch-icon';
+import Input from '@/components/Input/Input';
+import { useLiquidateTransaction } from '@/hooks/useLiquidateTransaction';
+import { formatBalance, formatReadable } from '@/utils/balance';
+import { estimateLiquidationRepaidAmount } from '@/utils/morpho';
+import type { Market } from '@/utils/types';
+import { TokenIcon } from '@/components/shared/token-icon';
+import { ExecuteTransactionButton } from '@/components/ui/ExecuteTransactionButton';
+import { AccountIdentity } from '@/components/shared/account-identity';
+import type { Address } from 'viem';
+
+type LiquidateModalContentProps = {
+ market: Market;
+ borrower: Address;
+ borrowerCollateral: bigint;
+ borrowerBorrowShares: bigint;
+ oraclePrice: bigint;
+ onSuccess?: () => void;
+ onRefresh?: () => void;
+ isLoading?: boolean;
+};
+
+export function LiquidateModalContent({
+ market,
+ borrower,
+ borrowerCollateral,
+ borrowerBorrowShares,
+ oraclePrice,
+ onSuccess,
+ onRefresh,
+ isLoading,
+}: LiquidateModalContentProps): JSX.Element {
+ const [seizedCollateralAmount, setSeizedCollateralAmount] = useState(BigInt(0));
+ const [inputError, setInputError] = useState(null);
+ const [useMaxShares, setUseMaxShares] = useState(false);
+ const [isRefreshing, setIsRefreshing] = useState(false);
+
+ // Convert borrow shares to assets for display
+ const totalBorrowAssets = BigInt(market.state.borrowAssets);
+ const totalBorrowShares = BigInt(market.state.borrowShares);
+ const borrowerDebtInAssets =
+ totalBorrowShares > 0n && borrowerBorrowShares > 0n ? (borrowerBorrowShares * totalBorrowAssets) / totalBorrowShares : 0n;
+
+ // Calculate USD values
+ const borrowerDebtUsd =
+ totalBorrowAssets > 0n && market.state.borrowAssetsUsd > 0
+ ? (Number(borrowerDebtInAssets) / Number(totalBorrowAssets)) * market.state.borrowAssetsUsd
+ : 0;
+
+ const borrowerCollateralUsd =
+ borrowerCollateral > 0n && market.state.collateralAssetsUsd != null && Number(market.state.collateralAssets) > 0
+ ? (Number(borrowerCollateral) / Number(market.state.collateralAssets)) * (market.state.collateralAssetsUsd ?? 0)
+ : 0;
+
+ // seizedAssets is in collateral token units
+ // When useMaxShares is true, we repay full debt (shares) and protocol calculates collateral
+ const seizedAssets = useMaxShares ? BigInt(0) : seizedCollateralAmount;
+ const repaidShares = useMaxShares ? borrowerBorrowShares : BigInt(0);
+
+ const estimatedRepaidAmount = useMemo(
+ () =>
+ estimateLiquidationRepaidAmount({
+ seizedAssets,
+ repaidShares,
+ oraclePrice,
+ totalBorrowAssets,
+ totalBorrowShares,
+ lltv: BigInt(market.lltv),
+ }),
+ [market.lltv, oraclePrice, repaidShares, seizedAssets, totalBorrowAssets, totalBorrowShares],
+ );
+
+ const { liquidatePending, handleLiquidate } = useLiquidateTransaction({
+ market,
+ borrower,
+ seizedAssets,
+ repaidShares,
+ estimatedRepaidAmount,
+ onSuccess,
+ });
+
+ const handleMaxClick = useCallback(() => {
+ setUseMaxShares(true);
+ setInputError(null);
+ }, []);
+
+ const handleInputChange = useCallback((value: bigint) => {
+ setSeizedCollateralAmount(value);
+ setUseMaxShares(false);
+ setInputError(null);
+ }, []);
+
+ const handleRefresh = useCallback(async () => {
+ if (!onRefresh) return;
+ setIsRefreshing(true);
+ try {
+ onRefresh();
+ } finally {
+ setTimeout(() => setIsRefreshing(false), 500);
+ }
+ }, [onRefresh]);
+
+ const isValid = (seizedCollateralAmount > 0n || useMaxShares) && (useMaxShares || oraclePrice > 0n);
+ const hasBorrowPosition = borrowerBorrowShares > 0n;
+
+ return (
+
+
+
+
Borrower
+ {onRefresh && (
+
+ )}
+
+
+
+
+
+
+
Debt
+
+ {formatReadable(formatBalance(borrowerDebtInAssets, market.loanAsset.decimals))}
+
+ {borrowerDebtUsd > 0 && ${borrowerDebtUsd.toFixed(2)}}
+
+
+
+
Collateral
+
+ {formatReadable(formatBalance(borrowerCollateral, market.collateralAsset.decimals))}
+
+ {borrowerCollateralUsd > 0 && ${borrowerCollateralUsd.toFixed(2)}}
+
+
+
+
+ {hasBorrowPosition && (
+
+
+ Collateral to seize
+
+
+
+
+ )}
+
+ {hasBorrowPosition && estimatedRepaidAmount > 0n && (
+
+
+
+ Estimated repay
+
+
+ {formatReadable(formatBalance(estimatedRepaidAmount, market.loanAsset.decimals))}
+
+
+
+
+ )}
+
+ {!hasBorrowPosition && borrowerBorrowShares === 0n && borrowerCollateral === 0n && (
+
+ {isLoading ? 'Loading position data...' : 'No position found for this borrower'}
+
+ )}
+
+
+
+ Liquidate
+
+
+
+ );
+}
diff --git a/src/modals/liquidate/liquidate-modal.tsx b/src/modals/liquidate/liquidate-modal.tsx
new file mode 100644
index 00000000..3aeba515
--- /dev/null
+++ b/src/modals/liquidate/liquidate-modal.tsx
@@ -0,0 +1,92 @@
+import { useReadContract } from 'wagmi';
+import { Modal, ModalHeader, ModalBody } from '@/components/common/Modal';
+import type { Market } from '@/utils/types';
+import { LiquidateModalContent } from './components/liquidate-modal-content';
+import { TokenIcon } from '@/components/shared/token-icon';
+import morphoAbi from '@/abis/morpho';
+import type { Address } from 'viem';
+
+type LiquidateModalProps = {
+ market: Market;
+ borrower: Address;
+ oraclePrice: bigint;
+ onOpenChange: (open: boolean) => void;
+};
+
+export function LiquidateModal({ market, borrower, oraclePrice, onOpenChange }: LiquidateModalProps): JSX.Element {
+ const {
+ data: borrowerPosition,
+ refetch: refetchBorrowerPosition,
+ isLoading: isBorrowerPositionLoading,
+ } = useReadContract({
+ address: market.morphoBlue.address as `0x${string}`,
+ functionName: 'position',
+ args: [market.uniqueKey as `0x${string}`, borrower as `0x${string}`],
+ abi: morphoAbi,
+ chainId: market.morphoBlue.chain.id,
+ query: {
+ enabled: !!borrower && !!market.morphoBlue.address && !!market.uniqueKey,
+ refetchInterval: 10_000,
+ },
+ });
+
+ const borrowerCollateral = borrowerPosition ? BigInt(borrowerPosition[2]) : 0n;
+ const borrowerBorrowShares = borrowerPosition ? BigInt(borrowerPosition[1]) : 0n;
+
+ const mainIcon = (
+
+ );
+
+ const handleRefetch = () => {
+ void refetchBorrowerPosition();
+ };
+
+ return (
+
+ onOpenChange(false)}
+ title={
+
+ {market.loanAsset.symbol}
+ / {market.collateralAsset.symbol}
+
+ }
+ description="Liquidate an underwater position"
+ />
+
+ onOpenChange(false)}
+ onRefresh={handleRefetch}
+ isLoading={isBorrowerPositionLoading}
+ />
+
+
+ );
+}
diff --git a/src/utils/morpho.ts b/src/utils/morpho.ts
index 0c317530..bbab6b99 100644
--- a/src/utils/morpho.ts
+++ b/src/utils/morpho.ts
@@ -1,4 +1,4 @@
-import { Market as BlueMarket, MarketParams as BlueMarketParams } from '@morpho-org/blue-sdk';
+import { Market as BlueMarket, MarketParams as BlueMarketParams, MarketUtils } from '@morpho-org/blue-sdk';
import { type Address, decodeAbiParameters, encodeAbiParameters, keccak256, parseAbiParameters, zeroAddress } from 'viem';
import { SupportedNetworks } from './networks';
import { type Market, type MarketParams, UserTxTypes } from './types';
@@ -283,6 +283,44 @@ type MarketStatePreview = {
liquidityAssets: bigint;
};
+type EstimateLiquidationRepaidAmountParams = {
+ seizedAssets: bigint;
+ repaidShares: bigint;
+ oraclePrice: bigint;
+ totalBorrowAssets: bigint;
+ totalBorrowShares: bigint;
+ lltv: bigint;
+};
+
+export function estimateLiquidationRepaidAmount({
+ seizedAssets,
+ repaidShares,
+ oraclePrice,
+ totalBorrowAssets,
+ totalBorrowShares,
+ lltv,
+}: EstimateLiquidationRepaidAmountParams): bigint {
+ const marketState = {
+ totalBorrowAssets,
+ totalBorrowShares,
+ price: oraclePrice,
+ };
+
+ if (repaidShares > 0n) {
+ return MarketUtils.toBorrowAssets(repaidShares, marketState, 'Up');
+ }
+
+ if (seizedAssets > 0n && oraclePrice > 0n) {
+ const derivedRepaidShares =
+ MarketUtils.getLiquidationRepaidShares(seizedAssets, marketState, {
+ lltv,
+ }) ?? 0n;
+ return MarketUtils.toBorrowAssets(derivedRepaidShares, marketState, 'Up');
+ }
+
+ return 0n;
+}
+
/**
* Simulates market state changes based on supply and borrow deltas.
*