diff --git a/src/constants/repay.ts b/src/constants/repay.ts
new file mode 100644
index 00000000..9b4e1077
--- /dev/null
+++ b/src/constants/repay.ts
@@ -0,0 +1,4 @@
+export const BPS_DENOMINATOR = 10_000n;
+
+// 0.10% buffer for repay-by-shares.
+export const REPAY_BY_SHARES_BUFFER_BPS = 10n;
diff --git a/src/features/positions/components/borrowed-morpho-blue-table.tsx b/src/features/positions/components/borrowed-morpho-blue-table.tsx
index 65b7fca7..da9ccd8d 100644
--- a/src/features/positions/components/borrowed-morpho-blue-table.tsx
+++ b/src/features/positions/components/borrowed-morpho-blue-table.tsx
@@ -107,9 +107,6 @@ export function BorrowedMorphoBlueTable({ account, positions, onRefetch, isRefet
showOracle
showLltv
/>
- {row.hasResidualCollateral && (
- Inactive
- )}
diff --git a/src/hooks/useBorrowTransaction.ts b/src/hooks/useBorrowTransaction.ts
index 08ea7623..9ccee9e8 100644
--- a/src/hooks/useBorrowTransaction.ts
+++ b/src/hooks/useBorrowTransaction.ts
@@ -6,7 +6,7 @@ import { formatBalance } from '@/utils/balance';
import { getBundlerV2, MONARCH_TX_IDENTIFIER } from '@/utils/morpho';
import type { Market } from '@/utils/types';
import { useERC20Approval } from './useERC20Approval';
-import { useMorphoAuthorization } from './useMorphoAuthorization';
+import { useBundlerAuthorizationStep } from './useBundlerAuthorizationStep';
import { usePermit2 } from './usePermit2';
import { useAppSettings } from '@/stores/useAppSettings';
import { useStyledToast } from './useStyledToast';
@@ -45,11 +45,12 @@ export function useBorrowTransaction({ market, collateralAmount, borrowAmount, o
const bundlerAddress = getBundlerV2(market.morphoBlue.chain.id);
// Hook for Morpho bundler authorization (both sig and tx)
- const { isBundlerAuthorized, isAuthorizingBundler, authorizeBundlerWithSignature, authorizeWithTransaction, refetchIsBundlerAuthorized } =
- useMorphoAuthorization({
+ const { isBundlerAuthorized, isAuthorizingBundler, ensureBundlerAuthorization, refetchIsBundlerAuthorized } = useBundlerAuthorizationStep(
+ {
chainId: market.morphoBlue.chain.id,
- authorized: bundlerAddress,
- });
+ bundlerAddress: bundlerAddress as Address,
+ },
+ );
// Get approval for collateral token
const {
@@ -165,11 +166,9 @@ export function useBorrowTransaction({ market, collateralAmount, borrowAmount, o
tracking.update('execute');
// Bundler authorization may still be needed for the borrow operation
- if (!isBundlerAuthorized) {
- const bundlerAuthSigTx = await authorizeBundlerWithSignature();
- if (bundlerAuthSigTx) {
- transactions.push(bundlerAuthSigTx);
- }
+ const { authorizationTxData } = await ensureBundlerAuthorization({ mode: 'signature' });
+ if (authorizationTxData) {
+ transactions.push(authorizationTxData);
}
} else if (usePermit2Setting) {
// --- Permit2 Flow ---
@@ -180,9 +179,9 @@ export function useBorrowTransaction({ market, collateralAmount, borrowAmount, o
}
tracking.update('authorize_bundler_sig');
- const bundlerAuthSigTx = await authorizeBundlerWithSignature(); // Get signature for Bundler auth if needed
- if (bundlerAuthSigTx) {
- transactions.push(bundlerAuthSigTx);
+ const { authorizationTxData } = await ensureBundlerAuthorization({ mode: 'signature' }); // Get signature for Bundler auth if needed
+ if (authorizationTxData) {
+ transactions.push(authorizationTxData);
await new Promise((resolve) => setTimeout(resolve, 800)); // UI delay
}
@@ -212,8 +211,8 @@ export function useBorrowTransaction({ market, collateralAmount, borrowAmount, o
} else {
// --- Standard ERC20 Flow ---
tracking.update('authorize_bundler_tx');
- const bundlerTxAuthorized = await authorizeWithTransaction(); // Authorize Bundler via TX if needed
- if (!bundlerTxAuthorized) {
+ const { authorized } = await ensureBundlerAuthorization({ mode: 'transaction' }); // Authorize Bundler via TX if needed
+ if (!authorized) {
throw new Error('Failed to authorize Bundler via transaction.'); // Stop if auth tx fails/is rejected
}
// Wait for tx confirmation implicitly handled by useTransactionWithToast within authorizeWithTransaction
@@ -332,10 +331,8 @@ export function useBorrowTransaction({ market, collateralAmount, borrowAmount, o
usePermit2Setting,
permit2Authorized,
authorizePermit2,
- authorizeBundlerWithSignature,
- isBundlerAuthorized,
+ ensureBundlerAuthorization,
signForBundlers,
- authorizeWithTransaction,
isApproved,
approve,
batchAddUserMarkets,
diff --git a/src/hooks/useBundlerAuthorizationStep.ts b/src/hooks/useBundlerAuthorizationStep.ts
new file mode 100644
index 00000000..a6eca0be
--- /dev/null
+++ b/src/hooks/useBundlerAuthorizationStep.ts
@@ -0,0 +1,68 @@
+import { useCallback } from 'react';
+import type { Address } from 'viem';
+import { useMorphoAuthorization } from './useMorphoAuthorization';
+
+type AuthorizationMode = 'signature' | 'transaction';
+
+type EnsureBundlerAuthorizationParams = {
+ mode: AuthorizationMode;
+};
+
+type UseBundlerAuthorizationStepParams = {
+ chainId: number;
+ bundlerAddress: Address;
+};
+
+type EnsureBundlerAuthorizationResult = {
+ authorized: boolean;
+ authorizationTxData: `0x${string}` | null;
+};
+
+export const useBundlerAuthorizationStep = ({ chainId, bundlerAddress }: UseBundlerAuthorizationStepParams) => {
+ const { isBundlerAuthorized, isAuthorizingBundler, authorizeBundlerWithSignature, authorizeWithTransaction, refetchIsBundlerAuthorized } =
+ useMorphoAuthorization({
+ chainId,
+ authorized: bundlerAddress,
+ });
+
+ const ensureBundlerAuthorization = useCallback(
+ async ({ mode }: EnsureBundlerAuthorizationParams): Promise => {
+ if (isBundlerAuthorized === true) {
+ return {
+ authorized: true,
+ authorizationTxData: null,
+ };
+ }
+
+ if (mode === 'signature') {
+ const authorizationTxData = await authorizeBundlerWithSignature();
+ if (authorizationTxData) {
+ return {
+ authorized: true,
+ authorizationTxData: authorizationTxData as `0x${string}`,
+ };
+ }
+
+ const refreshedAuthorization = await refetchIsBundlerAuthorized();
+ return {
+ authorized: refreshedAuthorization.data === true,
+ authorizationTxData: null,
+ };
+ }
+
+ const authorized = await authorizeWithTransaction();
+ return {
+ authorized,
+ authorizationTxData: null,
+ };
+ },
+ [isBundlerAuthorized, authorizeBundlerWithSignature, authorizeWithTransaction, refetchIsBundlerAuthorized],
+ );
+
+ return {
+ isBundlerAuthorized,
+ isAuthorizingBundler,
+ ensureBundlerAuthorization,
+ refetchIsBundlerAuthorized,
+ };
+};
diff --git a/src/hooks/useERC20Approval.ts b/src/hooks/useERC20Approval.ts
index 111ca8b7..4040313e 100644
--- a/src/hooks/useERC20Approval.ts
+++ b/src/hooks/useERC20Approval.ts
@@ -47,24 +47,29 @@ export function useERC20Approval({
successDescription: `Successfully approved ${tokenSymbol} for spender ${spender.slice(2, 8)}`,
});
- const approve = useCallback(async () => {
- if (!account || !amount) return;
+ const approve = useCallback(
+ async (amountOverride?: bigint) => {
+ const approvalAmount = amountOverride ?? amount;
+ if (!account || !approvalAmount) return;
- await sendTransactionAsync({
- account,
- to: token,
- data: encodeFunctionData({
- abi: erc20Abi,
- functionName: 'approve',
- args: [spender, amount],
- }),
- });
+ await sendTransactionAsync({
+ account,
+ to: token,
+ data: encodeFunctionData({
+ abi: erc20Abi,
+ functionName: 'approve',
+ args: [spender, approvalAmount],
+ }),
+ });
- await refetchAllowance();
- }, [account, amount, sendTransactionAsync, token, spender, refetchAllowance]);
+ await refetchAllowance();
+ },
+ [account, amount, sendTransactionAsync, token, spender, refetchAllowance],
+ );
return {
isApproved,
+ allowance: allowance ?? 0n,
approve,
isApproving,
};
diff --git a/src/hooks/usePermit2.ts b/src/hooks/usePermit2.ts
index e44b304b..b96c6d30 100644
--- a/src/hooks/usePermit2.ts
+++ b/src/hooks/usePermit2.ts
@@ -50,54 +50,59 @@ export function usePermit2({ user, chainId = 1, token, spender, refetchInterval
const permit2Authorized = useMemo(() => !!allowanceToPermit2 && allowanceToPermit2 > amount, [allowanceToPermit2, amount]);
- const signForBundlers = useCallback(async () => {
- if (!user || !spender || !token) throw new Error('User, spender, or token not provided');
+ const signForBundlers = useCallback(
+ async (amountOverride?: bigint) => {
+ if (!user || !spender || !token) throw new Error('User, spender, or token not provided');
- const deadline = moment.now() + 600;
+ const deadline = moment.now() + 600;
+ const permitAmount = amountOverride ?? amount;
- const nonce = packedAllowance ? ((packedAllowance as number[])[2] as number) : 0;
+ const nonce = packedAllowance ? ((packedAllowance as number[])[2] as number) : 0;
- const permitSingle = {
- details: {
- token: token,
- amount: amount,
- expiration: deadline,
- nonce,
- },
- spender: spender,
- sigDeadline: BigInt(deadline),
- };
+ const permitSingle = {
+ details: {
+ token,
+ amount: permitAmount,
+ expiration: deadline,
+ nonce,
+ },
+ spender,
+ sigDeadline: BigInt(deadline),
+ };
- // sign erc712 signature for permit2
- const sigs = await signTypedDataAsync({
- domain: {
- name: 'Permit2',
- chainId,
- verifyingContract: PERMIT2_ADDRESS,
- },
- types: {
- PermitDetails: [
- { name: 'token', type: 'address' },
- { name: 'amount', type: 'uint160' },
- { name: 'expiration', type: 'uint48' },
- { name: 'nonce', type: 'uint48' },
- ],
- // (PermitDetails details,address spender,uint256 sigDeadline)
- PermitSingle: [
- { name: 'details', type: 'PermitDetails' },
- { name: 'spender', type: 'address' },
- { name: 'sigDeadline', type: 'uint256' },
- ],
- },
- primaryType: 'PermitSingle',
- message: permitSingle,
- });
+ // sign erc712 signature for permit2
+ const sigs = await signTypedDataAsync({
+ domain: {
+ name: 'Permit2',
+ chainId,
+ verifyingContract: PERMIT2_ADDRESS,
+ },
+ types: {
+ PermitDetails: [
+ { name: 'token', type: 'address' },
+ { name: 'amount', type: 'uint160' },
+ { name: 'expiration', type: 'uint48' },
+ { name: 'nonce', type: 'uint48' },
+ ],
+ // (PermitDetails details,address spender,uint256 sigDeadline)
+ PermitSingle: [
+ { name: 'details', type: 'PermitDetails' },
+ { name: 'spender', type: 'address' },
+ { name: 'sigDeadline', type: 'uint256' },
+ ],
+ },
+ primaryType: 'PermitSingle',
+ message: permitSingle,
+ });
- return { sigs, permitSingle };
- }, [user, spender, token, chainId, packedAllowance, amount, signTypedDataAsync]);
+ return { sigs, permitSingle };
+ },
+ [user, spender, token, chainId, packedAllowance, amount, signTypedDataAsync],
+ );
return {
permit2Authorized,
+ permit2Allowance: allowanceToPermit2,
authorizePermit2,
signForBundlers,
isLoading: isLoadingAllowance,
diff --git a/src/hooks/useRebalance.ts b/src/hooks/useRebalance.ts
index 6f16d2b0..4df5eb96 100644
--- a/src/hooks/useRebalance.ts
+++ b/src/hooks/useRebalance.ts
@@ -8,7 +8,7 @@ import { getBundlerV2, MONARCH_TX_IDENTIFIER } from '@/utils/morpho';
import type { GroupedPosition, RebalanceAction } from '@/utils/types';
import { GAS_COSTS, GAS_MULTIPLIER_NUMERATOR, GAS_MULTIPLIER_DENOMINATOR } from '@/features/markets/components/constants';
import { useERC20Approval } from './useERC20Approval';
-import { useMorphoAuthorization } from './useMorphoAuthorization';
+import { useBundlerAuthorizationStep } from './useBundlerAuthorizationStep';
import { usePermit2 } from './usePermit2';
import { useAppSettings } from '@/stores/useAppSettings';
import { useStyledToast } from './useStyledToast';
@@ -37,11 +37,12 @@ export const useRebalance = (groupedPosition: GroupedPosition, onRebalance?: ()
const totalAmount = rebalanceActions.reduce((acc, action) => acc + BigInt(action.amount), BigInt(0));
// Hook for Morpho bundler authorization (both sig and tx)
- const { isBundlerAuthorized, isAuthorizingBundler, authorizeBundlerWithSignature, authorizeWithTransaction, refetchIsBundlerAuthorized } =
- useMorphoAuthorization({
+ const { isBundlerAuthorized, isAuthorizingBundler, ensureBundlerAuthorization, refetchIsBundlerAuthorized } = useBundlerAuthorizationStep(
+ {
chainId: groupedPosition.chainId,
- authorized: bundlerAddress,
- });
+ bundlerAddress: bundlerAddress as Address,
+ },
+ );
// Hook for Permit2 handling
const {
@@ -266,9 +267,9 @@ export const useRebalance = (groupedPosition: GroupedPosition, onRebalance?: ()
}
tracking.update('authorize_bundler_sig');
- const bundlerAuthSigTx = await authorizeBundlerWithSignature(); // Get signature for Bundler auth if needed
- if (bundlerAuthSigTx) {
- transactions.push(bundlerAuthSigTx);
+ const { authorizationTxData } = await ensureBundlerAuthorization({ mode: 'signature' }); // Get signature for Bundler auth if needed
+ if (authorizationTxData) {
+ transactions.push(authorizationTxData);
await new Promise((resolve) => setTimeout(resolve, 800)); // UI delay
}
@@ -292,8 +293,8 @@ export const useRebalance = (groupedPosition: GroupedPosition, onRebalance?: ()
} else {
// --- Standard ERC20 Flow ---
tracking.update('authorize_bundler_tx');
- const bundlerTxAuthorized = await authorizeWithTransaction(); // Authorize Bundler via TX if needed
- if (!bundlerTxAuthorized) {
+ const { authorized } = await ensureBundlerAuthorization({ mode: 'transaction' }); // Authorize Bundler via TX if needed
+ if (!authorized) {
throw new Error('Failed to authorize Bundler via transaction.'); // Stop if auth tx fails/is rejected
}
// Wait for tx confirmation implicitly handled by useTransactionWithToast within authorizeWithTransaction
@@ -385,9 +386,8 @@ export const useRebalance = (groupedPosition: GroupedPosition, onRebalance?: ()
usePermit2Setting,
permit2Authorized,
authorizePermit2,
- authorizeBundlerWithSignature,
+ ensureBundlerAuthorization,
signForBundlers,
- authorizeWithTransaction,
isTokenApproved,
approveToken,
generateRebalanceTxData,
diff --git a/src/hooks/useRepayTransaction.ts b/src/hooks/useRepayTransaction.ts
index 5539c7b0..192f67fa 100644
--- a/src/hooks/useRepayTransaction.ts
+++ b/src/hooks/useRepayTransaction.ts
@@ -1,11 +1,16 @@
import { useCallback } from 'react';
+import { MathLib } from '@morpho-org/blue-sdk';
import { type Address, encodeFunctionData } from 'viem';
-import { useConnection } from 'wagmi';
+import { useConnection, usePublicClient } from 'wagmi';
import morphoBundlerAbi from '@/abis/bundlerV2';
+import morphoAbi from '@/abis/morpho';
+import { BPS_DENOMINATOR, REPAY_BY_SHARES_BUFFER_BPS } from '@/constants/repay';
import { formatBalance } from '@/utils/balance';
-import { getBundlerV2, MONARCH_TX_IDENTIFIER } from '@/utils/morpho';
+import { getBundlerV2, getMorphoAddress, MONARCH_TX_IDENTIFIER } from '@/utils/morpho';
+import { estimateRepayAssetsForBorrowShares } from '@/utils/repay-estimation';
import type { Market, MarketPosition } from '@/utils/types';
import { useERC20Approval } from './useERC20Approval';
+import { useBundlerAuthorizationStep } from './useBundlerAuthorizationStep';
import { usePermit2 } from './usePermit2';
import { useAppSettings } from '@/stores/useAppSettings';
import { useStyledToast } from './useStyledToast';
@@ -21,6 +26,53 @@ type UseRepayTransactionProps = {
onSuccess?: () => void;
};
+type RepayExecutionPlan = {
+ repayTransferAmount: bigint;
+ repayBySharesToRepay: bigint;
+};
+
+const calculateRepayBySharesBufferedAssets = (baseAssets: bigint): bigint => {
+ if (baseAssets <= 0n) return 0n;
+ const bpsBuffer = MathLib.mulDivUp(baseAssets, REPAY_BY_SHARES_BUFFER_BPS, BPS_DENOMINATOR);
+ return baseAssets + bpsBuffer;
+};
+
+const irmAbi = [
+ {
+ inputs: [
+ {
+ components: [
+ { internalType: 'address', name: 'loanToken', type: 'address' },
+ { internalType: 'address', name: 'collateralToken', type: 'address' },
+ { internalType: 'address', name: 'oracle', type: 'address' },
+ { internalType: 'address', name: 'irm', type: 'address' },
+ { internalType: 'uint256', name: 'lltv', type: 'uint256' },
+ ],
+ internalType: 'struct MarketParams',
+ name: 'marketParams',
+ type: 'tuple',
+ },
+ {
+ components: [
+ { internalType: 'uint128', name: 'totalSupplyAssets', type: 'uint128' },
+ { internalType: 'uint128', name: 'totalSupplyShares', type: 'uint128' },
+ { internalType: 'uint128', name: 'totalBorrowAssets', type: 'uint128' },
+ { internalType: 'uint128', name: 'totalBorrowShares', type: 'uint128' },
+ { internalType: 'uint128', name: 'lastUpdate', type: 'uint128' },
+ { internalType: 'uint128', name: 'fee', type: 'uint128' },
+ ],
+ internalType: 'struct Market',
+ name: 'market',
+ type: 'tuple',
+ },
+ ],
+ name: 'borrowRateView',
+ outputs: [{ internalType: 'uint256', name: '', type: 'uint256' }],
+ stateMutability: 'view',
+ type: 'function',
+ },
+] as const;
+
export function useRepayTransaction({
market,
currentPosition,
@@ -35,22 +87,29 @@ export function useRepayTransaction({
const tracking = useTransactionTracking('repay');
const { address: account, chainId } = useConnection();
+ const publicClient = usePublicClient({ chainId: market.morphoBlue.chain.id });
const toast = useStyledToast();
+ const bundlerAddress = getBundlerV2(market.morphoBlue.chain.id);
const useRepayByShares = repayShares > 0n;
+ const repayBySharesBaseAssets = useRepayByShares && repayAssets === 0n ? BigInt(currentPosition?.state.borrowAssets ?? 0) : repayAssets;
+ const repayAmountToApprove = useRepayByShares ? calculateRepayBySharesBufferedAssets(repayBySharesBaseAssets) : repayAssets;
- // If we're using repay by shares, we need to add a small amount as buffer to the repay amount we're approving
- const repayAmountToApprove = useRepayByShares ? repayAssets + 1000n : repayAssets;
+ const { isAuthorizingBundler, ensureBundlerAuthorization } = useBundlerAuthorizationStep({
+ chainId: market.morphoBlue.chain.id,
+ bundlerAddress: bundlerAddress as Address,
+ });
// Get approval for loan token
const {
authorizePermit2,
permit2Authorized,
+ permit2Allowance,
isLoading: isLoadingPermit2,
signForBundlers,
} = usePermit2({
user: account as `0x${string}`,
- spender: getBundlerV2(market.morphoBlue.chain.id),
+ spender: bundlerAddress,
token: market.loanAsset.address as `0x${string}`,
refetchInterval: 10_000,
chainId: market.morphoBlue.chain.id,
@@ -58,9 +117,9 @@ export function useRepayTransaction({
amount: repayAmountToApprove,
});
- const { isApproved, approve } = useERC20Approval({
+ const { isApproved, allowance, approve } = useERC20Approval({
token: market.loanAsset.address as Address,
- spender: getBundlerV2(market.morphoBlue.chain.id),
+ spender: bundlerAddress as Address,
amount: repayAmountToApprove,
tokenSymbol: market.loanAsset.symbol,
});
@@ -112,165 +171,337 @@ export function useRepayTransaction({
[market.loanAsset.symbol],
);
- // Core transaction execution logic
- const executeRepayTransaction = useCallback(async () => {
- if (!currentPosition) {
- toast.error('No Position', 'No active position found');
- return;
- }
+ const fetchLiveRepayBySharesMaxAssets = useCallback(
+ async (fallbackMaxAssets: bigint): Promise<{ maxAssetsToRepay: bigint; sharesToRepay: bigint }> => {
+ if (!useRepayByShares || repayShares <= 0n || !account || !publicClient) {
+ return { maxAssetsToRepay: fallbackMaxAssets, sharesToRepay: repayShares };
+ }
- try {
- const txs: `0x${string}`[] = [];
+ console.info('[repay] fetching results: live quote start', {
+ marketId: market.uniqueKey,
+ account,
+ repayShares: repayShares.toString(),
+ fallbackMaxAssets: fallbackMaxAssets.toString(),
+ });
- // Add token approval and transfer transactions if repaying
- if ((repayAssets > 0n || repayShares > 0n) && repayAmountToApprove > 0n) {
- if (usePermit2Setting) {
- const { sigs, permitSingle } = await signForBundlers();
- const tx1 = encodeFunctionData({
- abi: morphoBundlerAbi,
- functionName: 'approve2',
- args: [permitSingle, sigs, false],
- });
+ try {
+ const marketParams = {
+ 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),
+ };
+
+ const morphoAddress = getMorphoAddress(market.morphoBlue.chain.id) as Address;
+ const [marketResult, positionResult] = await publicClient.multicall({
+ contracts: [
+ {
+ address: morphoAddress,
+ abi: morphoAbi,
+ functionName: 'market',
+ args: [market.uniqueKey as `0x${string}`],
+ },
+ {
+ address: morphoAddress,
+ abi: morphoAbi,
+ functionName: 'position',
+ args: [market.uniqueKey as `0x${string}`, account as Address],
+ },
+ ],
+ allowFailure: false,
+ });
- // transferFrom with permit2
- const tx2 = encodeFunctionData({
- abi: morphoBundlerAbi,
- functionName: 'transferFrom2',
- args: [market.loanAsset.address as Address, repayAmountToApprove],
+ const marketState = marketResult as readonly bigint[];
+ const positionState = positionResult as readonly bigint[];
+ const liveBorrowShares = positionState[1] ?? 0n;
+ const liveTotalBorrowAssets = marketState[2] ?? 0n;
+ const liveTotalBorrowShares = marketState[3] ?? 0n;
+ const liveLastUpdate = marketState[4] ?? 0n;
+ const sharesToRepay = repayShares > liveBorrowShares ? liveBorrowShares : repayShares;
+
+ if (sharesToRepay <= 0n || liveTotalBorrowShares <= 0n) {
+ console.info('[repay] fetching results: live quote done', {
+ marketId: market.uniqueKey,
+ sharesToRepay: sharesToRepay.toString(),
+ maxAssetsToRepay: '0',
+ reason: 'empty-borrow',
});
-
- txs.push(tx1);
- txs.push(tx2);
- } else {
- // For standard ERC20 flow, we only need to transfer the tokens
- txs.push(
- encodeFunctionData({
- abi: morphoBundlerAbi,
- functionName: 'erc20TransferFrom',
- args: [market.loanAsset.address as Address, repayAmountToApprove],
- }),
- );
+ return { maxAssetsToRepay: 0n, sharesToRepay };
}
- }
-
- // Add the repay transaction if there's an amount to repay
- if (useRepayByShares) {
- const morphoRepayTx = encodeFunctionData({
- abi: morphoBundlerAbi,
- functionName: 'morphoRepay',
+ const borrowRate = await publicClient.readContract({
+ address: market.irmAddress as Address,
+ abi: irmAbi,
+ functionName: 'borrowRateView',
args: [
+ marketParams,
{
- 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),
+ totalSupplyAssets: marketState[0],
+ totalSupplyShares: marketState[1],
+ totalBorrowAssets: marketState[2],
+ totalBorrowShares: marketState[3],
+ lastUpdate: marketState[4],
+ fee: marketState[5],
},
- 0n, // assets to repay (0)
- repayShares, // shares to repay
- repayAmountToApprove, // Slippage amount: max amount to repay
- account as Address,
- '0x', // bytes
],
});
- txs.push(morphoRepayTx);
- // build another erc20 transfer action, to transfer any surplus back (unused loan assets) back to the user
- const refundTx = encodeFunctionData({
- abi: morphoBundlerAbi,
- functionName: 'erc20Transfer',
- args: [market.loanAsset.address as Address, account as Address, repayAmountToApprove],
+ const latestBlock = await publicClient.getBlock({ blockTag: 'latest' });
+ const estimation = estimateRepayAssetsForBorrowShares({
+ repayShares: sharesToRepay,
+ totalBorrowAssets: liveTotalBorrowAssets,
+ totalBorrowShares: liveTotalBorrowShares,
+ lastUpdate: liveLastUpdate,
+ borrowRate,
+ currentTimestamp: latestBlock.timestamp,
});
- txs.push(refundTx);
- } else if (repayAssets > 0n) {
- console.log('repayAssets', repayAssets);
- const minShares = 1n;
- const morphoRepayTx = encodeFunctionData({
- abi: morphoBundlerAbi,
- functionName: 'morphoRepay',
- 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),
- },
- repayAssets, // assets to repay
- 0n, // shares to repay (0)
- minShares, // Slippage amount: min shares to repay
- account as Address,
- '0x', // bytes
- ],
+
+ console.info('[repay] fetching results: live quote done', {
+ marketId: market.uniqueKey,
+ sharesToRepay: sharesToRepay.toString(),
+ elapsedSeconds: estimation.elapsedSeconds.toString(),
+ assetsToRepayShares: estimation.assetsToRepayShares.toString(),
+ safetyAssetsBuffer: estimation.safetyAssetsBuffer.toString(),
+ blockDriftBuffer: estimation.blockDriftBuffer.toString(),
+ maxAssetsToRepay: estimation.maxAssetsToRepay.toString(),
});
- txs.push(morphoRepayTx);
- }
- // Add the withdraw transaction if there's an amount to withdraw
- if (withdrawAmount > 0n) {
- const morphoWithdrawTx = encodeFunctionData({
- abi: morphoBundlerAbi,
- functionName: 'morphoWithdrawCollateral',
- 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),
- },
- withdrawAmount,
- account as Address,
- ],
+ return {
+ maxAssetsToRepay: estimation.maxAssetsToRepay,
+ sharesToRepay,
+ };
+ } catch (error: unknown) {
+ console.warn('[repay] fetching results: live quote failed, fallback used', {
+ marketId: market.uniqueKey,
+ fallbackMaxAssets: fallbackMaxAssets.toString(),
+ error,
});
- txs.push(morphoWithdrawTx);
+ return {
+ maxAssetsToRepay: fallbackMaxAssets,
+ sharesToRepay: repayShares,
+ };
}
+ },
+ [account, market, publicClient, repayShares, useRepayByShares],
+ );
- tracking.update('repaying');
+ const resolveRepayBySharesEstimation = useCallback(async (): Promise<{ maxAssetsToRepay: bigint; sharesToRepay: bigint }> => {
+ if (!useRepayByShares) {
+ return { maxAssetsToRepay: 0n, sharesToRepay: 0n };
+ }
+ return fetchLiveRepayBySharesMaxAssets(repayAmountToApprove);
+ }, [fetchLiveRepayBySharesMaxAssets, repayAmountToApprove, useRepayByShares]);
+
+ const buildRepayExecutionPlan = useCallback(async (): Promise => {
+ if (!useRepayByShares) {
+ const plan = {
+ repayTransferAmount: repayAssets,
+ repayBySharesToRepay: 0n,
+ };
+ console.info('[repay] fetching results: execution plan', {
+ marketId: market.uniqueKey,
+ repayTransferAmount: plan.repayTransferAmount.toString(),
+ });
+ return plan;
+ }
- // Add timeout to prevent rabby reverting
- await new Promise((resolve) => setTimeout(resolve, 800));
+ const estimation = await resolveRepayBySharesEstimation();
+ const plan = {
+ repayTransferAmount: estimation.maxAssetsToRepay,
+ repayBySharesToRepay: estimation.sharesToRepay,
+ };
+ console.info('[repay] fetching results: execution plan', {
+ marketId: market.uniqueKey,
+ repayTransferAmount: plan.repayTransferAmount.toString(),
+ repayBySharesToRepay: plan.repayBySharesToRepay.toString(),
+ });
+ return plan;
+ }, [market.uniqueKey, repayAssets, resolveRepayBySharesEstimation, useRepayByShares]);
- await sendTransactionAsync({
- account,
- to: getBundlerV2(market.morphoBlue.chain.id),
- data: (encodeFunctionData({
- abi: morphoBundlerAbi,
- functionName: 'multicall',
- args: [txs],
- }) + MONARCH_TX_IDENTIFIER) as `0x${string}`,
- });
+ // Core transaction execution logic
+ const executeRepayTransaction = useCallback(
+ async (executionPlan: RepayExecutionPlan) => {
+ if (!currentPosition) {
+ toast.error('No Position', 'No active position found');
+ return;
+ }
- tracking.complete();
- } catch (error: unknown) {
- console.error('Error in repay transaction:', error);
- tracking.fail();
- if (error instanceof Error) {
- if (error.message.includes('User rejected')) {
- toast.error('Transaction rejected', 'Transaction rejected by user');
+ try {
+ const txs: `0x${string}`[] = [];
+ const { repayTransferAmount, repayBySharesToRepay } = executionPlan;
+
+ if (withdrawAmount > 0n) {
+ if (usePermit2Setting) {
+ const { authorizationTxData } = await ensureBundlerAuthorization({ mode: 'signature' });
+ if (authorizationTxData) {
+ txs.push(authorizationTxData);
+ }
+ } else {
+ const { authorized } = await ensureBundlerAuthorization({ mode: 'transaction' });
+ if (!authorized) {
+ throw new Error('Failed to authorize Bundler for collateral withdrawal.');
+ }
+ }
+ }
+
+ // Add token approval and transfer transactions if repaying
+ if ((repayAssets > 0n || repayShares > 0n) && repayTransferAmount > 0n) {
+ if (usePermit2Setting) {
+ const { sigs, permitSingle } = await signForBundlers(repayTransferAmount);
+ const tx1 = encodeFunctionData({
+ abi: morphoBundlerAbi,
+ functionName: 'approve2',
+ args: [permitSingle, sigs, false],
+ });
+
+ // transferFrom with permit2
+ const tx2 = encodeFunctionData({
+ abi: morphoBundlerAbi,
+ functionName: 'transferFrom2',
+ args: [market.loanAsset.address as Address, repayTransferAmount],
+ });
+
+ txs.push(tx1);
+ txs.push(tx2);
+ } else {
+ // For standard ERC20 flow, we only need to transfer the tokens
+ txs.push(
+ encodeFunctionData({
+ abi: morphoBundlerAbi,
+ functionName: 'erc20TransferFrom',
+ args: [market.loanAsset.address as Address, repayTransferAmount],
+ }),
+ );
+ }
+ }
+
+ // Add the repay transaction if there's an amount to repay
+
+ if (useRepayByShares && repayBySharesToRepay > 0n && repayTransferAmount > 0n) {
+ const morphoRepayTx = encodeFunctionData({
+ abi: morphoBundlerAbi,
+ functionName: 'morphoRepay',
+ 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),
+ },
+ 0n, // assets to repay (0)
+ repayBySharesToRepay, // shares to repay
+ repayTransferAmount, // Slippage amount: max amount to repay
+ account as Address,
+ '0x', // bytes
+ ],
+ });
+ txs.push(morphoRepayTx);
+
+ // build another erc20 transfer action, to transfer any surplus back (unused loan assets) back to the user
+ const refundTx = encodeFunctionData({
+ abi: morphoBundlerAbi,
+ functionName: 'erc20Transfer',
+ args: [market.loanAsset.address as Address, account as Address, repayTransferAmount],
+ });
+ txs.push(refundTx);
+ } else if (repayAssets > 0n) {
+ const minShares = 1n;
+ const morphoRepayTx = encodeFunctionData({
+ abi: morphoBundlerAbi,
+ functionName: 'morphoRepay',
+ 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),
+ },
+ repayAssets, // assets to repay
+ 0n, // shares to repay (0)
+ minShares, // Slippage amount: min shares to repay
+ account as Address,
+ '0x', // bytes
+ ],
+ });
+ txs.push(morphoRepayTx);
+ }
+
+ // Add the withdraw transaction if there's an amount to withdraw
+ if (withdrawAmount > 0n) {
+ const morphoWithdrawTx = encodeFunctionData({
+ abi: morphoBundlerAbi,
+ functionName: 'morphoWithdrawCollateral',
+ 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),
+ },
+ withdrawAmount,
+ account as Address,
+ ],
+ });
+ txs.push(morphoWithdrawTx);
+ }
+
+ if (txs.length === 0) {
+ toast.info('Nothing to execute', 'No repayable debt or withdrawal amount found on-chain.');
+ tracking.complete();
+ return;
+ }
+
+ tracking.update('repaying');
+
+ // Add timeout to prevent rabby reverting
+ await new Promise((resolve) => setTimeout(resolve, 800));
+
+ await sendTransactionAsync({
+ account,
+ to: bundlerAddress,
+ data: (encodeFunctionData({
+ abi: morphoBundlerAbi,
+ functionName: 'multicall',
+ args: [txs],
+ }) + MONARCH_TX_IDENTIFIER) as `0x${string}`,
+ });
+
+ tracking.complete();
+ } catch (error: unknown) {
+ console.error('Error in repay transaction:', error);
+ tracking.fail();
+ if (error instanceof Error) {
+ if (error.message.includes('User rejected')) {
+ toast.error('Transaction rejected', 'Transaction rejected by user');
+ } else {
+ toast.error('Transaction Error', 'Failed to process transaction');
+ }
} else {
- toast.error('Transaction Error', 'Failed to process transaction');
+ toast.error('Transaction Error', 'An unexpected error occurred');
}
- } else {
- toast.error('Transaction Error', 'An unexpected error occurred');
}
- }
- }, [
- account,
- market,
- currentPosition,
- withdrawAmount,
- repayAssets,
- repayShares,
- sendTransactionAsync,
- signForBundlers,
- usePermit2Setting,
- toast,
- useRepayByShares,
- repayAmountToApprove,
- tracking,
- ]);
+ },
+ [
+ account,
+ market,
+ currentPosition,
+ withdrawAmount,
+ repayAssets,
+ repayShares,
+ sendTransactionAsync,
+ signForBundlers,
+ usePermit2Setting,
+ ensureBundlerAuthorization,
+ toast,
+ useRepayByShares,
+ bundlerAddress,
+ tracking,
+ ],
+ );
// Combined approval and repay flow
const approveAndRepay = useCallback(async () => {
@@ -293,16 +524,20 @@ export function useRepayTransaction({
'approve',
);
+ const executionPlan = await buildRepayExecutionPlan();
+
if (usePermit2Setting) {
// Permit2 flow
try {
- await authorizePermit2();
+ if (executionPlan.repayTransferAmount > 0n && permit2Allowance < executionPlan.repayTransferAmount) {
+ await authorizePermit2();
+ }
tracking.update('signing');
// Small delay to prevent UI glitches
await new Promise((resolve) => setTimeout(resolve, 500));
- await executeRepayTransaction();
+ await executeRepayTransaction(executionPlan);
} catch (error: unknown) {
console.error('Error in Permit2 flow:', error);
if (error instanceof Error) {
@@ -318,9 +553,10 @@ export function useRepayTransaction({
}
} else {
// ERC20 approval flow or just withdraw
- if (!isApproved) {
+ const hasRequiredAllowance = allowance >= executionPlan.repayTransferAmount;
+ if (!hasRequiredAllowance) {
try {
- await approve();
+ await approve(executionPlan.repayTransferAmount);
} catch (error: unknown) {
console.error('Error in approval:', error);
tracking.fail();
@@ -338,7 +574,7 @@ export function useRepayTransaction({
}
tracking.update('repaying');
- await executeRepayTransaction();
+ await executeRepayTransaction(executionPlan);
}
} catch (error: unknown) {
console.error('Error in approveAndRepay:', error);
@@ -347,9 +583,11 @@ export function useRepayTransaction({
}, [
account,
authorizePermit2,
+ buildRepayExecutionPlan,
executeRepayTransaction,
usePermit2Setting,
- isApproved,
+ allowance,
+ permit2Allowance,
approve,
toast,
tracking,
@@ -380,7 +618,21 @@ export function useRepayTransaction({
usePermit2Setting ? 'signing' : 'repaying',
);
- await executeRepayTransaction();
+ const executionPlan = await buildRepayExecutionPlan();
+
+ if (usePermit2Setting && executionPlan.repayTransferAmount > 0n && permit2Allowance < executionPlan.repayTransferAmount) {
+ toast.info('Permit2 approval required', 'Please approve Permit2 before signing this repayment.');
+ tracking.fail();
+ return;
+ }
+
+ if (!usePermit2Setting && allowance < executionPlan.repayTransferAmount) {
+ toast.info('Token approval required', `Please approve ${market.loanAsset.symbol} before submitting repayment.`);
+ tracking.fail();
+ return;
+ }
+
+ await executeRepayTransaction(executionPlan);
} catch (error: unknown) {
console.error('Error in signAndRepay:', error);
tracking.fail();
@@ -394,7 +646,20 @@ export function useRepayTransaction({
toast.error('Transaction Error', 'An unexpected error occurred');
}
}
- }, [account, executeRepayTransaction, toast, tracking, getStepsForFlow, usePermit2Setting, market, repayAssets, withdrawAmount]);
+ }, [
+ account,
+ buildRepayExecutionPlan,
+ executeRepayTransaction,
+ getStepsForFlow,
+ allowance,
+ market,
+ permit2Allowance,
+ repayAssets,
+ toast,
+ tracking,
+ usePermit2Setting,
+ withdrawAmount,
+ ]);
return {
// Transaction tracking
@@ -402,7 +667,7 @@ export function useRepayTransaction({
dismiss: tracking.dismiss,
currentStep: tracking.currentStep as 'approve' | 'signing' | 'repaying' | null,
// State
- isLoadingPermit2,
+ isLoadingPermit2: isLoadingPermit2 || isAuthorizingBundler,
isApproved,
permit2Authorized,
repayPending,
diff --git a/src/utils/repay-estimation.ts b/src/utils/repay-estimation.ts
new file mode 100644
index 00000000..cfc6f80f
--- /dev/null
+++ b/src/utils/repay-estimation.ts
@@ -0,0 +1,63 @@
+import { MathLib, SharesMath } from '@morpho-org/blue-sdk';
+import { BPS_DENOMINATOR } from '@/constants/repay';
+
+type RepayEstimationInputs = {
+ repayShares: bigint;
+ totalBorrowAssets: bigint;
+ totalBorrowShares: bigint;
+ lastUpdate: bigint;
+ borrowRate: bigint;
+ currentTimestamp: bigint;
+ safetyWindowSeconds?: bigint;
+};
+
+type RepayEstimationResult = {
+ elapsedSeconds: bigint;
+ expectedTotalBorrowAssets: bigint;
+ assetsToRepayShares: bigint;
+ safetyAssetsBuffer: bigint;
+ blockDriftBuffer: bigint;
+ maxAssetsToRepay: bigint;
+};
+
+const DEFAULT_SAFETY_WINDOW_SECONDS = 45n;
+const BLOCK_DRIFT_BUFFER_BPS = 1n;
+
+export const estimateRepayAssetsForBorrowShares = ({
+ repayShares,
+ totalBorrowAssets,
+ totalBorrowShares,
+ lastUpdate,
+ borrowRate,
+ currentTimestamp,
+ safetyWindowSeconds = DEFAULT_SAFETY_WINDOW_SECONDS,
+}: RepayEstimationInputs): RepayEstimationResult => {
+ if (repayShares <= 0n) {
+ return {
+ elapsedSeconds: 0n,
+ expectedTotalBorrowAssets: totalBorrowAssets,
+ assetsToRepayShares: 0n,
+ safetyAssetsBuffer: 0n,
+ blockDriftBuffer: 0n,
+ maxAssetsToRepay: 0n,
+ };
+ }
+
+ const elapsedSeconds = currentTimestamp > lastUpdate ? currentTimestamp - lastUpdate : 0n;
+ const accruedInterest = MathLib.wMulDown(totalBorrowAssets, MathLib.wTaylorCompounded(borrowRate, elapsedSeconds));
+ const expectedTotalBorrowAssets = totalBorrowAssets + accruedInterest;
+ const assetsToRepayShares = SharesMath.toAssets(repayShares, expectedTotalBorrowAssets, totalBorrowShares, 'Up');
+
+ const safetyAssetsBuffer = MathLib.wMulUp(assetsToRepayShares, MathLib.wTaylorCompounded(borrowRate, safetyWindowSeconds));
+ const blockDriftBuffer = MathLib.mulDivUp(assetsToRepayShares, BLOCK_DRIFT_BUFFER_BPS, BPS_DENOMINATOR);
+ const maxAssetsToRepay = assetsToRepayShares + safetyAssetsBuffer + blockDriftBuffer;
+
+ return {
+ elapsedSeconds,
+ expectedTotalBorrowAssets,
+ assetsToRepayShares,
+ safetyAssetsBuffer,
+ blockDriftBuffer,
+ maxAssetsToRepay,
+ };
+};