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
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import { MarketIdentity, MarketIdentityFocus, MarketIdentityMode } from '@/featu
import { useModal } from '@/hooks/useModal';
import { useRateLabel } from '@/hooks/useRateLabel';
import { usePositionsPreferences } from '@/stores/usePositionsPreferences';
import { formatReadable } from '@/utils/balance';
import { formatReadableTokenAmount } from '@/utils/balance';
import { buildBorrowPositionRows } from '@/utils/positions';
import { computeHealthScoreFromLtv, formatHealthScore } from '@/modals/borrow/components/helpers';
import type { MarketPositionWithEarnings } from '@/utils/types';
Expand Down Expand Up @@ -177,7 +177,7 @@ export function BorrowedMorphoBlueTable({ account, positions, onRefetch, isRefet
<div className="flex items-center justify-center gap-2">
{row.isActiveDebt ? (
<>
<span className="font-medium">{formatReadable(row.borrowAmount)}</span>
<span className="font-medium">{formatReadableTokenAmount(row.borrowAmount)}</span>
<span>{row.market.loanAsset.symbol}</span>
<TokenIcon
address={row.market.loanAsset.address}
Expand Down Expand Up @@ -210,7 +210,7 @@ export function BorrowedMorphoBlueTable({ account, positions, onRefetch, isRefet
<div className="flex items-center justify-center gap-2">
{row.collateralAmount > 0 ? (
<>
<span className="font-medium">{formatReadable(row.collateralAmount)}</span>
<span className="font-medium">{formatReadableTokenAmount(row.collateralAmount)}</span>
<span>{row.market.collateralAsset.symbol}</span>
<TokenIcon
address={row.market.collateralAsset.address}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,9 @@ import { useModalStore } from '@/stores/useModalStore';
import { useRateLabel } from '@/hooks/useRateLabel';
import { useStyledToast } from '@/hooks/useStyledToast';
import type { EarningsPeriod } from '@/hooks/useUserPositionsSummaryData';
import { formatReadable, formatBalance } from '@/utils/balance';
import { formatReadable, formatReadableTokenAmount } from '@/utils/balance';
import { computeAssetUsdValue, formatUsdValueDisplay } from '@/utils/assetDisplay';
import { formatTokenAmountPreview } from '@/utils/token-amount-format';
import { getNetworkImg } from '@/utils/networks';
import { getGroupedEarnings, groupPositionsByLoanAsset, processCollaterals } from '@/utils/positions';
import { convertApyToApr } from '@/utils/rateMath';
Expand Down Expand Up @@ -206,7 +208,7 @@ export function SuppliedMorphoBlueGroupedTable({
{/* Loan asset details */}
<TableCell data-label="Size">
<div className="flex items-center justify-center gap-2">
<span className="font-medium">{formatReadable(groupedPosition.totalSupply)}</span>
<span className="font-medium">{formatReadableTokenAmount(groupedPosition.totalSupply)}</span>
<span>{groupedPosition.loanAsset}</span>
<TokenIcon
address={groupedPosition.loanAssetAddress}
Expand Down Expand Up @@ -275,27 +277,38 @@ export function SuppliedMorphoBlueGroupedTable({

const startTimestamp = blockData.timestamp * 1000;
const endTimestamp = Date.now();
const earningsPreview = formatTokenAmountPreview(earnings, groupedPosition.loanAssetDecimals);
const exactTokenAmount = `${earningsPreview.full} ${groupedPosition.loanAsset}`;

return (
<TooltipContent
title="Interest Accrued Time Period"
detail={`Calculated from ${moment(Number(startTimestamp)).format('MMM D, YYYY HH:mm')} to ${moment(Number(endTimestamp)).format('MMM D, YYYY HH:mm')}`}
detail={
<div className="space-y-1">
<div>
Calculated from {moment(Number(startTimestamp)).format('MMM D, YYYY HH:mm')} to{' '}
{moment(Number(endTimestamp)).format('MMM D, YYYY HH:mm')}
</div>
<div>Exact amount: {exactTokenAmount}</div>
</div>
}
/>
);
})()}
>
<div className="cursor-help">
{(() => {
const earningsReadable = Number(formatBalance(earnings, groupedPosition.loanAssetDecimals));
const priceKey = getTokenPriceKey(groupedPosition.loanAssetAddress, groupedPosition.chainId);
const price = prices.get(priceKey);
const tokenAmount = `${formatReadable(earningsReadable)} ${groupedPosition.loanAsset}`;
const usdValue = price ? earningsReadable * price : null;
const earningsPreview = formatTokenAmountPreview(earnings, groupedPosition.loanAssetDecimals);
const tokenAmount = `${earningsPreview.compact} ${groupedPosition.loanAsset}`;
const usdValue = computeAssetUsdValue(earnings, groupedPosition.loanAssetDecimals, price ?? null);
const usdDisplay = usdValue == null ? null : formatUsdValueDisplay(usdValue);

if (showEarningsInUsd && usdValue !== null) {
if (showEarningsInUsd && usdDisplay !== null) {
return (
<div className="flex flex-col items-center gap-0 font-medium">
<span>${formatReadable(usdValue, usdValue < 1 ? 4 : 2)}</span>
<span>{usdDisplay.display}</span>
<span className="font-normal opacity-70">{tokenAmount}</span>
</div>
);
Expand Down
32 changes: 20 additions & 12 deletions src/hooks/deleverage/deleverageWithErc4626Redeem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,9 @@ type DeleverageWithErc4626RedeemParams = {
autoWithdrawCollateralAmount: bigint;
bundlerAddress: Address;
/**
* Exact market collateral-share amount to route through the repay/redeem leg.
* On full-close-by-shares this must come from the quote-derived close bound, not the raw input field.
* ERC4626 share amount routed through the unwind leg.
* - partial route: exact shares to redeem
* - full-close route: maximum shares the bundler may spend to withdraw the exact repayment assets
*/
collateralToRedeem: bigint;
ensureBundlerAuthorization: EnsureBundlerAuthorization;
Expand Down Expand Up @@ -82,11 +83,22 @@ export const deleverageWithErc4626Redeem = async ({
useRepayByShares: useCloseRoute,
});

// No swap slippage exists on the ERC4626 redeem path.
// This redeem leg must return at least the flash-loan settlement amount or the whole bundle
// would revert later during flash-loan repayment. Extra loan assets from the buffered close
// amount are swept back to the user, while any remaining collateral is withdrawn separately below.
const minLoanAssetsOut = flashLoanAmount;
const unwindVaultTx = useCloseRoute
? encodeFunctionData({
abi: morphoBundlerAbi,
functionName: 'erc4626Withdraw',
// Full close is the reverse of leverage's exact-asset deposit path: ask the vault for the
// exact buffered loan-asset amount needed to settle the flash loan, with a max-shares ceiling.
args: [route.collateralVault, flashLoanAmount, collateralToRedeem, bundlerAddress, bundlerAddress],
})
: encodeFunctionData({
abi: morphoBundlerAbi,
functionName: 'erc4626Redeem',
// Partial unwind keeps the user-selected share amount fixed and applies the slippage buffer as
// a minimum loan-asset floor derived from the redeem preview.
args: [route.collateralVault, collateralToRedeem, flashLoanAmount, bundlerAddress, bundlerAddress],
});

const callbackTxs: `0x${string}`[] = [
encodeFunctionData({
abi: morphoBundlerAbi,
Expand All @@ -106,11 +118,7 @@ export const deleverageWithErc4626Redeem = async ({
// Withdraw ERC4626 shares onto the bundler because the same bundler multicall redeems them immediately.
args: [marketParams, collateralToRedeem, bundlerAddress],
}),
encodeFunctionData({
abi: morphoBundlerAbi,
functionName: 'erc4626Redeem',
args: [route.collateralVault, collateralToRedeem, minLoanAssetsOut, bundlerAddress, bundlerAddress],
}),
unwindVaultTx,
];

if (autoWithdrawCollateralAmount > 0n) {
Expand Down
16 changes: 8 additions & 8 deletions src/hooks/leverage/leverageWithErc4626Deposit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,12 @@ type LeverageWithErc4626DepositParams = {
route: Erc4626LeverageRoute;
/** Exact user-entered starting capital, denominated by `isLoanAssetInput`. */
initialCapitalInputAmount: bigint;
/** Market collateral-token amount sourced from the initial capital before the flash leg. */
/** Minimum collateral shares accepted from depositing the initial capital before the flash leg. */
initialCapitalCollateralTokenAmount: bigint;
initialCapitalInputTokenAddress: Address;
initialCapitalTransferAmount: bigint;
isLoanAssetInput: boolean;
/** Minimum collateral shares accepted from depositing the flash-loaned assets. */
flashLegCollateralTokenAmount: bigint;
flashLoanAssetAmount: bigint; // amount to flashloan in loan asset
leverageFeeAmount: bigint;
Expand Down Expand Up @@ -136,8 +137,7 @@ export const leverageWithErc4626Deposit = async ({

if (isLoanAssetInput) {
// WHY: the user provided an exact amount of loan-token assets, so this leg should deposit that
// exact asset amount into the vault. The share floor is the exact quote returned by previewDeposit,
// not a swap-style slippage floor.
// exact asset amount into the vault with a share floor derived from the ERC4626 preview.
txs.push(
encodeFunctionData({
abi: morphoBundlerAbi,
Expand All @@ -150,11 +150,11 @@ export const leverageWithErc4626Deposit = async ({
const callbackTxs: `0x${string}`[] = [
encodeFunctionData({
abi: morphoBundlerAbi,
functionName: 'erc4626Mint',
// Mint the exact flash-leg collateral shares quoted off-chain. If the vault now requires
// more than flashLoanAssetAmount assets, revert early instead of minting fewer shares and drifting
// above the previewed leverage target. If it requires fewer, residual loan assets are swept later.
args: [route.collateralVault, flashLegCollateralTokenAmount, flashLoanAssetAmount, bundlerAddress],
functionName: 'erc4626Deposit',
// Deposit the exact flash-loaned assets and enforce a minimum share floor. This keeps the
// flash leg executable even if the vault's share price moved enough that an exact-share mint
// would require more assets than the flash loan supplied.
args: [route.collateralVault, flashLoanAssetAmount, flashLegCollateralTokenAmount, bundlerAddress],
}),
];

Expand Down
60 changes: 22 additions & 38 deletions src/hooks/useDeleverageQuote.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { zeroAddress } from 'viem';
import { useReadContracts } from 'wagmi';
import { useReadContract } from 'wagmi';
import { erc4626Abi } from '@/abis/erc4626';
import { fetchVeloraPriceRoute, type VeloraPriceRoute } from '@/features/swap/api/velora';
import { withSlippageCeil, withSlippageFloor } from './leverage/math';
Expand Down Expand Up @@ -37,7 +37,7 @@ export type DeleverageQuote = {
* Quotes how much debt can be repaid when unwinding a given collateral amount.
*
* Routes:
* - ERC4626: `withdrawCollateralAmount -> previewRedeem` and `previewWithdraw(currentDebt)`
* - ERC4626: `withdrawCollateralAmount -> previewRedeem`, then apply the slippage buffer to that conversion
* - Swap: Velora SELL quote for repay preview and Velora BUY quote for max collateral to close debt
*/
export function useDeleverageQuote({
Expand All @@ -58,39 +58,21 @@ export function useDeleverageQuote({
const erc4626VaultAddress = route?.kind === 'erc4626' ? route.collateralVault : zeroAddress;

const {
data: erc4626PreviewData,
isLoading: isLoadingErc4626Previews,
error: erc4626PreviewError,
} = useReadContracts({
contracts: [
{
address: erc4626VaultAddress,
abi: erc4626Abi,
functionName: 'previewRedeem',
// `previewRedeem(collateral shares)` -> loan-token assets returned by redeeming that exact share amount.
args: [withdrawCollateralAmount],
chainId,
},
{
address: erc4626VaultAddress,
abi: erc4626Abi,
functionName: 'previewWithdraw',
// `previewWithdraw(buffered loan assets)` -> collateral shares needed to withdraw that exact asset amount.
args: [bufferedBorrowAssets],
chainId,
},
],
allowFailure: false,
data: previewRedeemLoanAssetsFromCollateralShares,
isLoading: isLoadingErc4626PreviewRedeem,
error: erc4626PreviewRedeemError,
} = useReadContract({
address: erc4626VaultAddress,
abi: erc4626Abi,
functionName: 'previewRedeem',
// `previewRedeem(collateral shares)` -> loan-token assets returned by redeeming that exact share amount.
args: [withdrawCollateralAmount],
chainId,
query: {
enabled: route?.kind === 'erc4626' && (withdrawCollateralAmount > 0n || bufferedBorrowAssets > 0n),
enabled: route?.kind === 'erc4626' && withdrawCollateralAmount > 0n,
},
});

const [previewRedeemLoanAssetsFromCollateralShares, previewWithdrawCollateralSharesForBufferedDebtAssets] = useMemo(
() => (erc4626PreviewData as readonly [bigint, bigint] | undefined) ?? [0n, 0n],
[erc4626PreviewData],
);

const swapRepayQuoteQuery = useQuery({
queryKey: [
'deleverage-swap-repay-quote',
Expand Down Expand Up @@ -184,8 +166,8 @@ export function useDeleverageQuote({
const rawRouteRepayAmount = useMemo(() => {
if (!route || withdrawCollateralAmount <= 0n) return 0n;
if (route.kind === 'swap') return swapRepayQuote.rawRouteRepayAmount;
return previewRedeemLoanAssetsFromCollateralShares;
}, [route, withdrawCollateralAmount, swapRepayQuote.rawRouteRepayAmount, previewRedeemLoanAssetsFromCollateralShares]);
return withSlippageFloor((previewRedeemLoanAssetsFromCollateralShares as bigint | undefined) ?? 0n, slippageBps);
}, [route, withdrawCollateralAmount, swapRepayQuote.rawRouteRepayAmount, previewRedeemLoanAssetsFromCollateralShares, slippageBps]);

const repayAmount = useMemo(() => {
if (rawRouteRepayAmount <= 0n) return 0n;
Expand All @@ -203,14 +185,16 @@ export function useDeleverageQuote({
if (!userAddress || swapMaxCollateralForDebtQuery.error) return 0n;
return swapMaxCollateralForDebtQuery.data?.maxCollateralForDebtRepay ?? 0n;
}
return previewWithdrawCollateralSharesForBufferedDebtAssets;
return rawRouteRepayAmount >= bufferedBorrowAssets ? withdrawCollateralAmount : 0n;
}, [
route,
currentBorrowAssets,
swapMaxCollateralForDebtQuery.data,
swapMaxCollateralForDebtQuery.error,
userAddress,
previewWithdrawCollateralSharesForBufferedDebtAssets,
rawRouteRepayAmount,
bufferedBorrowAssets,
withdrawCollateralAmount,
]);

const closeRouteRequiresResolution = useMemo(() => {
Expand Down Expand Up @@ -283,7 +267,7 @@ export function useDeleverageQuote({
if (!routeError) return null;
return routeError instanceof Error ? routeError.message : 'Failed to quote Velora swap route for deleverage.';
}
const routeError = erc4626PreviewError;
const routeError = erc4626PreviewRedeemError;
if (!routeError) return null;
return routeError instanceof Error ? routeError.message : 'Failed to quote deleverage route';
}, [
Expand All @@ -295,14 +279,14 @@ export function useDeleverageQuote({
currentBorrowShares,
swapMaxCollateralForDebtQuery.error,
swapRepayQuoteQuery.error,
erc4626PreviewError,
erc4626PreviewRedeemError,
]);

const isLoading =
!!route &&
(route.kind === 'swap'
? swapRepayQuoteQuery.isLoading || swapRepayQuoteQuery.isFetching || closeRouteRequiresResolution
: isLoadingErc4626Previews);
: isLoadingErc4626PreviewRedeem);

return {
repayAmount,
Expand Down
Loading