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. *