From b6e8634df98149c656af575e3176cfa974eace44 Mon Sep 17 00:00:00 2001 From: secretmemelocker Date: Sat, 17 Jan 2026 23:52:47 -0600 Subject: [PATCH 1/2] Add liqudate button on borrower table --- .../components/borrowers-table.tsx | 15 +- .../components/liquidate-button.tsx | 49 ++++ src/features/market-detail/market-view.tsx | 45 +--- src/hooks/useAccrueInterest.ts | 60 +++++ src/hooks/useLiquidateTransaction.ts | 214 ++++++++++++++++++ 5 files changed, 342 insertions(+), 41 deletions(-) create mode 100644 src/features/market-detail/components/liquidate-button.tsx create mode 100644 src/hooks/useAccrueInterest.ts create mode 100644 src/hooks/useLiquidateTransaction.ts diff --git a/src/features/market-detail/components/borrowers-table.tsx b/src/features/market-detail/components/borrowers-table.tsx index 57cdcb4d..5298bd5e 100644 --- a/src/features/market-detail/components/borrowers-table.tsx +++ b/src/features/market-detail/components/borrowers-table.tsx @@ -14,6 +14,7 @@ import { MONARCH_PRIMARY } from '@/constants/chartColors'; import { useMarketBorrowers } from '@/hooks/useMarketBorrowers'; import { formatSimple } from '@/utils/balance'; import type { Market } from '@/utils/types'; +import { LiquidateButton } from './liquidate-button'; type BorrowersTableProps = { chainId: number; @@ -21,9 +22,10 @@ type BorrowersTableProps = { minShares: string; oraclePrice: bigint; onOpenFiltersModal: () => void; + showLiquidateButton?: boolean; }; -export function BorrowersTable({ chainId, market, minShares, oraclePrice, onOpenFiltersModal }: BorrowersTableProps) { +export function BorrowersTable({ chainId, market, minShares, oraclePrice, onOpenFiltersModal, showLiquidateButton = true }: BorrowersTableProps) { const [currentPage, setCurrentPage] = useState(1); const pageSize = 10; @@ -117,13 +119,14 @@ export function BorrowersTable({ chainId, market, minShares, oraclePrice, onOpen COLLATERAL LTV % OF BORROW + {showLiquidateButton && ACTIONS} {borrowersWithLTV.length === 0 && !isLoading ? ( No borrowers found for this market @@ -175,6 +178,14 @@ export function BorrowersTable({ chainId, market, minShares, oraclePrice, onOpen {borrower.ltv.toFixed(2)}% {percentDisplay} + {showLiquidateButton && ( + + + + )} ); }) diff --git a/src/features/market-detail/components/liquidate-button.tsx b/src/features/market-detail/components/liquidate-button.tsx new file mode 100644 index 00000000..983ceea6 --- /dev/null +++ b/src/features/market-detail/components/liquidate-button.tsx @@ -0,0 +1,49 @@ +import { useCallback } from 'react'; +import { Button } from '@/components/ui/button'; +import { useLiquidateTransaction } from '@/hooks/useLiquidateTransaction'; +import type { Market } from '@/utils/types'; + +type Borrower = { + userAddress: string; + borrowAssets: string; + collateral: string; + ltv: number; +}; + +type LiquidateButtonProps = { + market: Market; + borrower: Borrower; + onSuccess?: () => void; +}; + +export function LiquidateButton({ market, borrower, onSuccess }: LiquidateButtonProps) { + // Seize all collateral - Morpho will calculate the appropriate repayment + const seizedAssets = BigInt(borrower.collateral); + + const { isApproved, isLoading, approveAndLiquidate, signAndLiquidate } = useLiquidateTransaction({ + market, + borrower, + seizedAssets, + onSuccess, + }); + + const handleClick = useCallback(() => { + if (!isApproved) { + void approveAndLiquidate(); + } else { + void signAndLiquidate(); + } + }, [isApproved, approveAndLiquidate, signAndLiquidate]); + + return ( + + ); +} diff --git a/src/features/market-detail/market-view.tsx b/src/features/market-detail/market-view.tsx index 8e6964e1..bd06a88f 100644 --- a/src/features/market-detail/market-view.tsx +++ b/src/features/market-detail/market-view.tsx @@ -4,9 +4,8 @@ import { useState, useCallback, useMemo } from 'react'; import { useParams } from 'next/navigation'; -import { parseUnits, formatUnits, type Address, encodeFunctionData } from 'viem'; -import { useConnection, useSwitchChain } from 'wagmi'; -import morphoAbi from '@/abis/morpho'; +import { parseUnits, formatUnits } from 'viem'; +import { useConnection } from 'wagmi'; import { BorrowModal } from '@/modals/borrow/borrow-modal'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Spinner } from '@/components/ui/spinner'; @@ -17,7 +16,7 @@ import { useOraclePrice } from '@/hooks/useOraclePrice'; import { useTransactionFilters } from '@/stores/useTransactionFilters'; import { useMarketDetailPreferences, type MarketDetailTab } from '@/stores/useMarketDetailPreferences'; import useUserPosition from '@/hooks/useUserPosition'; -import { useTransactionWithToast } from '@/hooks/useTransactionWithToast'; +import { useAccrueInterest } from '@/hooks/useAccrueInterest'; import type { SupportedNetworks } from '@/utils/networks'; import { BorrowersTable } from '@/features/market-detail/components/borrowers-table'; import { BorrowsTable } from '@/features/market-detail/components/borrows-table'; @@ -98,17 +97,9 @@ function MarketContent() { totalCount: suppliersTotalCount, } = useAllMarketSuppliers(market?.uniqueKey, network); - const { mutateAsync: switchChainAsync } = useSwitchChain(); - // Transaction hook for accruing interest - const { sendTransaction } = useTransactionWithToast({ - toastId: 'accrue-interest', - pendingText: 'Accruing Interest', - successText: 'Interest Accrued', - errorText: 'Failed to accrue interest', - chainId: market?.morphoBlue.chain.id, - pendingDescription: 'Updating market interest rates...', - successDescription: 'Market interest rates have been updated', + const { accrueInterest } = useAccrueInterest({ + market: market ?? undefined, onSuccess: () => { void refetchMarket(); }, @@ -265,30 +256,6 @@ function MarketContent() { setShowBorrowModal(true); }; - const handleAccrueInterest = async () => { - await switchChainAsync({ chainId: market.morphoBlue.chain.id }); - const morphoAddress = market.morphoBlue.address as Address; - - sendTransaction({ - to: morphoAddress, - account: address, - data: encodeFunctionData({ - abi: morphoAbi, - functionName: 'accrueInterest', - args: [ - { - loanToken: market.loanAsset.address as Address, - collateralToken: market.collateralAsset.address as Address, - oracle: market.oracleAddress as Address, - irm: market.irmAddress as Address, - lltv: BigInt(market.lltv), - }, - ], - }), - chainId: market.morphoBlue.chain.id, - }); - }; - return ( <>
@@ -303,7 +270,7 @@ function MarketContent() { allWarnings={allWarnings} onSupplyClick={handleSupplyClick} onBorrowClick={handleBorrowClick} - accrueInterest={handleAccrueInterest} + accrueInterest={accrueInterest} /> {showBorrowModal && ( diff --git a/src/hooks/useAccrueInterest.ts b/src/hooks/useAccrueInterest.ts new file mode 100644 index 00000000..316745f0 --- /dev/null +++ b/src/hooks/useAccrueInterest.ts @@ -0,0 +1,60 @@ +import { useCallback, useState } from 'react'; +import { type Address, encodeFunctionData } from 'viem'; +import { useConnection, useSwitchChain } from 'wagmi'; +import morphoAbi from '@/abis/morpho'; +import type { Market } from '@/utils/types'; +import { useTransactionWithToast } from './useTransactionWithToast'; +import { useStyledToast } from './useStyledToast'; + +type UseAccrueInterestProps = { + market: Market | undefined; + onSuccess?: () => void; +}; + +export function useAccrueInterest({ market, onSuccess }: UseAccrueInterestProps) { + const { address: account } = useConnection(); + const { mutateAsync: switchChainAsync } = useSwitchChain(); + + const { sendTransaction, isConfirming } = useTransactionWithToast({ + toastId: 'accrue-interest', + pendingText: 'Accruing Interest', + successText: 'Interest Accrued', + errorText: 'Failed to accrue interest', + chainId: market?.morphoBlue.chain.id, + pendingDescription: 'Accruing interest...', + successDescription: 'Interest has been accrued', + onSuccess, + }); + + const accrueInterest = useCallback(async () => { + if (!market) return; + + await switchChainAsync({ chainId: market.morphoBlue.chain.id }); + + const morphoAddress = market.morphoBlue.address as Address; + + sendTransaction({ + to: morphoAddress, + account, + data: encodeFunctionData({ + abi: morphoAbi, + functionName: 'accrueInterest', + args: [ + { + loanToken: market.loanAsset.address as Address, + collateralToken: market.collateralAsset.address as Address, + oracle: market.oracleAddress as Address, + irm: market.irmAddress as Address, + lltv: BigInt(market.lltv), + }, + ], + }), + chainId: market.morphoBlue.chain.id, + }); + }, [market, account, switchChainAsync, sendTransaction]); + + return { + accrueInterest, + isLoading: isConfirming, + }; +} diff --git a/src/hooks/useLiquidateTransaction.ts b/src/hooks/useLiquidateTransaction.ts new file mode 100644 index 00000000..ac61bc07 --- /dev/null +++ b/src/hooks/useLiquidateTransaction.ts @@ -0,0 +1,214 @@ +import { useCallback } from 'react'; +import { type Address, encodeFunctionData } from 'viem'; +import { useConnection, useSwitchChain } from 'wagmi'; +import morphoAbi from '@/abis/morpho'; +import type { Market } from '@/utils/types'; +import { useERC20Approval } from './useERC20Approval'; +import { useTransactionWithToast } from './useTransactionWithToast'; +import { useTransactionTracking } from './useTransactionTracking'; +import { useStyledToast } from './useStyledToast'; +import { formatBalance } from '@/utils/balance'; + +type Borrower = { + userAddress: string; + borrowAssets: string; + collateral: string; +}; + +type UseLiquidateTransactionProps = { + market: Market; + borrower: Borrower | null; + seizedAssets: bigint; + onSuccess?: () => void; +}; + +export function useLiquidateTransaction({ + market, + borrower, + seizedAssets, + onSuccess, +}: UseLiquidateTransactionProps) { + const { address: account } = useConnection(); + const { mutateAsync: switchChainAsync } = useSwitchChain(); + const toast = useStyledToast(); + const tracking = useTransactionTracking('liquidate'); + + const chainId = market.morphoBlue.chain.id; + const morphoAddress = market.morphoBlue.address as Address; + + // Estimate the max repay amount (borrowAssets + buffer for interest) + const maxRepayAmount = borrower ? (BigInt(borrower.borrowAssets) * 110n) / 100n : 0n; + + const { isApproved, approve, isApproving } = useERC20Approval({ + token: market.loanAsset.address as Address, + spender: morphoAddress, + amount: maxRepayAmount, + tokenSymbol: market.loanAsset.symbol, + chainId, + }); + + const { isConfirming, sendTransactionAsync } = useTransactionWithToast({ + toastId: 'liquidate', + pendingText: `Liquidating position`, + successText: 'Position Liquidated', + errorText: 'Failed to liquidate', + chainId, + pendingDescription: `Liquidating borrower ${borrower?.userAddress.slice(0, 8)}...`, + successDescription: `Successfully liquidated position`, + onSuccess, + }); + + const steps = [ + { id: 'approve', title: 'Approve Token', description: `Approve ${market.loanAsset.symbol} for liquidation` }, + { id: 'liquidating', title: 'Confirm Liquidation', description: 'Confirm transaction in wallet' }, + ]; + + + const executeLiquidation = useCallback(async () => { + if (!borrower || !account) return; + + await switchChainAsync({ chainId }); + + tracking.update('liquidating'); + + await sendTransactionAsync({ + account, + to: morphoAddress, + data: encodeFunctionData({ + abi: morphoAbi, + functionName: 'liquidate', + args: [ + { + loanToken: market.loanAsset.address as Address, + collateralToken: market.collateralAsset.address as Address, + oracle: market.oracleAddress as Address, + irm: market.irmAddress as Address, + lltv: BigInt(market.lltv), + }, + borrower.userAddress as Address, + seizedAssets, + 0n, // repaidShares = 0 since we're specifying seizedAssets + '0x', // callback data + ], + }), + }); + + tracking.complete(); + }, [ + borrower, + account, + switchChainAsync, + chainId, + sendTransactionAsync, + morphoAddress, + market, + seizedAssets, + tracking, + ]); + + const approveAndLiquidate = useCallback(async () => { + if (!account) { + toast.info('No account connected', 'Please connect your wallet to continue.'); + return; + } + + if (!borrower) { + toast.error('No borrower selected', 'Please select a borrower to liquidate.'); + return; + } + + await switchChainAsync({ chainId: market.morphoBlue.chain.id }); + + try { + tracking.start( + steps, + { + title: 'Liquidate Position', + description: `Liquidating ${formatBalance(seizedAssets, market.collateralAsset.decimals)} ${market.collateralAsset.symbol}`, + tokenSymbol: market.collateralAsset.symbol, + amount: seizedAssets, + marketId: market.uniqueKey, + }, + 'approve', + ); + + if (!isApproved) { + await approve(); + } + + await executeLiquidation(); + } catch (error: unknown) { + console.error('Error in liquidation:', error); + tracking.fail(); + if (error instanceof Error) { + if (error.message.includes('User rejected')) { + toast.error('Transaction rejected', 'Transaction rejected by user'); + } else if (error.message.includes('insufficient')) { + toast.error('Insufficient balance', 'You need more loan tokens to liquidate this position'); + } else { + toast.error('Liquidation failed', error.message); + } + } + } + }, [ + account, + borrower, + isApproved, + approve, + executeLiquidation, + toast, + tracking, + steps, + seizedAssets, + market, + ]); + + const signAndLiquidate = useCallback(async () => { + if (!account) { + toast.info('No account connected', 'Please connect your wallet to continue.'); + return; + } + + if (!borrower) { + toast.error('No borrower selected', 'Please select a borrower to liquidate.'); + return; + } + + try { + tracking.start( + [{ id: 'liquidating', title: 'Confirm Liquidation', description: 'Confirm transaction in wallet' }], + { + title: 'Liquidate Position', + description: `Liquidating ${formatBalance(seizedAssets, market.collateralAsset.decimals)} ${market.collateralAsset.symbol}`, + tokenSymbol: market.collateralAsset.symbol, + amount: seizedAssets, + marketId: market.uniqueKey, + }, + 'liquidating', + ); + + await executeLiquidation(); + } catch (error: unknown) { + console.error('Error in liquidation:', error); + tracking.fail(); + if (error instanceof Error) { + if (error.message.includes('User rejected')) { + toast.error('Transaction rejected', 'Transaction rejected by user'); + } else { + toast.error('Liquidation failed', error.message); + } + } + } + }, [account, borrower, executeLiquidation, toast, tracking, seizedAssets, market]); + + return { + transaction: tracking.transaction, + dismiss: tracking.dismiss, + isApproved, + isApproving, + isConfirming, + isLoading: isApproving || isConfirming, + approveAndLiquidate, + signAndLiquidate, + }; +} From 0ece3355abce2e69c7c376bf7da015057eaaeb55 Mon Sep 17 00:00:00 2001 From: antoncoding Date: Mon, 19 Jan 2026 00:01:50 +0800 Subject: [PATCH 2/2] chore: lint --- .../components/borrowers-table.tsx | 9 +++- .../components/liquidate-button.tsx | 8 ++-- src/features/market-detail/market-view.tsx | 2 +- src/hooks/useAccrueInterest.ts | 3 +- src/hooks/useLiquidateTransaction.ts | 41 ++++--------------- 5 files changed, 21 insertions(+), 42 deletions(-) diff --git a/src/features/market-detail/components/borrowers-table.tsx b/src/features/market-detail/components/borrowers-table.tsx index 5298bd5e..ec14606e 100644 --- a/src/features/market-detail/components/borrowers-table.tsx +++ b/src/features/market-detail/components/borrowers-table.tsx @@ -25,7 +25,14 @@ type BorrowersTableProps = { showLiquidateButton?: boolean; }; -export function BorrowersTable({ chainId, market, minShares, oraclePrice, onOpenFiltersModal, showLiquidateButton = true }: BorrowersTableProps) { +export function BorrowersTable({ + chainId, + market, + minShares, + oraclePrice, + onOpenFiltersModal, + showLiquidateButton = true, +}: BorrowersTableProps) { const [currentPage, setCurrentPage] = useState(1); const pageSize = 10; diff --git a/src/features/market-detail/components/liquidate-button.tsx b/src/features/market-detail/components/liquidate-button.tsx index 983ceea6..b1b673a3 100644 --- a/src/features/market-detail/components/liquidate-button.tsx +++ b/src/features/market-detail/components/liquidate-button.tsx @@ -28,16 +28,16 @@ export function LiquidateButton({ market, borrower, onSuccess }: LiquidateButton }); const handleClick = useCallback(() => { - if (!isApproved) { - void approveAndLiquidate(); - } else { + if (isApproved) { void signAndLiquidate(); + } else { + void approveAndLiquidate(); } }, [isApproved, approveAndLiquidate, signAndLiquidate]); return (