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, + }; +};