From e1e15384037be114c5865e6091e029b1a95c85ba Mon Sep 17 00:00:00 2001 From: antoncoding Date: Thu, 19 Feb 2026 18:01:59 +0800 Subject: [PATCH 1/8] feat: liquidation temp --- .../components/borrowers-table.tsx | 28 ++++ src/hooks/useLiquidateTransaction.ts | 71 +++++++++ .../components/liquidate-modal-content.tsx | 145 ++++++++++++++++++ src/modals/liquidate/liquidate-modal.tsx | 93 +++++++++++ 4 files changed, 337 insertions(+) create mode 100644 src/hooks/useLiquidateTransaction.ts create mode 100644 src/modals/liquidate/components/liquidate-modal-content.tsx create mode 100644 src/modals/liquidate/liquidate-modal.tsx diff --git a/src/features/market-detail/components/borrowers-table.tsx b/src/features/market-detail/components/borrowers-table.tsx index a4d088e1..4d14645f 100644 --- a/src/features/market-detail/components/borrowers-table.tsx +++ b/src/features/market-detail/components/borrowers-table.tsx @@ -2,6 +2,7 @@ import { useState, useMemo } from 'react'; import { Tooltip } from '@/components/ui/tooltip'; import { Table, TableHeader, TableBody, TableRow, TableCell, TableHead } from '@/components/ui/table'; import { GoFilter } from 'react-icons/go'; +import { LuZap } from 'react-icons/lu'; import type { Address } from 'viem'; import { formatUnits } from 'viem'; import { Button } from '@/components/ui/button'; @@ -10,10 +11,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 +28,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,6 +122,7 @@ export function BorrowersTable({ chainId, market, minShares, oraclePrice, onOpen COLLATERAL LTV % OF BORROW + {showDeveloperOptions && ACTIONS} @@ -176,6 +182,18 @@ export function BorrowersTable({ chainId, market, minShares, oraclePrice, onOpen {borrower.ltv.toFixed(2)}% {percentDisplay} + {showDeveloperOptions && ( + + + + )} ); }) @@ -195,6 +213,16 @@ 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..1b6084b7 --- /dev/null +++ b/src/hooks/useLiquidateTransaction.ts @@ -0,0 +1,71 @@ +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; + onSuccess?: () => void; +}; + +export function useLiquidateTransaction({ market, borrower, seizedAssets, repaidShares, onSuccess }: UseLiquidateTransactionProps) { + const { address: account, chainId } = useConnection(); + + const tracking = useTransactionTracking('liquidate'); + const morphoAddress = getMorphoAddress(chainId); + + const { isApproved, approve } = useERC20Approval({ + token: market.loanAsset.address as Address, + spender: morphoAddress, + amount: 0n, + tokenSymbol: market.loanAsset.symbol, + }); + + 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) return; + + 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, seizedAssets, repaidShares, '0x'], + }); + + await sendTransactionAsync({ to: morphoAddress, data: liquidateTx }); + }, [account, chainId, market, borrower, seizedAssets, repaidShares, morphoAddress, sendTransactionAsync]); + + const handleLiquidate = useCallback(async () => { + if (!isApproved) await approve(); + await liquidate(); + }, [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..23525476 --- /dev/null +++ b/src/modals/liquidate/components/liquidate-modal-content.tsx @@ -0,0 +1,145 @@ +import { useState, useEffect, useCallback } from 'react'; +import Input from '@/components/Input/Input'; +import { useLiquidateTransaction } from '@/hooks/useLiquidateTransaction'; +import { formatBalance } from '@/utils/balance'; +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; + borrowerDebt: bigint; + onSuccess?: () => void; +}; + +export function LiquidateModalContent({ + market, + borrower, + borrowerCollateral, + borrowerDebt, + onSuccess, +}: LiquidateModalContentProps): JSX.Element { + const [seizedAssets, setSeizedAssets] = useState(BigInt(0)); + const [repaidShares, setRepaidShares] = useState(BigInt(0)); + const [inputError, setInputError] = useState(null); + + const { liquidatePending, handleLiquidate } = useLiquidateTransaction({ + market, + borrower, + seizedAssets, + repaidShares, + onSuccess, + }); + + const handleSetMax = useCallback(() => { + setRepaidShares(borrowerDebt); + setSeizedAssets(BigInt(0)); + }, [borrowerDebt]); + + useEffect(() => { + if (repaidShares !== BigInt(0) && repaidShares !== borrowerDebt) { + setRepaidShares(BigInt(0)); + } + }, [seizedAssets, borrowerDebt]); + + const handleSeizedAssetsChange = useCallback((value: bigint) => { + setSeizedAssets(value); + setRepaidShares(BigInt(0)); + setInputError(null); + }, []); + + const handleRepaidSharesChange = useCallback((value: bigint) => { + if (value > 0n) { + setRepaidShares(value); + setSeizedAssets(BigInt(0)); + setInputError(null); + } + }, []); + + const isValid = seizedAssets > 0n || repaidShares > 0n; + + return ( +
+
+
Borrower
+ +
+ +
+
+
Debt
+
+ {formatBalance(borrowerDebt, market.loanAsset.decimals)} + +
+
+
+
Collateral
+
+ {formatBalance(borrowerCollateral, market.collateralAsset.decimals)} + +
+
+
+ +
+
+ Seize Collateral (Assets) + +
+ +
+ +
+ Or Repay Debt (Shares) + +
+ + + Liquidate + +
+ ); +} diff --git a/src/modals/liquidate/liquidate-modal.tsx b/src/modals/liquidate/liquidate-modal.tsx new file mode 100644 index 00000000..67da1398 --- /dev/null +++ b/src/modals/liquidate/liquidate-modal.tsx @@ -0,0 +1,93 @@ +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 type { Address } from 'viem'; + +type LiquidateModalProps = { + market: Market; + borrower: Address; + onOpenChange: (open: boolean) => void; +}; + +export function LiquidateModal({ market, borrower, onOpenChange }: LiquidateModalProps): JSX.Element { + const { data: borrowerPosition } = useReadContract({ + address: market.morphoBlue.address as `0x${string}`, + functionName: 'position', + args: [borrower, market.uniqueKey as `0x${string}`], + abi: [ + { + inputs: [ + { internalType: 'address', name: 'user', type: 'address' }, + { internalType: 'Id', name: 'id', type: 'bytes32' }, + ], + name: 'position', + outputs: [ + { internalType: 'uint256', name: 'supplyShares', type: 'uint256' }, + { internalType: 'uint256', name: 'borrowShares', type: 'uint256' }, + { internalType: 'uint256', name: 'collateral', type: 'uint256' }, + ], + stateMutability: 'view', + type: 'function', + }, + ], + chainId: market.morphoBlue.chain.id, + query: { + enabled: !!borrower, + }, + }); + + const borrowerCollateral = borrowerPosition ? BigInt(borrowerPosition[2]) : 0n; + const borrowerDebt = borrowerPosition ? BigInt(borrowerPosition[1]) : 0n; + + const mainIcon = ( +
+ +
+ +
+
+ ); + + return ( + + onOpenChange(false)} + title={ +
+ {market.loanAsset.symbol} + / {market.collateralAsset.symbol} +
+ } + description="Liquidate a underwater position" + /> + + onOpenChange(false)} + /> + +
+ ); +} From 9907b9112c058ad4b1993e1f3e3fdc7a78d256d7 Mon Sep 17 00:00:00 2001 From: antoncoding Date: Thu, 19 Feb 2026 20:54:22 +0800 Subject: [PATCH 2/8] feat: ui --- .../components/borrowers-table.tsx | 8 +- src/hooks/useLiquidateTransaction.ts | 9 +- .../components/liquidate-modal-content.tsx | 185 +++++++++++------- src/modals/liquidate/liquidate-modal.tsx | 41 ++-- 4 files changed, 142 insertions(+), 101 deletions(-) diff --git a/src/features/market-detail/components/borrowers-table.tsx b/src/features/market-detail/components/borrowers-table.tsx index 4d14645f..94992972 100644 --- a/src/features/market-detail/components/borrowers-table.tsx +++ b/src/features/market-detail/components/borrowers-table.tsx @@ -2,7 +2,6 @@ import { useState, useMemo } from 'react'; import { Tooltip } from '@/components/ui/tooltip'; import { Table, TableHeader, TableBody, TableRow, TableCell, TableHead } from '@/components/ui/table'; import { GoFilter } from 'react-icons/go'; -import { LuZap } from 'react-icons/lu'; import type { Address } from 'viem'; import { formatUnits } from 'viem'; import { Button } from '@/components/ui/button'; @@ -185,12 +184,11 @@ export function BorrowersTable({ chainId, market, minShares, oraclePrice, onOpen {showDeveloperOptions && ( )} diff --git a/src/hooks/useLiquidateTransaction.ts b/src/hooks/useLiquidateTransaction.ts index 1b6084b7..e3884f9e 100644 --- a/src/hooks/useLiquidateTransaction.ts +++ b/src/hooks/useLiquidateTransaction.ts @@ -21,13 +21,14 @@ export function useLiquidateTransaction({ market, borrower, seizedAssets, repaid const { address: account, chainId } = useConnection(); const tracking = useTransactionTracking('liquidate'); - const morphoAddress = getMorphoAddress(chainId); + const morphoAddress = chainId ? getMorphoAddress(chainId) : undefined; const { isApproved, approve } = useERC20Approval({ token: market.loanAsset.address as Address, - spender: morphoAddress, + spender: morphoAddress ?? '0x', amount: 0n, tokenSymbol: market.loanAsset.symbol, + chainId, }); const { isConfirming: liquidatePending, sendTransactionAsync } = useTransactionWithToast({ @@ -43,7 +44,7 @@ export function useLiquidateTransaction({ market, borrower, seizedAssets, repaid }); const liquidate = useCallback(async () => { - if (!account || !chainId) return; + if (!account || !chainId || !morphoAddress) return; const marketParams = { loanToken: market.loanAsset.address as `0x${string}`, @@ -59,7 +60,7 @@ export function useLiquidateTransaction({ market, borrower, seizedAssets, repaid args: [marketParams, borrower, seizedAssets, repaidShares, '0x'], }); - await sendTransactionAsync({ to: morphoAddress, data: liquidateTx }); + await sendTransactionAsync({ to: morphoAddress as Address, data: liquidateTx }); }, [account, chainId, market, borrower, seizedAssets, repaidShares, morphoAddress, sendTransactionAsync]); const handleLiquidate = useCallback(async () => { diff --git a/src/modals/liquidate/components/liquidate-modal-content.tsx b/src/modals/liquidate/components/liquidate-modal-content.tsx index 23525476..34845764 100644 --- a/src/modals/liquidate/components/liquidate-modal-content.tsx +++ b/src/modals/liquidate/components/liquidate-modal-content.tsx @@ -1,7 +1,8 @@ -import { useState, useEffect, useCallback } from 'react'; +import { useState, useCallback } from 'react'; +import { RefetchIcon } from '@/components/ui/refetch-icon'; import Input from '@/components/Input/Input'; import { useLiquidateTransaction } from '@/hooks/useLiquidateTransaction'; -import { formatBalance } from '@/utils/balance'; +import { formatBalance, formatReadable } from '@/utils/balance'; import type { Market } from '@/utils/types'; import { TokenIcon } from '@/components/shared/token-icon'; import { ExecuteTransactionButton } from '@/components/ui/ExecuteTransactionButton'; @@ -12,20 +13,46 @@ type LiquidateModalContentProps = { market: Market; borrower: Address; borrowerCollateral: bigint; - borrowerDebt: bigint; + borrowerBorrowShares: bigint; + borrowerSupplyShares: bigint; onSuccess?: () => void; + onRefresh?: () => void; + isLoading?: boolean; }; export function LiquidateModalContent({ market, borrower, borrowerCollateral, - borrowerDebt, + borrowerBorrowShares, onSuccess, + onRefresh, + isLoading, }: LiquidateModalContentProps): JSX.Element { - const [seizedAssets, setSeizedAssets] = useState(BigInt(0)); - const [repaidShares, setRepaidShares] = useState(BigInt(0)); + const [repayAmount, setRepayAmount] = 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; + + const seizedAssets = useMaxShares ? BigInt(0) : repayAmount; + const repaidShares = useMaxShares ? borrowerBorrowShares : BigInt(0); const { liquidatePending, handleLiquidate } = useLiquidateTransaction({ market, @@ -35,37 +62,50 @@ export function LiquidateModalContent({ onSuccess, }); - const handleSetMax = useCallback(() => { - setRepaidShares(borrowerDebt); - setSeizedAssets(BigInt(0)); - }, [borrowerDebt]); + const handleMaxClick = useCallback(() => { + setUseMaxShares(true); + setRepayAmount(borrowerDebtInAssets); + }, [borrowerDebtInAssets]); - useEffect(() => { - if (repaidShares !== BigInt(0) && repaidShares !== borrowerDebt) { - setRepaidShares(BigInt(0)); - } - }, [seizedAssets, borrowerDebt]); - - const handleSeizedAssetsChange = useCallback((value: bigint) => { - setSeizedAssets(value); - setRepaidShares(BigInt(0)); + const handleInputChange = useCallback((value: bigint) => { + setRepayAmount(value); + setUseMaxShares(false); setInputError(null); }, []); - const handleRepaidSharesChange = useCallback((value: bigint) => { - if (value > 0n) { - setRepaidShares(value); - setSeizedAssets(BigInt(0)); - setInputError(null); + const handleRefresh = useCallback(async () => { + if (!onRefresh) return; + setIsRefreshing(true); + try { + onRefresh(); + } finally { + setTimeout(() => setIsRefreshing(false), 500); } - }, []); + }, [onRefresh]); - const isValid = seizedAssets > 0n || repaidShares > 0n; + const isValid = repayAmount > 0n || useMaxShares; + const hasBorrowPosition = borrowerBorrowShares > 0n; return (
-
-
Borrower
+
+
+
Borrower
+ {onRefresh && ( + + )} +
-
-
Debt
-
- {formatBalance(borrowerDebt, market.loanAsset.decimals)} +
+
Debt
+
+ {formatReadable(formatBalance(borrowerDebtInAssets, market.loanAsset.decimals))} + {borrowerDebtUsd > 0 && ${borrowerDebtUsd.toFixed(2)}}
-
-
Collateral
-
- {formatBalance(borrowerCollateral, market.collateralAsset.decimals)} +
+
Collateral
+
+ {formatReadable(formatBalance(borrowerCollateral, market.collateralAsset.decimals))} + {borrowerCollateralUsd > 0 && ${borrowerCollateralUsd.toFixed(2)}}
-
-
- Seize Collateral (Assets) - + {hasBorrowPosition && ( +
+
+ {useMaxShares ? 'Liquidate max' : 'Repay amount'} + +
+
- -
+ )} -
- Or Repay Debt (Shares) - -
+ {!hasBorrowPosition && borrowerBorrowShares === 0n && borrowerCollateral === 0n && ( +
+ {isLoading ? 'Loading position data...' : 'No position found for this borrower'} +
+ )} - - Liquidate - +
+ + Liquidate + +
); } diff --git a/src/modals/liquidate/liquidate-modal.tsx b/src/modals/liquidate/liquidate-modal.tsx index 67da1398..e93d4e2b 100644 --- a/src/modals/liquidate/liquidate-modal.tsx +++ b/src/modals/liquidate/liquidate-modal.tsx @@ -3,6 +3,7 @@ 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 = { @@ -12,34 +13,25 @@ type LiquidateModalProps = { }; export function LiquidateModal({ market, borrower, onOpenChange }: LiquidateModalProps): JSX.Element { - const { data: borrowerPosition } = useReadContract({ + const { + data: borrowerPosition, + refetch: refetchBorrowerPosition, + isLoading: isBorrowerPositionLoading, + } = useReadContract({ address: market.morphoBlue.address as `0x${string}`, functionName: 'position', - args: [borrower, market.uniqueKey as `0x${string}`], - abi: [ - { - inputs: [ - { internalType: 'address', name: 'user', type: 'address' }, - { internalType: 'Id', name: 'id', type: 'bytes32' }, - ], - name: 'position', - outputs: [ - { internalType: 'uint256', name: 'supplyShares', type: 'uint256' }, - { internalType: 'uint256', name: 'borrowShares', type: 'uint256' }, - { internalType: 'uint256', name: 'collateral', type: 'uint256' }, - ], - stateMutability: 'view', - type: 'function', - }, - ], + args: [market.uniqueKey as `0x${string}`, borrower as `0x${string}`], + abi: morphoAbi, chainId: market.morphoBlue.chain.id, query: { - enabled: !!borrower, + enabled: !!borrower && !!market.morphoBlue.address && !!market.uniqueKey, + refetchInterval: 10_000, }, }); const borrowerCollateral = borrowerPosition ? BigInt(borrowerPosition[2]) : 0n; - const borrowerDebt = borrowerPosition ? BigInt(borrowerPosition[1]) : 0n; + const borrowerBorrowShares = borrowerPosition ? BigInt(borrowerPosition[1]) : 0n; + const borrowerSupplyShares = borrowerPosition ? BigInt(borrowerPosition[0]) : 0n; const mainIcon = (
@@ -62,6 +54,10 @@ export function LiquidateModal({ market, borrower, onOpenChange }: LiquidateModa
); + const handleRefetch = () => { + void refetchBorrowerPosition(); + }; + return ( onOpenChange(false)} + onRefresh={handleRefetch} + isLoading={isBorrowerPositionLoading} /> From 67a025cb0524a6f93a8acbb4d7b88a1512c68861 Mon Sep 17 00:00:00 2001 From: antoncoding Date: Thu, 19 Feb 2026 21:10:05 +0800 Subject: [PATCH 3/8] chore: review fix --- .../liquidate/components/liquidate-modal-content.tsx | 11 +++++++---- src/modals/liquidate/liquidate-modal.tsx | 2 +- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/modals/liquidate/components/liquidate-modal-content.tsx b/src/modals/liquidate/components/liquidate-modal-content.tsx index 34845764..f9f34a28 100644 --- a/src/modals/liquidate/components/liquidate-modal-content.tsx +++ b/src/modals/liquidate/components/liquidate-modal-content.tsx @@ -51,6 +51,8 @@ export function LiquidateModalContent({ ? (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) : repayAmount; const repaidShares = useMaxShares ? borrowerBorrowShares : BigInt(0); @@ -64,8 +66,8 @@ export function LiquidateModalContent({ const handleMaxClick = useCallback(() => { setUseMaxShares(true); - setRepayAmount(borrowerDebtInAssets); - }, [borrowerDebtInAssets]); + setRepayAmount(borrowerCollateral); + }, [borrowerCollateral]); const handleInputChange = useCallback((value: bigint) => { setRepayAmount(value); @@ -147,7 +149,7 @@ export function LiquidateModalContent({ {hasBorrowPosition && (
- {useMaxShares ? 'Liquidate max' : 'Repay amount'} + Collateral to seize
/ {market.collateralAsset.symbol}
} - description="Liquidate a underwater position" + description="Liquidate an underwater position" /> Date: Thu, 19 Feb 2026 21:26:12 +0800 Subject: [PATCH 4/8] chore: fix review --- src/hooks/useLiquidateTransaction.ts | 15 +++++++++-- .../components/liquidate-modal-content.tsx | 25 ++++++++++++++++++- src/modals/liquidate/liquidate-modal.tsx | 2 -- 3 files changed, 37 insertions(+), 5 deletions(-) diff --git a/src/hooks/useLiquidateTransaction.ts b/src/hooks/useLiquidateTransaction.ts index e3884f9e..0226399f 100644 --- a/src/hooks/useLiquidateTransaction.ts +++ b/src/hooks/useLiquidateTransaction.ts @@ -14,19 +14,30 @@ type UseLiquidateTransactionProps = { borrower: Address; seizedAssets: bigint; repaidShares: bigint; + repayAmount: bigint; // loan token amount for approval onSuccess?: () => void; }; -export function useLiquidateTransaction({ market, borrower, seizedAssets, repaidShares, onSuccess }: UseLiquidateTransactionProps) { +export function useLiquidateTransaction({ + market, + borrower, + seizedAssets, + repaidShares, + repayAmount, + onSuccess, +}: UseLiquidateTransactionProps) { const { address: account, chainId } = useConnection(); const tracking = useTransactionTracking('liquidate'); const morphoAddress = chainId ? getMorphoAddress(chainId) : undefined; + // Approve the loan token amount needed for liquidation + const approvalAmount = repaidShares > 0n ? repayAmount : 0n; + const { isApproved, approve } = useERC20Approval({ token: market.loanAsset.address as Address, spender: morphoAddress ?? '0x', - amount: 0n, + amount: approvalAmount, tokenSymbol: market.loanAsset.symbol, chainId, }); diff --git a/src/modals/liquidate/components/liquidate-modal-content.tsx b/src/modals/liquidate/components/liquidate-modal-content.tsx index f9f34a28..4a1d19d6 100644 --- a/src/modals/liquidate/components/liquidate-modal-content.tsx +++ b/src/modals/liquidate/components/liquidate-modal-content.tsx @@ -14,7 +14,6 @@ type LiquidateModalContentProps = { borrower: Address; borrowerCollateral: bigint; borrowerBorrowShares: bigint; - borrowerSupplyShares: bigint; onSuccess?: () => void; onRefresh?: () => void; isLoading?: boolean; @@ -56,11 +55,35 @@ export function LiquidateModalContent({ const seizedAssets = useMaxShares ? BigInt(0) : repayAmount; const repaidShares = useMaxShares ? borrowerBorrowShares : BigInt(0); + // Calculate loan token amount for approval + // Convert collateral to loan token equivalent using USD values + let loanAmountForApproval: bigint; + if (useMaxShares) { + // Max: approve the full debt amount in loan tokens + loanAmountForApproval = borrowerDebtInAssets; + } else if ( + seizedAssets > 0n && + market.state.collateralAssetsUsd != null && + market.state.borrowAssetsUsd > 0 && + Number(market.state.collateralAssets) > 0 && + Number(market.state.borrowAssets) > 0 + ) { + // Convert collateral amount to loan token amount using USD exchange rate + const collateralUsdPerToken = market.state.collateralAssetsUsd / Number(market.state.collateralAssets); + const loanUsdPerToken = market.state.borrowAssetsUsd / Number(market.state.borrowAssets); + const collateralUsd = Number(seizedAssets) * collateralUsdPerToken; + const loanTokens = collateralUsd / loanUsdPerToken; + loanAmountForApproval = BigInt(Math.floor(loanTokens * 10 ** market.loanAsset.decimals)); + } else { + loanAmountForApproval = 0n; + } + const { liquidatePending, handleLiquidate } = useLiquidateTransaction({ market, borrower, seizedAssets, repaidShares, + repayAmount: loanAmountForApproval, onSuccess, }); diff --git a/src/modals/liquidate/liquidate-modal.tsx b/src/modals/liquidate/liquidate-modal.tsx index 886ee2b9..df8eec9c 100644 --- a/src/modals/liquidate/liquidate-modal.tsx +++ b/src/modals/liquidate/liquidate-modal.tsx @@ -31,7 +31,6 @@ export function LiquidateModal({ market, borrower, onOpenChange }: LiquidateModa const borrowerCollateral = borrowerPosition ? BigInt(borrowerPosition[2]) : 0n; const borrowerBorrowShares = borrowerPosition ? BigInt(borrowerPosition[1]) : 0n; - const borrowerSupplyShares = borrowerPosition ? BigInt(borrowerPosition[0]) : 0n; const mainIcon = (
@@ -81,7 +80,6 @@ export function LiquidateModal({ market, borrower, onOpenChange }: LiquidateModa borrower={borrower} borrowerCollateral={borrowerCollateral} borrowerBorrowShares={borrowerBorrowShares} - borrowerSupplyShares={borrowerSupplyShares} onSuccess={() => onOpenChange(false)} onRefresh={handleRefetch} isLoading={isBorrowerPositionLoading} From 4bf45a164e44a1c9945766d230e6d6f6506f46e5 Mon Sep 17 00:00:00 2001 From: antoncoding Date: Thu, 19 Feb 2026 21:57:19 +0800 Subject: [PATCH 5/8] chore: liquidation --- src/hooks/useLiquidateTransaction.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/hooks/useLiquidateTransaction.ts b/src/hooks/useLiquidateTransaction.ts index 0226399f..973504a8 100644 --- a/src/hooks/useLiquidateTransaction.ts +++ b/src/hooks/useLiquidateTransaction.ts @@ -31,8 +31,10 @@ export function useLiquidateTransaction({ const tracking = useTransactionTracking('liquidate'); const morphoAddress = chainId ? getMorphoAddress(chainId) : undefined; - // Approve the loan token amount needed for liquidation - const approvalAmount = repaidShares > 0n ? repayAmount : 0n; + // Liquidation repays debt in both modes: + // - repaidShares > 0 (max/share-based) + // - seizedAssets > 0 (asset-based) + const approvalAmount = repaidShares > 0n || seizedAssets > 0n ? repayAmount : 0n; const { isApproved, approve } = useERC20Approval({ token: market.loanAsset.address as Address, From 698b8d5e58c820a6d513fe66e7fb19bc26e2af78 Mon Sep 17 00:00:00 2001 From: antoncoding Date: Thu, 19 Feb 2026 21:59:47 +0800 Subject: [PATCH 6/8] chore: rewview fixes --- src/hooks/useLiquidateTransaction.ts | 29 ++++++++++++++++++++++++---- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/src/hooks/useLiquidateTransaction.ts b/src/hooks/useLiquidateTransaction.ts index 973504a8..214cbc2a 100644 --- a/src/hooks/useLiquidateTransaction.ts +++ b/src/hooks/useLiquidateTransaction.ts @@ -30,11 +30,14 @@ export function useLiquidateTransaction({ 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 = repaidShares > 0n || seizedAssets > 0n ? repayAmount : 0n; + const approvalAmount = hasExactlyOneLiquidationMode ? repayAmount : 0n; const { isApproved, approve } = useERC20Approval({ token: market.loanAsset.address as Address, @@ -58,6 +61,9 @@ export function useLiquidateTransaction({ 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}`, @@ -70,16 +76,31 @@ export function useLiquidateTransaction({ const liquidateTx = encodeFunctionData({ abi: morphoAbi, functionName: 'liquidate', - args: [marketParams, borrower, seizedAssets, repaidShares, '0x'], + args: [marketParams, borrower, hasSeizedAssets ? seizedAssets : 0n, hasRepaidShares ? repaidShares : 0n, '0x'], }); await sendTransactionAsync({ to: morphoAddress as Address, data: liquidateTx }); - }, [account, chainId, market, borrower, seizedAssets, repaidShares, morphoAddress, sendTransactionAsync]); + }, [ + 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(); - }, [isApproved, approve, liquidate]); + }, [hasExactlyOneLiquidationMode, isApproved, approve, liquidate]); return { liquidatePending, liquidate, handleLiquidate }; } From 58127e28539821d802b5498184b45d1e92ab066e Mon Sep 17 00:00:00 2001 From: antoncoding Date: Thu, 19 Feb 2026 22:30:23 +0800 Subject: [PATCH 7/8] feat: estimate repay --- .../components/borrowers-table.tsx | 1 + src/hooks/useLiquidateTransaction.ts | 14 ++- .../components/liquidate-modal-content.tsx | 92 +++++++++++-------- src/modals/liquidate/liquidate-modal.tsx | 4 +- src/utils/morpho.ts | 40 +++++++- 5 files changed, 107 insertions(+), 44 deletions(-) diff --git a/src/features/market-detail/components/borrowers-table.tsx b/src/features/market-detail/components/borrowers-table.tsx index 94992972..88f25add 100644 --- a/src/features/market-detail/components/borrowers-table.tsx +++ b/src/features/market-detail/components/borrowers-table.tsx @@ -216,6 +216,7 @@ export function BorrowersTable({ chainId, market, minShares, oraclePrice, onOpen { if (!open) setLiquidateBorrower(null); }} diff --git a/src/hooks/useLiquidateTransaction.ts b/src/hooks/useLiquidateTransaction.ts index 214cbc2a..3d21ed43 100644 --- a/src/hooks/useLiquidateTransaction.ts +++ b/src/hooks/useLiquidateTransaction.ts @@ -14,16 +14,24 @@ type UseLiquidateTransactionProps = { borrower: Address; seizedAssets: bigint; repaidShares: bigint; - repayAmount: bigint; // loan token amount for approval + 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, - repayAmount, + estimatedRepaidAmount, onSuccess, }: UseLiquidateTransactionProps) { const { address: account, chainId } = useConnection(); @@ -37,7 +45,7 @@ export function useLiquidateTransaction({ // Liquidation repays debt in both modes: // - repaidShares > 0 (max/share-based) // - seizedAssets > 0 (asset-based) - const approvalAmount = hasExactlyOneLiquidationMode ? repayAmount : 0n; + const approvalAmount = hasExactlyOneLiquidationMode ? addBufferBpsUp(estimatedRepaidAmount, APPROVAL_BUFFER_BPS) : 0n; const { isApproved, approve } = useERC20Approval({ token: market.loanAsset.address as Address, diff --git a/src/modals/liquidate/components/liquidate-modal-content.tsx b/src/modals/liquidate/components/liquidate-modal-content.tsx index 4a1d19d6..4f061a4b 100644 --- a/src/modals/liquidate/components/liquidate-modal-content.tsx +++ b/src/modals/liquidate/components/liquidate-modal-content.tsx @@ -1,8 +1,9 @@ -import { useState, useCallback } from 'react'; +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'; @@ -14,6 +15,7 @@ type LiquidateModalContentProps = { borrower: Address; borrowerCollateral: bigint; borrowerBorrowShares: bigint; + oraclePrice: bigint; onSuccess?: () => void; onRefresh?: () => void; isLoading?: boolean; @@ -24,11 +26,12 @@ export function LiquidateModalContent({ borrower, borrowerCollateral, borrowerBorrowShares, + oraclePrice, onSuccess, onRefresh, isLoading, }: LiquidateModalContentProps): JSX.Element { - const [repayAmount, setRepayAmount] = useState(BigInt(0)); + const [seizedCollateralAmount, setSeizedCollateralAmount] = useState(BigInt(0)); const [inputError, setInputError] = useState(null); const [useMaxShares, setUseMaxShares] = useState(false); const [isRefreshing, setIsRefreshing] = useState(false); @@ -52,48 +55,38 @@ export function LiquidateModalContent({ // seizedAssets is in collateral token units // When useMaxShares is true, we repay full debt (shares) and protocol calculates collateral - const seizedAssets = useMaxShares ? BigInt(0) : repayAmount; + const seizedAssets = useMaxShares ? BigInt(0) : seizedCollateralAmount; const repaidShares = useMaxShares ? borrowerBorrowShares : BigInt(0); - // Calculate loan token amount for approval - // Convert collateral to loan token equivalent using USD values - let loanAmountForApproval: bigint; - if (useMaxShares) { - // Max: approve the full debt amount in loan tokens - loanAmountForApproval = borrowerDebtInAssets; - } else if ( - seizedAssets > 0n && - market.state.collateralAssetsUsd != null && - market.state.borrowAssetsUsd > 0 && - Number(market.state.collateralAssets) > 0 && - Number(market.state.borrowAssets) > 0 - ) { - // Convert collateral amount to loan token amount using USD exchange rate - const collateralUsdPerToken = market.state.collateralAssetsUsd / Number(market.state.collateralAssets); - const loanUsdPerToken = market.state.borrowAssetsUsd / Number(market.state.borrowAssets); - const collateralUsd = Number(seizedAssets) * collateralUsdPerToken; - const loanTokens = collateralUsd / loanUsdPerToken; - loanAmountForApproval = BigInt(Math.floor(loanTokens * 10 ** market.loanAsset.decimals)); - } else { - loanAmountForApproval = 0n; - } + 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, - repayAmount: loanAmountForApproval, + estimatedRepaidAmount, onSuccess, }); const handleMaxClick = useCallback(() => { setUseMaxShares(true); - setRepayAmount(borrowerCollateral); - }, [borrowerCollateral]); + setInputError(null); + }, []); const handleInputChange = useCallback((value: bigint) => { - setRepayAmount(value); + setSeizedCollateralAmount(value); setUseMaxShares(false); setInputError(null); }, []); @@ -108,7 +101,7 @@ export function LiquidateModalContent({ } }, [onRefresh]); - const isValid = repayAmount > 0n || useMaxShares; + const isValid = (seizedCollateralAmount > 0n || useMaxShares) && (useMaxShares || oraclePrice > 0n); const hasBorrowPosition = borrowerBorrowShares > 0n; return ( @@ -171,27 +164,48 @@ export function LiquidateModalContent({ {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'} diff --git a/src/modals/liquidate/liquidate-modal.tsx b/src/modals/liquidate/liquidate-modal.tsx index df8eec9c..3aeba515 100644 --- a/src/modals/liquidate/liquidate-modal.tsx +++ b/src/modals/liquidate/liquidate-modal.tsx @@ -9,10 +9,11 @@ import type { Address } from 'viem'; type LiquidateModalProps = { market: Market; borrower: Address; + oraclePrice: bigint; onOpenChange: (open: boolean) => void; }; -export function LiquidateModal({ market, borrower, onOpenChange }: LiquidateModalProps): JSX.Element { +export function LiquidateModal({ market, borrower, oraclePrice, onOpenChange }: LiquidateModalProps): JSX.Element { const { data: borrowerPosition, refetch: refetchBorrowerPosition, @@ -80,6 +81,7 @@ export function LiquidateModal({ market, borrower, onOpenChange }: LiquidateModa borrower={borrower} borrowerCollateral={borrowerCollateral} borrowerBorrowShares={borrowerBorrowShares} + oraclePrice={oraclePrice} onSuccess={() => 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. * From d6e116a53380efa5a49e1b78777bfe19dd02019f Mon Sep 17 00:00:00 2001 From: antoncoding Date: Thu, 19 Feb 2026 22:44:47 +0800 Subject: [PATCH 8/8] chore: fix col span --- src/features/market-detail/components/borrowers-table.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/features/market-detail/components/borrowers-table.tsx b/src/features/market-detail/components/borrowers-table.tsx index 88f25add..1d5e306f 100644 --- a/src/features/market-detail/components/borrowers-table.tsx +++ b/src/features/market-detail/components/borrowers-table.tsx @@ -128,7 +128,7 @@ export function BorrowersTable({ chainId, market, minShares, oraclePrice, onOpen {borrowersWithLTV.length === 0 && !isLoading ? ( No borrowers found for this market