Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 28 additions & 1 deletion src/features/market-detail/components/borrowers-table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -25,7 +27,9 @@ type BorrowersTableProps = {

export function BorrowersTable({ chainId, market, minShares, oraclePrice, onOpenFiltersModal }: BorrowersTableProps) {
const [currentPage, setCurrentPage] = useState(1);
const [liquidateBorrower, setLiquidateBorrower] = useState<Address | null>(null);
const pageSize = 10;
const { showDeveloperOptions } = useAppSettings();

const { data: paginatedData, isLoading, isFetching } = useMarketBorrowers(market?.uniqueKey, chainId, minShares, currentPage, pageSize);

Expand Down Expand Up @@ -117,13 +121,14 @@ export function BorrowersTable({ chainId, market, minShares, oraclePrice, onOpen
<TableHead className="text-right">COLLATERAL</TableHead>
<TableHead className="text-right">LTV</TableHead>
<TableHead className="text-right">% OF BORROW</TableHead>
{showDeveloperOptions && <TableHead className="text-right">ACTIONS</TableHead>}
</TableRow>
</TableHeader>
<TableBody className="table-body-compact">
{borrowersWithLTV.length === 0 && !isLoading ? (
<TableRow>
<TableCell
colSpan={5}
colSpan={showDeveloperOptions ? 6 : 5}
className="text-center text-gray-400"
>
No borrowers found for this market
Expand Down Expand Up @@ -176,6 +181,17 @@ export function BorrowersTable({ chainId, market, minShares, oraclePrice, onOpen
</TableCell>
<TableCell className="text-right text-sm">{borrower.ltv.toFixed(2)}%</TableCell>
<TableCell className="text-right text-sm">{percentDisplay}</TableCell>
{showDeveloperOptions && (
<TableCell className="text-right">
<Button
variant="default"
size="xs"
onClick={() => setLiquidateBorrower(borrower.userAddress as Address)}
>
Liquidate
</Button>
</TableCell>
)}
</TableRow>
);
})
Expand All @@ -195,6 +211,17 @@ export function BorrowersTable({ chainId, market, minShares, oraclePrice, onOpen
isLoading={isFetching}
/>
)}

{liquidateBorrower && (
<LiquidateModal
market={market}
borrower={liquidateBorrower}
oraclePrice={oraclePrice}
onOpenChange={(open) => {
if (!open) setLiquidateBorrower(null);
}}
/>
)}
</div>
);
}
114 changes: 114 additions & 0 deletions src/hooks/useLiquidateTransaction.ts
Original file line number Diff line number Diff line change
@@ -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',
Comment thread
antoncoding marked this conversation as resolved.
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 };
}
Loading